跳转到内容

Safari 27 让顶层 await 符合规范

发布于

2026 年 9 月 17 日,WebKit 发布了 Safari 27.0,其特性文章称这一版带来「全新的 ES 模块加 载器」:它是对 ECMAScript 规范自身模块加载算法的纯 C++ 实现,取代了此前那套基于 2016 年 就已被放弃的 WHATWG Loader 提案的实现——用 WebKit 的原话,那份提案「完全早于顶层 await」。这一版的「已修复问题」清单用一句话记下了用户可见的结果:「Fixed multiple top-level await correctness bugs with a rewrite of the ES module loader for standards compliance.」这不是开关、不是 polyfill,也不是一处定点补丁。旧的加载器——用那份重写拉取请 求自己的说法,是「当前这套 C++ 与 JS 的混合体(amalgam of C++ and JS)」,依据的是一份十年 前就已被放弃的提案——被换成了纯 C++ 的重写实现。WebKit 把这次重写归入该文的「Foundations」 一节,旁边正是解释整套做法的那句话:「有时候提升质量 的最好办法就是从头再来。」Kai Tamkun 撰写的工程记录则早两周发布,时间是 2026 年 9 月 2 日。

ECMAScript 规范并没有把模块加载从头到尾定义完。WebKit 的说法是,规范的模块章节把一部分 内容留给宿主环境,其中包括抓取模块的机制——在浏览器里走网络,在 Node.js、Bun 这类运行时 里则来自本地文件系统。Safari 需要为这块宿主定义的部分给出答案,而它当年选的答案是 WHATWG Loader 提案:加载器最初编写时,WebKit 依赖的就是该提案对宿主侧功能的规定。WebKit 指出, 这份提案最后一次更新是在 2016 年 1 月。

以当时的条件看,这个选择是站得住脚的。按 WebKit 的描述,那时模块执行是纯同步的,模块顶层 的 await 还不存在。问题随 ECMAScript 2022 出现——这个版本引入了该特性。而到那时,用 WebKit 的原话说,WHATWG Loader 提案在被 ECMAScript 标准的模块章节取代之后已经基本湮没无 闻,但 Safari 的加载器仍然建立在它之上。于是这项特性是基于一份早于语言中任何 async/await 支持的提案实现的,而不是依照标准关于异步模块求值的算法。WebKit 把由此产生的若干隐蔽缺陷 归因于这种错配,并表示在多次尝试修补之后,这些缺陷多年都没能被彻底解决,团队最终决定重建 基础,而不是继续在旧地基上打补丁。

真正完成这件事的拉取请求是 WebKit/WebKit#57827,标题为「[JSC] Rewrite module loader」。 它于 2026 年 2 月 4 日提交,2026 年 4 月 14 日合并,审阅者为 Yusuke Suzuki 与 Sosuke Suzuki。它的描述毫不含糊地写明了起点:旧加载器存在长期遗留缺陷,包括在合法 ES 模块上触发 断言失败、模块求值顺序错误,以及顶层 await 的种种怪异行为。该补丁移除了 C++ 与 JS 混杂 的实现,换成遵循现代 ECMAScript 规范(而非旧的 WHATWG Loader 提案)的纯 C++ 重写。具体 地说,builtins/ModuleLoader.js 被删除,取而代之出现了一批原生类型—— CyclicModuleRecord、ModuleRegistryEntry、ModuleGraphLoadingState、 ModuleLoadingContext——与规范自身描述的记录与状态机制相对应。

WebKit 这篇文章围绕一个复现案例展开:一个 main.js 并发地三次动态导入同一个模块,并打印 每次得到的命名空间的键;而被导入的那个模块以一个基于定时器的顶层 await 开头。在旧加载器 下,三次导入的完成顺序是 2、3、1,并且前两次以 Cannot access 'someArray' before initialization 失败。在新加载器下,它们按 1、2、3 完成,每个命名空间的导出都是完整的。

WebKit 把这两种症状都追溯到同一个缺陷。当加载器第一次遇到那个带顶层 await 的模块时,它 开始执行该模块,并在 await 处暂停,把控制权交回给导入方;导入方随即发起第二次导入。按理 说,第二次导入的 promise 不应当在第一次导入求值完成之前兑现,但在旧加载器里它立即兑现了。 于是第二次导入在模块求值仍处于挂起状态时就「完成」了,读取它的导出便触碰到尚未初始化的绑 定,从而抛出异常。第三次导入遭遇的是同一件事。只有第一次导入——它在兑现时确实已经完成求 值——成功打印出了键名。

