For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/stage/4/proposal-top-level-await.md.
  • 简体中文
  • Top-level await S4

    中文标题:顶层 await

    提案概览
    提案速览

    该提案在 ECMAScript 模块中引入了顶层 await,允许模块在顶层直接等待异步操作。它解决了使用 IIAFE 或导出 Promise 来处理模块初始化依赖时的竞争条件和复杂性问题。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    ECMAScript 提案:顶层 await

    提案发起人:Myles Borins, Yulia Startsev。

    作者:Myles Borins, Yulia Startsev, Daniel Ehrenberg, Guy Bedford, Ms2ger 等。

    状态:第 4 阶段

    摘要

    顶层 await 使得模块能够像大的异步函数一样工作:通过顶层 await,ECMAScript 模块(ESM)可以 await 资源,导致其他 import 它们的模块在开始执行模块体之前需要等待。

    动机

    IIAFE 的限制

    由于 await 仅在 async 函数内可用,模块可以将启动时执行的代码放入 async 函数中来包含 await

    // awaiting.mjs
    import { process } from "./some-module.mjs";
    let output;
    async function main() {
      const dynamic = await import(computedModuleSpecifier);
      const data = await fetch(url);
      output = process(dynamic.default, data);
    }
    main();
    export { output };

    这种模式也可以立即调用,可以称之为立即调用异步函数表达式(IIAFE),这是对 IIFE 习语的借鉴。

    // awaiting.mjs
    import { process } from "./some-module.mjs";
    let output;
    (async () => {
      const dynamic = await import(computedModuleSpecifier);
      const data = await fetch(url);
      output = process(dynamic.default, data);
    })();
    export { output };

    这种模式适用于加载模块旨在调度稍后要完成的工作的情况。然而,在此异步函数完成之前,该模块的导出可能被访问:如果另一个模块导入此模块,它可能会看到 outputundefined,或者也可能在 output 被初始化为 process 的返回值之后看到它,这取决于访问发生的时间!例如:

    // usage.mjs
    import { output } from "./awaiting.mjs";
    export function outputPlusValue(value) { return output + value; }
    
    console.log(outputPlusValue(100));
    setTimeout(() => console.log(outputPlusValue(100), 1000);

    变通方法:导出 Promise 来表示初始化

    在没有此功能的情况下,可以从模块导出一个 Promise,并等待它来了解其导出何时就绪。例如,上述模块可以这样写:

    // awaiting.mjs
    import { process } from "./some-module.mjs";
    let output;
    export default (async () => {
      const dynamic = await import(computedModuleSpecifier);
      const data = await fetch(url);
      output = process(dynamic.default, data);
    })();
    export { output };

    然后,该模块可以这样使用:

    // usage.mjs
    import promise, { output } from "./awaiting.mjs";
    export function outputPlusValue(value) { return output + value }
    
    promise.then(() => {
      console.log(outputPlusValue(100));
      setTimeout(() => console.log(outputPlusValue(100), 1000);
    });

    然而,这仍然给我们留下了一些问题:

    • 每个人都必须了解特定的协议才能找到正确的 Promise 来等待模块加载完成。
    • 如果你忘记应用该协议,事情有时可能“碰巧正常工作”(由于竞争条件以某种方式得到了有利的结果)。
    • 在深层模块层级中,Promise 需要被显式地贯穿于链条的每一步。

    例如,这里我们正确地等待了 "./awaiting.mjs" 的 Promise,但忘记重新导出它,因此使用我们模块的模块可能仍然会遇到最初的竞争条件。

    通过显著的额外动态性来避免竞争

    为了避免在访问导出之前忘记等待导出的 Promise 的风险,模块可以改为导出一个 Promise,该 Promise 解析为一个包含导出的对象。

    // awaiting.mjs
    import { process } from "./some-module.mjs";
    export default (async () => {
      const dynamic = await import(computedModuleSpecifier);
      const data = await fetch(url);
      const output = process(dynamic.default, data);
      return { output };
    })();
    // usage.mjs
    import promise from "./awaiting.mjs";
    
    export default promise.then(({output}) => {
      function outputPlusValue(value) { return output + value }
    
      console.log(outputPlusValue(100));
      setTimeout(() => console.log(outputPlusValue(100), 1000);
    
      return { outputPlusValue };
    });

    这种模式是否被广泛采用尚不清楚,但有时在 StackOverflow 上被推荐给面临此类问题的人。

    然而,这种模式具有不理想的效果,即需要将相关源码进行广泛重组为更动态的模式,并将模块体的大部分内容放入 .then() 回调中以便使用动态可用的导入。与 ES2015 模块相比,这在静态可分析性、可测试性、人体工程学等方面是显著的倒退。而且,当你遇到需要 await 的深层依赖时,你需要重新组织所有依赖模块来使用此模式。

    解决方案:顶层 await

    顶层 await 让我们可以依赖模块系统本身来处理所有这些 Promise,并确保一切协调良好。上述示例可以简单地编写和使用如下:

    // awaiting.mjs
    import { process } from "./some-module.mjs";
    const dynamic = import(computedModuleSpecifier);
    const data = fetch(url);
    export const output = process((await dynamic).default, await data);
    // usage.mjs
    import { output } from "./awaiting.mjs";
    export function outputPlusValue(value) { return output + value }
    
    console.log(outputPlusValue(100));
    setTimeout(() => console.log(outputPlusValue(100), 1000);

    awaiting.mjs 中的 await 对应的 Promise 解析之前,usage.mjs 中的任何语句都不会执行,因此竞争条件在设计上被避免了。这是对以下行为的扩展:如果 awaiting.mjs 不使用顶层 await,那么在 awaiting.mjs 加载并执行完其所有语句之前,usage.mjs 中的任何语句都不会执行。

    使用场景

    什么情况下模块等待异步操作加载才有意义?本节给出一些示例。

    动态依赖路径

    const strings = await import(`/i18n/${navigator.language}`);

    这允许模块使用运行时值来确定依赖关系。这对于开发/生产拆分、国际化、环境拆分等场景很有用。

    资源初始化

    const connection = await dbConnector();

    这允许模块代表资源,并在模块永远无法被使用的情况下产生错误。

    依赖回退

    let jQuery;
    try {
      jQuery = await import('https://cdn-a.com/jQuery');
    } catch {
      jQuery = await import('https://cdn-b.com/jQuery');
    }

    WebAssembly 模块

    WebAssembly 模块的“编译”和“实例化”在逻辑上是异步的,这取决于它们的导入:一些 WebAssembly 实现在任一阶段都做了重要的计算,将这些工作转移到另一个线程是很重要的。为了与 JavaScript 模块系统集成,它们需要执行相当于顶层 await 的操作。有关更多详细信息,请参阅 WebAssembly ESM 集成提案

    作为语法糖的语义

    目前,模块会等待其所有依赖项执行完所有语句后,导入才被认为完成,模块代码才能运行。本提案在引入 await 时保持了这一特性:依赖项仍然会执行到底,即使你需要等待该执行异步完成。一种思考方式是,就好像每个模块导出一个 Promise,并且在所有 import 语句之后、模块的其余部分之前,这些 Promise 都被 await

    import { a } from './a.mjs';
    import { b } from './b.mjs';
    import { c } from './c.mjs';
    
    console.log(a, b, c);

    将大致等效于

    import { promise as aPromise, a } from './a.mjs';
    import { promise as bPromise, b } from './b.mjs';
    import { promise as cPromise, c } from './c.mjs';
    
    export const promise = Promise.all([aPromise, bPromise, cPromise]).then(() => {
    
    console.log(a, b, c);
    
    });

    模块 a.mjsb.mjsc.mjs 都会按顺序执行,直到它们各自的第一个 await;然后我们等待所有这些模块恢复并完成求值后再继续。

    常见问题解答

    顶层 await 难道不是一把“坑人的枪”吗?

    如果你看过 该 gist,你可能已经听说过这个批评。

    以下是针对这里一些主要担忧的回应:

    顶层 await 会导致开发者让他们的代码阻塞更长时间吗?

    的确,顶层 await 为开发者提供了一种新工具来让代码等待。我们希望适当的开发者教育能确保对顶层 await 语义有充分理解,以便人们知道在确实希望导入者阻塞时使用它。

    我们过去已经看到这种教育方式效果很好。例如,使用 async/await 编写代码很容易将本可以并行完成的两个任务序列化,但是深思熟虑的开发者教育努力推广了使用 Promise.all 来避免这种风险。

    顶层 await 会鼓励开发者不必要地使用 import(),从而降低可优化性吗?

    许多 JavaScript 开发者正在学习 import() 作为代码拆分的工具。人们正在意识到打包与多个请求之间的关系,并学习如何组合它们以获得良好的应用性能。顶层 await 并不会真正改变这种权衡——从顶层 await 中使用 import() 与从函数中使用它相比,性能影响类似。只要我们能够将顶层 await 的教育材料与现有的关于该性能权衡的知识联系起来,我们希望避免 import() 使用量出现适得其反的增加。

    顶层 await 到底阻塞了什么?

    当一个模块导入另一个模块时,导入模块只有在依赖模块主体执行完毕后才会开始执行其模块主体。如果依赖模块到达顶层 await,那么导入模块的主体必须等待该 await 完成才能开始执行。

    为什么顶层 await 不阻塞相邻模块的导入?

    如果一个模块希望声明自己依赖于另一个模块,以便在模块主体执行前等待另一个模块完成其顶层 await 语句,它可以将该模块声明为导入。

    在如下情况下,打印顺序将是 "X1""Y""X2",因为一个模块“先于”另一个模块导入并不创建隐式依赖。

    // x.mjs
    console.log("X1");
    await new Promise(r => setTimeout(r, 1000));
    console.log("X2");
    // y.mjs
    console.log("Y");
    // z.mjs
    import "./x.mjs";
    import "./y.mjs";

    依赖关系必须被明确注明,以提高并行化的潜力:大多数由于顶层 await 而阻塞的设置工作(例如,上述所有案例研究)可以与来自无关模块的其他设置工作并行完成。当其中一些工作可能高度并行化(例如,网络获取)时,尽可能多地在执行开始时就排队这些工作是很重要的。

    关于代码执行顺序有什么保证?

    模块在开始执行时保持与 ES2015 中相同的顺序。如果模块到达 await,它将让出控制权,让其他模块以同样明确规定的顺序初始化自身。

    具体来说:无论是否使用顶层 await,模块总是最初以 ES2015 中建立的后序遍历开始运行:模块主体的执行从最深的导入开始,按照到达其导入语句的顺序。在到达顶层 await 后,控制权被传递给该遍历顺序中的下一个模块,或者传递给其他异步调度的代码。

    这些保证是否满足 polyfill 的需求?

    目前(在没有顶层 await 的世界中),polyfill 是同步的。因此,先导入一个 polyfill(它修改全局对象),然后导入一个应受该 polyfill 影响的模块的习惯用法,在添加顶层 await 后仍然有效。但是,如果 polyfill 包含顶层 await,那么它需要被依赖它的模块导入才能可靠地生效。

    如果没有一个导入的模块包含顶层 awaitPromise.all 还会发生吗?

    如果模块的执行是确定性同步的(即,如果它及其依赖项都不包含顶层 await),则 Promise.all 中不会有针对该模块的条目。在这种情况下,它将同步运行。

    这些语义保持了 ES Modules 当前的行为,即在不使用顶层 await 时,Evaluate 阶段完全是同步的。这些语义与 Promise 在其他地方的使用形成鲜明对比。有关具体示例和进一步讨论,请参阅 issue #43#47

    依赖项究竟是如何被等待的?它真的使用 Promise.all 吗?

    没有顶层 await 的模块的语义是同步的:整棵树以后序遍历执行,一个模块在其依赖项运行后紧接着运行。包含顶层 await 的模块也应用同样的语义:一旦包含顶层 await 的模块执行完毕,它将触发那些依赖项都已执行完的依赖模块的同步执行。如果模块确实包含顶层 await,即使 await 并未动态到达,整个模块也将被视为“异步的”,就好像它是一个大的 async 函数。因此,当它完成时运行的任何内容都处于 Promise 回调中。然而,从那里开始,如果多个模块依赖它,并且这些模块不包含顶层 await,那么它们将同步运行,之间没有任何 Promise 工作

    顶层 await 是否会增加死锁的风险?

    顶层 await 创建了一种新的死锁机制,但本提案的发起人认为该风险值得承担,因为:

    • 模块已有许多方式可以创建死锁或以其他方式停止进展,开发者工具可以帮助调试它们
    • 考虑过的所有确定性死锁预防策略都过于宽泛,会阻止适当、现实和有用的模式。
    停止进展的现有方式
    无限循环
    for (const n of primes()) {
      console.log(`${n} is prime}`);
    }

    无限序列或缺少基本条件意味着静态控制结构容易受到无限循环的影响。

    无限递归
    const fibb = n => (n ? fibb(n - 1) : 1);
    fibb(Infinity);

    正确的尾调用允许递归永远不会栈溢出。这使得它容易受到无限递归的影响。

    Atomics.wait
    Atomics.wait(shared_array_buffer, 0, 0);

    Atomics 允许通过等待一个永远不会改变的索引来阻塞进展。

    export function then
    // a
    export function then(f, r) {}
    async function start() {
      const a = await import('a');
      console.log(a);
    }

    导出一个 then 函数允许阻塞 import()

    结论:确保持续进展是一个更大的问题
    被拒绝的死锁预防机制

    设计顶层 await 时一个潜在的要解决的问题空间是帮助检测和预防可能发生的死锁形式。例如,等待一个循环的动态导入可能会在模块图执行中引入死锁。

    以下关于死锁预防的部分将基于此代码示例:

    // file.html
    <script type=module src="a.mjs"></script>
    
    // a.mjs
    await import("./b.mjs");
    
    // b.mjs
    await import("./a.mjs");
    备选方案:返回一个部分填充的模块记录

    b.mjs 中,即使 a.mjs 尚未完成,也立即解析 Promise,以避免死锁。

    备选方案:对使用进行中的模块抛出异常

    b.mjs 中,当导入 a.mjs 时拒绝 Promise,因为该模块尚未完成,以防止死锁。

    案例研究:竞相 import() 一个模块

    当考虑到多段代码可能想要动态导入同一模块时,这两种策略都会失败。这样的多次导入通常不会是任何需要担心的竞争或死锁。但是,上述两种机制都无法很好地处理这种情况:一种会拒绝 Promise,另一种则无法等待导入的模块被初始化。

    结论:没有可行的死锁避免策略。
    顶层 await 会在转译器中工作吗?

    尽可能工作。广泛部署的 CommonJS(CJS)模块系统不直接支持顶层 await,因此针对它的任何转译策略都需要调整。然而,在这种背景下,我们根据几位 JavaScript 模块系统作者(包括转译器作者)的反馈和经验,对顶层 await 的语义进行了几次调整。本提案旨在在这些上下文中可实现。

    没有此提案,模块图执行是同步的。此提案是否维持开发者对此类加载是同步的期望?

    尽可能维持。当模块包含顶层 await(即使该 await 未动态到达)时,这不是同步的,并且至少会经过一次 Promise 任务队列。然而,不使用顶层 await 的模块子图继续以与本提案之前完全相同的方式同步运行。如果多个不使用顶层 await 的模块依赖于一个使用它的模块,那么一旦异步模块就绪,这些模块都将运行,而不会让出任何其他工作(既不会让出 Promise 任务队列/微任务队列,也不会让出宿主的事件循环等)。有关所用逻辑的详细信息,请参阅 #74

    模块加载是否应该在模块之间包含微任务检查点,或者在模块加载后让出事件循环?

    也许吧!这些模块加载问题是加载性能研究领域的一个激动人心的部分,也是围绕微任务检查点的其他不变量进行的有趣讨论。本提案对这些问题的解决方式不发表意见,将异步行为留给单独的提案。宿主环境可以用执行这些操作的方式包装模块,并且可以使用顶层 await 规范机制来协调事物。未来在 TC39 或宿主环境中的提案可以添加额外的微任务检查点。有关相关讨论,请参阅 whatwg/html#4400

    顶层 await 会在网页中工作吗?

    是的。与 HTML 规范集成的详细信息在 whatwg/html#4352 中提出。

    历史

    async / await 提案 最初于 2014 年 1 月 提交给委员会。在 2014 年 4 月 讨论了 await 关键字应在模块目标中被保留,以便用于顶层 await。在 2015 年 7 月async / await 提案 推进到第 2 阶段。在这次会议期间,决定暂时搁置顶层 await,以免阻塞当前提案,因为顶层 await 需要“与加载器一起设计”。

    自从决定推迟标准化顶层 await 以来,它已在几次委员会讨论中出现,主要是为了确保它在语言中仍然是可能的。

    2018 年 5 月,该提案在 TC39 流程中达到第 2 阶段,许多设计决策(特别是是否阻塞“兄弟”执行)留给第 2 阶段讨论。

    规范

    实现

    参考