动态 import 加顶层 await 在旧版 Safari 上报 TDZ:JavaScriptCore 模块加载器被原生 C++ 重写了一遍

项目里写动态 import() 时,只要被引入的模块带 Top-Level Await(顶层 await),在 iOS Safari 或旧版 Bun 的 JavaScriptCore 环境下,偶尔就会遇到一类很诡异的报错。
真正让人头疼的还不是模块加载失败,而是代码执行时序完全颠倒。
把当时的翻车现场压缩成最小复现逻辑:页面为了并发预加载,在短时间内连续调用三次 import('./test-module.js');被导入的模块内部只写了一行延迟等待 await new Promise(resolve => setTimeout(resolve, 10))。
// main.js
async function load(index) {
try {
console.log("Importing", index);
const module = await import("./test-module.js");
console.log("Imported", index);
console.log(`Keys for ${index}:`, Object.keys(module));
} catch (e) {
console.log("Accessing", index, "failed:", e.message);
}
}
// 并发触发三次动态导入
load(1);
load(2);
load(3);
// test-module.js
await new Promise(resolve => setTimeout(resolve, 10));
export function someFunction() {
return "Hello!";
}
export const someArray = [];
按正常理解,不管第几次触发动态导入,模块求值都应该按发起顺序依次返回,三次调用的结果应该是 1、2、3。
但在旧版 Safari 和旧版 JavaScriptCore 里跑这段代码,控制台输出却完全反着来:
Importing 1
Importing 2
Importing 3
Imported 2
Accessing 2 failed: Cannot access 'someArray' before initialization.
Imported 3
Accessing 3 failed: Cannot access 'someArray' before initialization.
Imported 1
Keys for 1: someArray,someFunction
打印完成顺序变成了 2、3、1。先执行完的两次导入都抛异常,提示 Cannot access 'someArray' before initialization;等它们彻底失败后,最先发起的第 1 次导入才慢吞吞地成功。
两个非常反常的现场表现
这个 bug 有两个点很难从业务代码层面解释。
第一,后发起的导入竟然比先发起的先完成。第二,同一个模块里明明写了 export const,外部拿到的导出却像处于 TDZ(暂存死区)里的未初始化空壳。
遇到这类线上报错,第一反应通常是怀疑业务代码有循环依赖,或者 Webpack/Vite 打包出来的模块闭包有问题。但来回排查业务代码,往往越查越不对。
问题出在 JavaScriptCore 引擎的模块加载器状态机:第 1 次动态导入执行到顶层 await 时,模块执行被挂起,主线程控制权交回调用方。第 2 次导入随后进来,按 ECMAScript 规范,它的内部 Promise 必须先挂起,等到第 1 次导入把依赖拓扑图完整求值完才能决议。
但旧版 Safari 的加载器把这个后续 Promise 过早 Resolve 了。第 2 次导入因此误以为目标模块已就绪,直接去读模块环境记录里的导出内容;此时模块内部还在异步等待,变量根本来不及初始化,于是 JSC 的 TDZ 检查被触发,红色报错直接抛出。

历史根因:被废弃的 WHATWG Loader 草案
这个 bug 能在 Safari 里潜伏多年,和 JavaScript 模块加载器的发展历史有关。
Safari 引擎搭模块加载器的时间大约在 2016 年。当时 ECMAScript 规范只定义了静态语法和声明,没有定义宿主环境如何从网络、文件系统拉取模块,大家参照的是 WHATWG Loader 规范草案。WebKit 团队就是照着这份草案搭起模块加载器骨架的。
当时这么实现问题不大。ES Module 还是纯同步求值,规范里根本没有顶层 await 的概念,依赖树理顺后按拓扑顺序递归走完即可。
后来 WHATWG Loader 草案被废弃,ECMAScript 规范在 2022 年正式接管异步求值算法,也就是 Async Evaluation Flow。但 Safari 内部的模块加载器一直沿用那套同步骨架。为了支持 Top-Level Await,团队只能在老代码上不断打补丁、加分支。
问题在于:在原本假设纯同步递归的框架里,硬塞异步挂起和状态重入逻辑。一旦遇到并发动态导入、微任务穿插调度,或者多层异步模块依赖,旧状态机里的隐式假设就会被击穿,于是出现偶发性死锁和 TDZ 报错。