这并不是一个纯理论上的顺序细节。该行为于 2022 年 7 月 14 日被登记为 WebKit bug 242740, 标题是「[JSC] ReferenceError when multiple modules are simultaneously importing a module containing a top-level await」。报告者指出:并发的导入中会有一个被拒绝;改成顺序导入则不 受影响;而在同一份精简复现上,Chrome 与 Firefox 都不会拒绝任何一个 promise。这份报告挂了 大约四年,如今状态为 RESOLVED FIXED,被这次重写的拉取请求关闭。

旧加载器是以自托管内建函数(self-hosted builtin)的形式用 JavaScript 写的。WebKit 很坦诚 地列出了这样做的真实好处:内建函数可以被内联进调用它的用户代码,省去跨越 JavaScript 与 C++ 边界的开销;而且从 JavaScript 创建对象更快,因为有时可以直接消除堆分配。

它列出的缺点才是最终胜出的一方。自托管代码启动更慢,因为它必须在运行时编译,而原生代码早 已提前编译完毕。内建函数天生具有很宽的使用特征,这让 JavaScriptCore 的优化 JIT 编译器更难 利用其使用模式。再者,模块加载器本来就不是热路径,运行时编译带来的收益本就很小。WebKit 给出的净结果是:整体性能不如 C++ 那样稳定和可预测——这正是这次重写彻底放弃自托管路线的 原因。

实现方法刻意地朴素。WebKit 表示,工作从 2026 年 1 月开始,第一步是删掉那个装着旧加载器的 整个 JavaScript 文件,然后把规范中的操作一个一个实现出来,把其中的伪代码翻译成 C++。由于 模块加载机制是一台复杂的状态机,为了给这项工作排序,团队通读规范、记录函数之间的调用关 系、画出一张流程图;像 ExecuteModule 和 ModuleRequestsEqual 这样不依赖其他函数的叶子 函数被最先实现。几周之后,新加载器已能处理最常见的情形,于是一份草稿拉取请求被提了上去。

按文章所述,证据来自三个彼此独立的方向。Bun 的工程师——他们的运行时构建在 JavaScriptCore 之上,因此继承了同一批加载器问题——提供了他们收集到的、能体现错误行为的 测试用例;WebKit 把这些用例适配到 jsc 命令行 shell 并作为测试落地。接着团队写了一个 fuzzer,用它生成由模块(一部分带顶层 await,一部分不带)和 import 语句构成的复杂依赖 图,再把 JavaScriptCore 的输出与其他引擎对比,以逐字节一致作为通过条件;WebKit 报告说, 所有被测样例都得到了正确处理。最后,补丁一直压着没合,直到 test262 中所有与模块相关的测 试通过、WPT 中许多此前失败的模块测试被修好且没有引入回归。拉取请求自身新增的测试点明了 具体的失效形态:concurrent-imports.js 对应求值顺序,sync-from-async.js 对应重写之前 调试版本会触发的一处断言失败,tla-cycle.js 对应含顶层 await 的循环依赖, bare-resolution-failure.js 对应解析失败的缓存。WebKit 补充说,在合并之前,团队已经用装 有新加载器的构建版本日常使用了数周,没有遇到问题。

模块顶层的 await 是让模块直接在模块作用域里完成异步初始化的写法。MDN 把语义讲得很直 白:你可以在模块顶层、在 async 函数之外 单独使用 await;而如果一个模块的子模块使用了 await,这个模块会等待那些子模块执行完再 执行自己,同时并不阻塞其他子模块的加载。WebKit 对模块图的描述与此一致:当某个模块遇到顶 层 await 时,所有导入它的模块都会被挂起,直到该 await 兑现;而依赖图中不依赖它的兄弟 模块仍可并发执行。

来源所描述的触发条件很窄,也容易讲清楚:多个导入方在同一时刻去取同一个含有顶层 await 的模块。bug 242740 报告称,当多个模块同时抓取一个因顶层 await 而需要一些准备时间的导入 时,其中一个导入会被拒绝;而改成按顺序抓取这些模块时,这种情况不会发生。WebKit 自己的复现 就是把这一情形写了出来——同一个模块并发地三次导入那个带顶层 await 的模块。

