大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!
ECMAScript 的顶级 `await` 提案已经到了第 4 阶段,本文和大家重点聊聊为什么前端离不开Top-level Await。话不多说,直接进入正题!
1. 为何不能用立即执行函数1.1 导入 IIAFE 存在返回值不确定性(Race Condition)由于 await 仅在 async 函数中可用,但是模块可以通过将代码分解到 async 函数中来在启动时执行的代码中包含 await:
// awaiting.mjsimport {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,即 Immediately Invoked Async Function Expression),作为 IIFE(Immediately Invoked Function Expression) 的有效补充。
// awaiting.mjsimport {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};此模式适用于加载模块同时在后续执行的场景。然而,此模块的 export 可能会在其他模块 import 的不同时机而不同,这里称之为竞争条件(Race Condition):
异步函数执行完成之前访问,可能会得到 undefined异步函数执行完成之后访问得到 process 的返回值// usage.mjsimport {output} from "./awaiting.mjs";export function outputPlusValue(value) { return output + value; }console.log(outputPlusValue(100));setTimeout(() => console.log(outputPlusValue(100), 1000);1.2 初始化导出 Promise除了上面的方法外,开发者还可以从模块导出 Promise 并等待其导出完成。例如,上述模块可以写为:
// awaiting.mjsimport {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.mjsimport promise, {output} from "./awaiting.mjs";// 读取了 output 的值,和导入模板实例化时机有关系export function outputPlusValue(value) { return output + value }promise.then(() => { console.log(outputPlusValue(100)); setTimeout(() => console.log(outputPlusValue(100), 1000);});然而,该方法依然存在诸多问题:
开发者必须了解代码编写的规范才能找到正确的 Promise 来等待模块加载如果忘记编码协议,有时可能会不 “正常工作”(由于 IIAFE 的返回时机问题)在深层依赖模块结构中,Promise 需要显式地贯穿链的每个步骤例如,上面示例正确 await 了 “./awaiting.mjs” 的 Promise,但由于忘记重新导出,因此使用当前模块的模块可能仍然会遇到返回值的问题。
1.3 通过额外机制避免导出不确定性是一种倒退为了避免在访问 export 之前忘记等待导出的 Promise 的风险,模块可以导出一个 Promise,该 Promise resolve 为包含导出的对象。
// awaiting.mjsimport {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.mjsimport 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 的深度依赖项时,需要重新组织所有依赖模块以使用此模式。而下文重点介绍的 Top-level Await 让开发者可以依靠模块系统本身来处理所有这些 Promise。
2. 抓住 Top-level Await 的救命稻草2.1 什么是 Top-level AwaitTop-level Await 使模块能够充当大型异步函数,通过 await(协程让出 CPU),ECMAScript 模块(ESM)可以等待外部资源,让其他模块在开始执行其代码体之前等待。
提示:目前,Chrome DevTools、Node.js 和 Safari Web Inspector 中的 REPL(Read-Eval-Print-Loop,是一个 Node.js 交互式 shell 处理表达式) 已经支持 Top-level Await 一段时间了。 然而,该功能是非标准的并且仅限于 REPL,而 Top-level Await 是一个新的提案,是 JavaScript 语言规范的一部分且仅适用于模块,两者有本质区别。
在引入 Top-level Await 之前,如果开发者尝试在 async 函数之外使用 wait 会导致语法错误。因此,许多开发人员会利用立即调用的异步函数表达式作为替代方案。比如下面的示例:
await Promise.resolve(console.log(''));// 报错→ SyntaxError: await is only valid in async function(async function() { await Promise.resolve(console.log('')); // → }());而使用 Top-level Await 后,以上代码就会简洁的多:
await Promise.resolve(console.log(''));// →但是,值得注意的是,Top-level Await 仅适用于模块的顶级,不支持经典脚本或非异步函数。
2.2 Top-level Await 重构导出不确定示例以上面 1 小结的代码为例,Top-level Await 让开发者可以依靠模块系统本身来处理所有 Promise,并确保代码运行正常。上面的例子代码可以简单地编写和使用如下:
// awaiting.mjsimport {process} from "./some-module.mjs";const dynamic = import(computedModuleSpecifier);const data = fetch(url);export const output = process((await dynamic).default, await data);下面是使用模块的代码:
// usage.mjsimport {output} from "./awaiting.mjs";export function outputPlusValue(value) { return output + value }console.log(outputPlusValue(100));setTimeout(() => console.log(outputPlusValue(100), 1000);在 awaiting.mjs 中的 await resolve 其 Promises 之前,usage.mjs 中的任何语句都不会执行,因此设计上避免了导出不确定性。 这相当于一个扩展,如果 awaiting.mjs 没有使用 Top-level Await,那么在加载 awaiting.mjs 并执行其所有语句之前,usage.mjs 中的任何语句都不会执行。
3.Top-level Await 的使用场景3.1 动态依赖路径在 Node.js 中,开发者可以通过下面的代码进行动态模块导入:
var x = condition ? require('./foo') : require( './bar' );doSomethingWith(x);然而,ES6 模块中没有等效的方法,因为 ES6 导入声明完全是静态的。当浏览器最终支持时,开发者也将能够动态加载模块,比如下面的例子:
// NB: may not look exactly like thisimport(condition ? './foo.js' : './bar.js').then( x => { doSomethingWith(x);});然而需要注意的是,以上代码是异步的而且必须如此,因为其必须通过网络加载资源且不会像 require 那样阻止代码执行,而 Top-level Await 却能实现该特性:
const strings = await import(`/i18n/${navigator.language}`);Top-level Await 允许模块使用运行时的值来确定依赖,这对于开发 / 生产拆分、国际化、环境拆分等非常有用。
3.2 资源初始化比如下面的代码示例:
const connection = await dbConnector();Top-level Await 允许模块代表资源,并在模块无法使用的情况下抛出错误。
3.3 依赖回退以下代码示例尝试从 CDN A 加载 JavaScript 库,如果失败则回退到 CDN B:
let jQuery;try { jQuery = await import('https://cdn-a.example.com/jQuery');} catch { jQuery = await import('https://cdn-b.example.com/jQuery');}4.Top-level Await 模块执行顺序4.1 依赖子模块执行完成 > 导出完成 > 代码继续执行从目前来看,一个模块需要等待其所有依赖执行完所有语句之后才算真正导出完成,此时模块代码才可继续执行。 Top-level Await 提案的出现依然保持了这个特性,即依赖依然需要执行完成,即使是只需要等待异步代码执行。一个好的理解方式是:每个模块都导出一个 Promise,并且在所有 import 语句之后、但在模块的其余代码执行之前,都会一直等待 Promise:
import {a} from './a.mjs';import {b} from './b.mjs';import {c} from './c.mjs';// 一直在等待 Promiseconsole.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.mjs、b.mjs 和 c.mjs 都会按顺序执行,直到每个模块中的第一个 wait 为止。然后,需要等待所有其他导入模块代码恢复并完成执行,当前模块才能再继续执行。
4.2 深入理解 JavaScript 引擎后序遍历执行模块使用 Top-level Await 的 JavaScript 的最大变化之一是模块的执行顺序,JavaScript 引擎以后序遍历(Post-order Traversal)的方式执行模块,即:
从模块图最左边的子树开始,对模块进行求值,导出绑定,并执行同级模块,然后执行父级模块。 该算法递归运行,直到执行模块图的根节点。
伪代码表示如下:
sub PostOrder(TreeNode) If LeftPointer(TreeNode) != NULL Then PostOrder(TreeNode.LeftNode) If RightPointer(TreeNode) != NULL Then PostOrder(TreeNode.RightNode) Output(TreeNode.value)end sub在 Top-level Await 之前,此顺序始终是同步(Synchronous)且确定(Deterministic)的,即在代码的多次运行之间保证模块以相同的顺序执行。而当在模块中使用 Top-level Await 时,会发生以下情况:
当前模块的执行被推迟,直到 await 的 Promise 得到 resolve父模块的执行被推迟,直到调用 await 的子模块及其所有同级模块导出绑定兄弟模块以及父模块的兄弟模块能够以相同的同步顺序继续执行 (后序遍历的优势),前提是图中没有循环或其他 await 的 Promise调用 await 的模块在等待的 Promise resolve 后恢复执行只要没有其他 await 的 Promise,父模块和后续树就会继续以同步顺序执行4.3 Top-level Await 不会阻止兄弟模块的 import如果一个模块想要声明自己依赖于另一个模块,为了在模块主体执行之前等待另一个模块完成其 Top-level Await 语句,其可以将该另一个模块声明为导入。
在下面示例,打印顺序将为 “X1”、“Y”、“X2”,因为在另一个模块 “之前” 导入一个模块不会创建隐式依赖关系。
// x.mjsconsole.log("X1");await new Promise(r => setTimeout(r, 1000));// 1s 后 resolveconsole.log("X2");// y.mjsconsole.log("Y");// z.mjsimport "./x.mjs";import "./y.mjs";需要明确指出依赖性,以提高并行性的潜力。大多数由于 Top-level Await 而阻塞的设置工作可以与其他设置工作并行完成且来自不相关的模块。 当其中一些工作可能是高度并行的(例如:网络获取)时,重要的是让尽可能多的工作在接近执行开始时排队。
4.4 没有 Top-level Await 模块为同步执行如果模块的执行是确定性同步的(比如:如果模块及其依赖项均不包含 Top-level Await),则 Promise.all 中不会有该模块的条目,在这种情况下,模块将同步运行。
这些语义保留了 ES 模块的当前行为,其中,当不使用 Top-level Await 时,执行阶段是完全同步的。
// b.mjsconsole.log("1");Promise.resolve().then(() => console.log("2"));// a.mjsimport "./b.mjs";console.log("3");Promise.resolve().then(() => console.log("4"));// file.html<script type=module src="/a.mjs"></script>比如,以上示例在 WebKit、Gecko 和 Chromium 中的控制台都会打印 “1 3 2 4” 。
5.Top-level Await 不得不说的秘密也许您以前看到过 Rich Harris 提出的一些 Top-level Await 的问题,并敦促 JavaScript 语言不要实现该功能,比如:
Top-level Await 可能会阻止执行Top-level Await 可能会阻止获取资源CommonJS 模块不会有明确的互操作Top-level Await 提案的第三阶段版本已经完全解决了这些问题:
由于兄弟姐妹模块依然能够执行(后序遍历),因此不存在明确的阻塞Top-level Await 发生在模块图的执行阶段,此时所有资源都已被 fetch 并链接(Fetch and Link), 不存在阻塞获取资源的风险Top-level Await 仅限于模块, 明确不支持脚本或 CommonJS 模块。与任何新的语言功能一样,总是存在意外行为的风险。 例如,对于 Top-level Await,循环模块依赖可能会导致死锁。
如果没有 Top-level Await,JavaScript 开发人员经常使用异步立即调用函数表达式(Immediately-invoked Function Expressions)来访问 await。 不幸的是,这种模式导致模块执行的确定性和应用程序的静态可分析性降低。 由于这些原因,缺乏 Top-level Await 被视为比该功能引入的危险更高的风险。
6.Top-level Await 增加死锁风险Top-level Await 创建了一种新的死锁机制,但该提案的支持者认为这种风险是值得的,因为:
模块可以通过多种现有方式造成死锁或停止进度,开发人员工具可以很好的帮助调试所有确定性死锁预防策略(Deterministic Deadlock Prevention Strategies)都过于宽泛,并且会阻碍适当、现实、有用的模式// await 循环动态导入给模块树执行带来死锁// file.html<script type=module src="a.mjs"></script>// a.mjsawait import("./b.mjs");// b.mjsawait import("./a.mjs");可以通过以下方法有效避免死锁:
返回部分填充的模块记录:在 b.mjs 中,即使 a.mjs 尚未完成,也要立即 resolve Promise,以避免死锁。在使用正在进行的模块时抛出异常:在 b.mjs 中,导入 a.mjs 时 reject Promise,因为该模块尚未完成,以防止死锁。import 模块导入竞赛:当考虑到多段代码可能想要动态导入同一个模块时,这两种策略都会失败。 这种多次导入通常不会产生任何值得担心的竞争或僵局。 然而,上述两种机制都不能很好地处理这种情况:一种会拒绝 Promise,另一种则无法等待导入的模块初始化。因此,可以得出结论,暂时没有可行的避免死锁的策略!!!
参考资料https://v8.dev/features/top-level-await
https://github.com/tc39/proposal-top-level-await
https://en.wikibooks.org/wiki/A-level_Computing/AQA/Paper_1/Fundamentals_of_algorithms/Tree_traversal#Post-order
https://github.com/tc39/proposal-top-level-await/issues/43
https://github.com/tc39/proposal-top-level-await/issues/47
https://stackoverflow.com/questions/42958334/how-can-i-export-promise-result/42958644#42958644
https://www.youtube.com/watch?v=dAwQreHX5nM
https://shantunparmar.in/for-what-reason-should-you-use-top-level-await-in-javascript/