深入了解 Top-level await #10
ulivz
started this conversation in
Deep Dive CN
Replies: 2 comments
-
大佬 你的理论很牛逼 佩服! 但是有一个小问题,就是这个build在AWS Lambda上没法用。因为Lambda要求最外层必须export一个叫handler的传统function。经过你这一波处理以后,那个暴露出的handler不会被Lambda识别。有没有可能能开一个后门帮Lambda用户使用?只需要维持handler形状是原始的function就可以。 Lambda已经原生支持TLA,不需要额外的处理。请问能不能提供一种比较原始的build方法? 谢谢了 |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
Introduction
在 ByteDance 内,我们基于 Rsbuild 建设的 Mobile Web Framework 的用户遇到了 Syntax Checker 问题:
针对这类问题,我们首先想到的是此问题可能是三方依赖引入的,这是因为构建器出于编译性能的考虑,默认情况下不会编译
node_modules
下的*.js|ts
文件[1],用户此时可能依赖了包含async/await
的三方依赖,从而导致最终编译错误。于是,我们建议开发者使用 source.include 来 Downgrade third-party dependencies:有意思的是,这一问题实际上和我们想象的并不相同,当我们使用 Source Map Visualization 来定位问题时,我们发现,
async
的位置是白色的 —— 没有源码与之映射:随着进一步分析,我们发现这个
async
是由 Webpack 编译 TLA (Top-level await) 注入的 Runtime 引入的。在这样的背景下,我们开始继续研究 TLA。在本文中,我们将进一步对 TLA 的 Specification、Toolchain Support、Webpack Runtime、Profiling、Availability 等进行了更为深入和全面的分析。
Specification
我们可以在 ECMAScript proposal: Top-level await 了解到 TLA 的最新的标准定义。TLA 的设计初衷来源于
await
仅在async function
内可用,这带来了以下问题:一个模块如果存在
IIAFE
(Immediately Invoked Async Function Expression) ,可能会导致exports
在该IIAFE
的初始化完成之前就被访问,如下所示:为了解决 1 中的问题,我们可能需要导出一个 Promise 给上游消费,但导出 Promise 显然会导致使用也需要感知这一类型:
接着,我们可以这样消费:
这带来了以下问题[2]:
race
获胜),有时则不能;为此,引入
Top-level await
,模块的写法将可以变成这样:一个典型的用例,就是解决 “动态依赖路径” 的问题,这对于国际化、基于环境拆分依赖等场景非常有用:
更多的用例见这里。
Compatibility
根据 Can I Use,我们可以在 Chrome 89,以及 Safari 15 上使用 TLA,Node.js 在 v14.8.0 也正式支持了 TLA:
你可以快速复制这段代码到你的 Chrome Devtools Console 面板或 Node.js 命令行中执行:
这是原生支持的 TLA 的效果,但是由于这是一个较新的 ECMAScript 特性,我们目前(2023 年)很难直接在移动端的 UI 代码中使用它。如果目前想要在 UI 代码中使用它,还是需要借助编译工具。下一节,我们将会介绍常见的工具链的 “编译行为” 和 “产物的兼容性”。
Toolchain Support
Prerequisites
为了统一测试编译行为的基准,我们约定测试的 Minimal Example 如下:
展开原始代码
各 Tooling 的最小仓库见 TypeScript (tsc) | esbuild | Rollup | Webpack。这里没有为 bun 创建 example,这是因为
bun
无需任何配置,在任意仓库下运行bun build src/a.ts --outdir ./build --format esm
即可进行打包的测试。TypeScript (tsc)
在 tsc 中,仅在
module
为es2022
、esnext
、system
、node16
、nodenext
,且target >= es2017
时才能成功编译 TLA,否则会遇到如下报错:编译成功后,可以看到发现产物和源码几乎一样:
由于 tsc 是一个 transpiler,不存在 bundle 行为,因此 tsc 下不会为 TLA 引入额外的 Runtime,也就是说,tsc 没有考虑 TLA 的兼容性。可移步 Profiling 一节,了解如何去运行这段产物。
esbuild
esbuild 目前只能在
format
为esm
,且target >= es2022
时(这一点和tsc
的module
对齐,而不是target
)才能成功编译 TLA,也就是说,esbuild 本身只处理了成功编译,不会对 TLA 的兼容性负责:编译成功后,产物如下:
可以看到,这里的产物直接平铺了所有的
module
—— 这似乎改变了代码原始的语义! 这一点我们可以在 Profiling 一节中得到验证。对于 TLA 在 esbuild 中的支持,esbuild 作者 @evanw 的对此的回复是[4]:
Rollup
Rollup 只能在
format
为es
或system
的场景下支持成功编译 TLA,否则会遇到如下报错:es
这里和esbuild
生成 es bundle 的行为一样修改了语义,这里不再赘述。对于system
,通过阅读 SystemJS 文档,SystemJS 支持模块被定义为一个 Async Module:因此,Rollup 这里也不会有特殊的行为,只是将 TLA 包裹在
execute
函数中,因此 Rollup 本身对 TLA 没有更多的 Runtime 层面的处理。关于 Rollup 在 iife 下支持 TLA 有一条 issue[4],可移步 rollup/rollup#3623 了解更多。Webpack
TLA 最早于 Webpack 5 中开始支持 ,但需要通过在 Webpack 配置中增加 experiments.topLevelAwait 开启:
从 5.83.0 开始,Webpack 默认开启了此选项,但如果你只是简单地书写一段 TLA 测试代码在 Webpack 中进行编译:
你会发现,你遇到如下编译错误:
通过搜寻相关 Issue (webpack/#15869 · Top Level await parsing failes),我们可以看到,Webpack 默认情况下,会认为那些没有 import / export 的模块是 CommonJS 模块,这一逻辑的实现位于 HarmonyDetectionParserPlugin.js:
综上,在 Webpack 中,成功编译 TLA 的条件如下:
true
;export
,能够被识别为一个 ES Module(HarmonyModules
)。对于 Webpack 处理 TLA 的 Runtime 流程可以移步 Webpack TLA Runtime 一节。
bun
bun build 目前只支持 esm,也就是说,bun 也会原封不动的将 TLA 编译到产物中去,同样也没有考虑兼容性,只考虑了现代浏览器的运行:
Profiling
这一节中,我们会首先讲述如何运行各类工具链的产物,接着结合 Profiling 来讲述运行行为。
In Node.js
首先,依赖了 TLA 的 module 必然是一个 ES module,如果我们使用 Node.js 来运行,那么就会遇到使用 Node.js 执行 ES module 的各种问题。考虑到
tsc
场景的产物是多个 ES module 模块,而不是单个 ES module,场景最为复杂。因此本节将使用 Node.js 执行tsc
中生成的产物来进行讲述。Question:
.mjs
ortype: module
?直接运行
node esm/a.js
来运行 tsc 中生成的产物,会首先遇到如下问题:根据 https://nodejs.org/api/esm.html#enabling::
我们,这里没有选择修改产物为
.mjs
,选择了在package.json
中增加"type": "module"
:Question: missing
.js
extension intsc
out code解决了上一个问题后,我们又遇到下述问题:
根据 https://nodejs.org/api/esm.html#import-specifiers:
也就是说,Node.js 中加载 ES Module 必须带上 extension,但是 tsc 的产物默认没有
.js
extension。根据 TypeScript 文档以及相关指南[5]所述,进行如下修改:compilerOptions.module
修改为NodeNext
,这是另一个很长很长的故事,这里不再展开;import "./foo"
修改为import "./foo.js"
;最终,上述代码能够成功运行,最终修复的 Commit 见这里。
Performance
使用
time node esm/a.js
运行的输入如下:可以看到,整个程序只用了
1.047s
来运行,这意味着b.js(sleep 1000ms)
和c.js (sleep 500ms)
的执行是并发的。In Chrome
Chrome 从 89 开始支持 TLA,你可以像本文开头一样快速去运行一段 TLA 示例代码,但为了测试包含如同示例中 “互相引用” 的原生行为,我们决定像上一节一样,在浏览器中运行 Toolchain Support > tsc 中生成的产物。首先,创建一个
.html
:为了更好的观测运行行为,我们在代码中使用
console.time
来进行了打点,可以看到运行时序如下:可以看到,
b.js
与c.js
的 load 与 execution 都是并发的!Result
如不考虑资源加载耗时,
b.js(sleep 1000ms)
和c.js (sleep 500ms)
串行的执行耗时是1.5s
,并行执行的耗时是1s
。基于前面的测试技巧,我们对以下几种场景的产物进行了测试,得到报告如下:tsc
tsc
es bundle
es bundle
Webpack (iife)
Webpack (iife)
总结一下,虽然 Rollup / esbuild / bun 等工具可以将包含 TLA 的模块成功编译成 es bundle,但是其语义是不符合 TLA 规范的语义的,现有简单的打包策略,会导致原本可以并行执行的模块变成了同步执行。只有 Webpack 通过编译到 iife,再加上复杂的 Webpack TLA Runtime,来模拟了 TLA 的语义,也就是说,在打包这件事上,Webpack 看起来是唯一一个能够正确模拟 TLA 语义的 Bundler。
TLA Fuzzer
在上一节中,我们通过比较初级的方式来验证了各种工具链对 TLA 语义的支持情况。实际上,esbuild 作者 @evanw 此前为了测试 TLA 的语义正确性,创建了一个仓库 tla-fuzzer,来测试各种打包器对 TLA 语义的正确性,也进一步验证了我们的结论:
Fuzzer 测试是通过随机生成 module graphs 并将打包产物的执行顺序序与 v8[6] 的原生模块执行顺序进行比较来完成的[7]。
Webpack TLA Runtime
由于只有 Webpack 正确地处理了 TLA 打包后的语义,本节将对 Webpack 的 TLA Runtime 进行分析。
基本例子
首先,我们回顾一下,在 Entry 没有任何 Dependency 的场景下,Webpack 的构建产物会相当简单:
Input
Output
当我们使用了 Top-level await:
Input:
Output
由于篇幅有限,产物太长,这里将 Output 进行了 external,请移步 TLA Output。可以看到使用了 TLA 后构建产物会变得较为复杂,后续会进一步分析。
这里我们可以大胆地猜测,Webpack 的编译产物看起来就是在 Bundler 层面,把 JS Runtime 原本该做的事情 Polyfill 了一遍。
整体流程
整体上来说,会以 Entry 为入口,通过
__webpack_require__()
执行 Entry 模块,接着,首先会通过__webpack_handle_async_dependencies__()
加载依赖,依赖的加载和 Entry 是完全一样的,依赖若存在依赖,也需要首先加载自身的依赖,依赖加载结束后,获取到依赖的 exports 方能执行当前 Module,执行结束后,会调用__webpack_async_result__()
进行回调,让被依赖的模块继续向前执行。这里运行时的本质和依赖关系完全一致,首先依赖开始加载本身是同步的,最末端的依赖加载结束后,返回
exports
给上层依赖,上层依赖也才能开始执行,继续向上返回 exports,最终当 Entry 的所有依赖加载结束后,entry 本身的代码开始执行:可以看到,在没有 TLA 之前,这一流程会相当简单,就是一个同步的 DFS,但是一旦 Dep 的加载是异步的,那么这里就是一个异步加载的 DFS,涉及到复杂的异步任务处理。接下来,我们将详细讲述 Webpack TLA Runtime 的运行流程。
Basic Concepts
Prerequisites
为了讲述 Webpack TLA Runtime 的运行流程,我们重新创建了一个更小的 Example 进行分析:
让我们明确一些基本概念,并给本例子中的模块起一个别名:
index.js
index.js
是component.js
的 Dependent;component.js
是index.js
的 Dependencycomponent.js
Webpack 的编译过程
为了更好的理解 TLA 内部原理,我们还需要简单了解一下一次 Webpack 的主要编译流程:
newCompilationParams
:创建Compilation
实例参数,核心功能是初始化用于在后续的构建流程中创建模块实例的工厂方法ModuleFactory
;newCompilation
:真正创建Compilation
实例,并挂载一些编译文件信息;compiler.hooks.make
:执行真正的模块编译流程 (Make),这个部分会对入口和模块进行构建,运行loader
、解析依赖、递归构建等等;compilation.finish
:模块构建的收尾阶段,主要是对模块间依赖关系和一些依赖元数据做进一步的整理,为后续代码拼接做好准备;compilation.seal
:模块冻结阶段 (Seal),开始拼接模块生成chunk
和chunkGroup
,生成产物代码。Webpack Runtime Globals
在
Seal
阶段,会基于 Chunk 中的runtimeRequirements
信息,使用 Template 拼接生成最终的结果代码,其中,Template 会依赖一些全局变量,在 Webpack 中,这些变量定义在 lib/RuntimeGlobals.js 中:产物分析
接下来,我们开始分析前面生成的产物。
加载 Entry
首先,执行的入口如下:
__webpack_require__
定义如下:可以看到:
__webpack_require__
是完全同步的过程;Async Dependency
的加载发生在 Module 的加载执行阶段;Entry 的执行
可以看到:
__webpack_require__.a
来定义异步模块。Async Module
,即使它本身没有 TLA;因此,核心的依赖如下:
__webpack_require__.a
:定义Async Module
;__webpack_handle_async_dependencies__
:加载异步依赖;__webpack_async_result__
的作用:Async Module
加载结束的回调;其中,
__webpack_require__.a
是最值得一提的。__webpack_require__.a
__webpack_require__.a
用于定义一个Async Module
,相关代码分析如下:在
__webpack_require__.a
被执行时,定义了如下几个变量:queue
array
await
时,queue
会被初始化为[d: -1]
,因此本例子中 Dep 会存在queue
,Entry 不会存在。有关 queue 的 状态机 详见queue 。depQueues
Set
queue
。promise
Promise
module.exports
*, *并将 resolve / reject 权利转移到外部,用于控制模块加载结束的时机。当promise
被 resolve 后,上层模块将能获取到当前 module 的 exports,有关promise
的细节详见 promise 。当完成一些基础的定义后,会开始 执行 Module 的 Body(
body()
),并传递:__webpack_handle_async_dependencies__
__webpack_async_result__
这两个核心方法给 body 函数,注意,body 函数内部的执行是异步的,当 body 函数开始执行后,如果
queue
存在(即在 TLA 模块内)且queue.d < 0
,那么将queue.d
赋值为0
。queue
这是一个状态机:
queue.d
会被赋值为-1
queue.d
会被赋值为0
resolveQueue
方法中会将queue.d
赋值为1
promise
上述
promise
上还挂载了 2 个额外的变量需要提及:[webpackExports]
module.exports
,因此 **Entry 可以通过promise
来获取到 Dep 的 exports。[webpackQueues]
2. 在 Entry 加载依赖( [Dep] )时,会传递一个
resolve
函数给 Dep,当 Dep 完全加载结束时,会调用 Entry 的resolve
函数,将 Dep 的exports
传递给 Entry,此时,Entry 的 body 才能开始执行。resolveQueue
resolveQueue
绝对是这段 Runtime 中的精华之一,在模块的 body 执行完,会调用resolveQueue
函数,实现如下:复杂例子
若左图依赖关系所示,其中
d
、b
两个模块是包含了 TLA 的模块,那么:a
、c
会由于 TLA 的传染问题同样变成 Async Module;__webpack_require__
的时机,这里会基于 import 的顺序进行 DFS假设
a
中 import 如下所示:a —> b —> e —> c —> d
。d > b
,那么 Module 加载结束的时机为b —> d —> c —> a
d < b
,那么 Module 加载结束的时机为d —> c —> b —> a
a
,因为a
在加载的时候就结束了Async Module
复杂的根源
如果我们仔细阅读 ECMAScript proposal: Top-level await,我们可以看到一个更简单的例子来描述这一行为:
大致相当于:
这一示例启发了类似一些 Bundleless 工具链的建设,如 vite-plugin-top-level-await。而在 Bundler 层面支持 TLA 编译到 iife 的复杂度主要来源于:我们需要合并所有模块到一个文件,还要保持上述语义。
现在能用 TLA 吗?
前文我们提到的 Runtime,是发生在 Seal 阶段由内联脚本注入的。由于 Seal 已经是模块编译的最后环节,不可能再经历 Make 阶段(不会运行 loader),因此此处拼接的模板代码必须要考虑兼容性。实际上也是如此,Webpack 内部的 Template 均是会考虑兼容性的,如:
当我们修改
target
在es5
或es6
之间切换,你会看到产物有明显的变化:但是偏偏,
Top-level await
没有遵守这一原则,在 webpack#12529 中,我们可以看到,Alexander Akait 曾经对 Template 中的async/await
的兼容性提出过质疑,但是 Tobias Koppers 以非常难以修复进行了回应:因此这一实现一直被保留在了 Webpack 中,TLA 也成为会导致 Webpack 中会导致 Runtime Template 带来兼容性问题的少数派特性。
实际上,这里也可以理解,如果 Template 中依赖了
async/await
,那么如果要考虑兼容性,那么要考虑引入 regenerator-runtime 或者类似 tsc 中更优雅的基于状态机的实现(See: TypeScript#1664),Web Infra 曾经的一个实习生也尝试实现过(See: babel-plugin-lite-regenerator)。也就是说,Webpack 对 TLA 的编译,由于产物中仍然会包含
async/await
,这导致了只能在 iOS 11、Chrome 55 的机器上跑:- Safari 16
- Safari 6
(i.e. async / await)
- Safari 11
总结
es2022
的特性,在 v14.8.0 以上的版本中可以用,如需在 UI 代码中使用,需要借助 Bundler 打包;除非你会在前端项目中直接使用 es module,一般来说,你需要打包成iife
;esm
时成功编译 TLA,但是只有 Webpack 能够支持将 TLA 编译到iife
,同时,Webpack 是唯一一个能够正确模拟 TLA 语义的 Bundler。iife
,但是由于产物中仍然包含async/await
(虽然不是 TLA),这导致了只能在iOS11 / Chrome 55
的机器上运行,目前,对于一些大型公司的 Mobile Web 面向 C 端的业务,可能要求兼容性设置为 iOS 9 / Android 4.4,因此,目前出于稳定性考虑,你不应该在 C 端项目中使用 TLA。未来,你应当基于业务尝试 TLA;await
要求在async function
使用一样具备传染性,TLA 会导致 Dependent 同样被处理为 Async Module,但这对开发者是无感的;下一步
看到这里,还是有一些附加问题,值得进一步研究:
写在最后
Rollup 作者 Rich Harris 在此前一篇 Gist Top-level await is a footgun 👣🔫 中提到[8]:
但后来,他又提到:
因此,他开始完全支持此提案:
那么这里我们也可以在 ECMAScript proposal: Top-level await 关于 TLA 的历史,可以概括如下:
async / await proposal
被提交给委员会;async / await proposal
推进到 Stage 2,在这次会议中决定推迟 TLA,以避免阻塞当前提案;很多委员会的人已经开始讨论,主要是为了确保它在语言中仍然是可能的;你怎么看待 TLA 的未来呢?
后续更新
Rspack 于 v0.3.8 正式支持 TLA,通过 Fuzzer 测试
Rspack 是一个高性能的基于 Rust 的 JavaScript 打包工具,它与 webpack 生态系统有强大的互操作性。近期,Rspack 在 v0.3.8 中加入了
TLA (Top Level Await)
。值得一提的是,Rspack 在 TLA Fuzzer 测试上做到了和 Webpack 结果一致[9]:
这么一看,在能够正确模拟 TLA 语义的 Bundler 的名单里,可以再加上 Rspack 了!
Refs
[1]: https://rsbuild.dev/config/options/source.html#sourceinclude
[2]: https://github.com/tc39/proposal-top-level-await
[3]: evanw/esbuild#253
[4]: rollup/rollup#3623
[5]: https://www.typescriptlang.org/docs/handbook/esm-node.html
[6]: https://v8.dev/features/top-level-await
[7]: https://github.com/evanw/tla-fuzzer
[8]: https://gist.github.com/Rich-Harris/0b6f317657f5167663b493c722647221
[9]: https://github.com/ulivz/tla-fuzzer/tree/feat/rspack
Beta Was this translation helpful? Give feedback.
All reactions