架构硬伤:模块加载器用 JavaScript 自托管
旧版模块加载器还有一个更深层的问题:它是用 JavaScript 写的。
用 JS 实现引擎内置逻辑,即 Self-hosted Builtins,看起来是合理的:内置代码能被 JIT 直接内联进业务代码;调用时不需要在 C 和 JS 虚拟机上下文之间反复横跳;在 JS 环境内部创建对象也能省掉部分 C 堆内存二次分配。
但把这套机制用在模块加载器上,几乎所有坑都会踩一遍。
冷启动耗时会增加。原生 C++ 代码在编译打包时已经生成机器码,启动直接运行;自托管 JS 则需要在每次浏览器冷启动或创建运行时上下文时,先把几千行 JS 重新解析、编译一遍,拖慢首屏启动。
模块加载逻辑对现代 JIT 也不友好。JIT 优化器擅长的是循环千万次、类型高度单态的热点路径;模块加载器只在应用启动或动态按需引入时跑几次,调用频次撑不起高阶 JIT 优化。何况加载器的调用形态多变,JIT 难以抓到稳定模式。
结果就是自托管 JS 占不到内联的便宜,反而因为 GC 和 JIT 状态抖动,让加载阶段执行时延变得飘忽。再用弱类型 JS 手写底层异步状态机,没有强类型约束,也没有内存屏障做兜底,遇到微妙时序竞态时排查成本极高。
收手:删光 JS,用原生 C++ 重写
补丁打了几年依然压不住偶发报错后,WebKit 团队选择了一个更彻底的方向:把旧模块加载器的整份 JavaScript 文件删光,一行不留。
新版模块加载器严格对照 ECMAScript 规范里的异步求值算法流程图,用原生 C++ 逐个函数重写。重构沿状态机依赖拓扑展开:先实现没有外部依赖的叶子函数,比如 ExecuteModule、ModuleRequestsEqual,再向上搭出完整的异步依赖图遍历管线。
这次重构里 Bun 团队也帮了忙。Bun 底层直接用 JavaScriptCore,过去几年同样被这套旧加载器折腾。Bun 工程师把服务端收集到的几十个典型翻车用例交给 WebKit 团队,转成了 JSC 命令行环境下的回归测试集。
为了覆盖更复杂的嵌套场景,工程团队还写了一套 Fuzzer 工具,自动生成大量嵌套混乱的动态模块依赖图。有的模块带顶层 await,有的只有同步导出,有的包含动态循环引用。生成后的用例会同时交给 JavaScriptCore、V8、SpiderMonkey 执行,并把控制台输出做 byte-for-byte 比对。只有 JSC 的求值时序和报错行为与另外两个引擎完全一致,并且 test262、WPT 的官方模块测试全部通过后,这套 C++ 加载器才合入主干。

生产建议与适用边界
底层隐患排除了,日常开发里的工程选型还是得看运行环境。
新版 Bun 服务端,或者运行在企业内网、最新 Safari 27 和现代 Chromium 内核下的定制桌面端,现在可以正常使用顶层 await。动态 import() 和并发异步依赖的求值顺序已经和标准拉齐,不再需要担心偶发 TDZ 报错。
但如果业务面向移动端普通用户,或者是跨平台混合 WebApp,目前仍建议谨慎。
iOS 生态里 WebKit 和系统版本绑定。Safari 27 才刚进入 Beta,从苹果推送正式版系统,到存量旧设备大面积升级,通常要经历 6 到 12 个月。过渡期内,只要底层公共库或首屏关键依赖直接铺开原生 Top-Level Await,低版本 iOS 上仍有概率触发并发动态导入乱序和 TDZ 报错。
构建配置上也建议稳一点:面向通用 Web 端时,Rollup/Vite 的 target 不必全局设为 esnext。初始化阶段需要拉取异步配置、做网络握手或加载 Wasm 的逻辑,保留显式异步初始化函数,或用构建工具的 wrapper 做一层隔离,是当前阶段最稳妥的做法。