这件事让开发者付出了什么,记录在这份缺陷报告里。报告者指出,在其附上的精简复现中,Chrome 与 Firefox 都不会出现这种拒绝,这说明该行为属于某一个引擎,而不是这项特性本身。同一份报告 后来的一条评论写道,不做大规模重构就很难绕开它,而且它并不是能用 polyfill 解决的东西;另 一条评论则指向 SvelteKit 仓库中的一个 issue,评论者称其他开发者在那里也撞上了同一个缺陷。 WebKit 还有一个更大的判断值得记下:加载器重建在正确的基础之上 以后,它把 ES 模块整体定位成在 Safari 中可以放心依赖的东西。这是关于加载器整体可靠性的陈 述,而不只是关于一个运算符。

时间上的问题不再是「等发布」,而是「支持底线定在哪」。WebKit 说明,Safari 27.0 随 macOS 27 Golden Gate、iOS 27、iPadOS 27 与 visionOS 27 一同提供,另外也可以在 macOS 26 Tahoe 与 macOS 15 Sequoia 上独立于系统升级安装。也就是说修复已经在正式发布的版本里;还要等的是你 自己的已装机量追上来。

  • 平台 —— 这是某个平台 JavaScript 引擎层面的行为变更,而平台 页正是 OpenPWA 记录「每个浏览器/操作系统组合实际上可以依赖什么」的地方。
  • Baseline 支持概览 —— WebKit 把这项特性的出现 时间定在 ECMAScript 2022;这一页解释了 Baseline 的分档,以及 Baseline 追踪的那组固定浏 览器——要读懂一次只落在单一引擎上的修复,正需要这个参照框架。
  • 按浏览器查看兼容性 —— 在你决定依赖它之前该查的那张 表:它给出 PWA 能力在各浏览器家族上的支持状况,而这正是某个引擎正式发布修复之后你真正要问 的问题。
  • 在 Safari 27.0 上跑一遍你自己的顶层 await 代码。WebKit 在那篇工程文章里的邀请是:下载 Safari Technology Preview 251 或 Safari 27 beta,试着把顶层 await 用进你的 Web 应用, 遇到问题就反馈;而现在 27.0 已是可以这样做的正式版本。
  • 把 Safari 27.0 当作这项修复行为的支持底线:WebKit 说明重写后的加载器在这一版里,而对更早 的版本没有任何同类表述。
  • 如果你当初是靠「把带顶层 await 的模块改成按顺序导入、而不是并发导入」来回避这个缺陷 ——这正是 bug 242740 报告者指出的、失败与不失败两种情形之间的唯一差别——那么在你自己的 统计数据显示 27 以下的版本已经消退之前,生产环境请继续保留。正式发布的修复移动的是支持底 线,而不是今天的流量构成。
  • 如果你仍然看到求值顺序错误,或者仍然撞上「accessed before initialization」这类错误,请提 交反馈。WebKit 把缺陷报告指向 bugs.webkit.org,而 bug 242740 正是「用户报告推动了这项 工作」的先例。
  • WebKit 列出了 Safari 27.0 随哪些系统版本一同提供、以及可以装在哪两个较早的 macOS 版本 上,但这次加载器重写只是在「这个发布版本」的层面被描述的。来源并没有按操作系统版本或设备 类别拆分行为;因此「每个 Apple 平台上的每个 Safari 27.0 构建是否都带着重写后的加载器」, 是就该发布版本给出的论断,而不是本文逐平台核实过的事实。
  • WebKit 对 ES 模块整体可靠性的论断,是厂商对自己工作的表述。它引用的测试证据(test262、 WPT、fuzzer、Bun 提供的用例)具体且可核查,但「可以放心依赖」本身不是一项度量。
  • fuzzer 的对比对象是「其他引擎」的输出,通过条件是逐字节一致。来源并没有列出具体是哪些引 擎,也没有说明生成的依赖图规模有多大。
  • 其他以 JavaScriptCore 为基础的嵌入方——最明显的就是提供了测试用例的 Bun——是否会继承 这次重写、按什么时间表继承,WebKit 的这些来源都没有涉及。