Safari 的 JavaScript 引擎存在一个隐藏缺陷:当模块化风格的 Web Worker 入口脚本在 bundle 的其他地方被导入时,Safari 会第二次执行该入口脚本。这种重复运行会破坏单例状态(singleton state),从而在无声无息中破坏依赖共享缓存或单例对象的 Worker。
在开发一款使用 Web Worker 解码 ProRes 文件的浏览器端视频处理应用时,这个问题浮出水面。Chrome 和 Firefox 都能正常运行代码,但 Safari 始终无法加载视频。控制台仅报告了通用的“无法读取视频”错误,而真正的罪魁祸首是 Worker 的初始化代码运行了两次,导致内存中留下了两个完全独立的同名模块副本。
该 Bug 的表现形式
现代打包工具(Vite、Rollup 等)通常会将共享工具函数提取到 Worker 的入口文件中,以便懒加载的 chunk 可以从该入口点重新导入这些代码。在遵循标准模块加载器行为的浏览器中,一旦入口模块被实例化,加载器会对任何后续的导入返回同一个模块对象,从而防止第二次执行。
Safari 偏离了这一预期。当一个懒加载的 chunk 导入 Worker 的入口文件时,Safari 会将该导入视为一个新的模块请求,并重新执行入口脚本。其结果是,该文件中定义的每个变量、类或单例都会产生两个独立的实例。
入口脚本运行两次会导致什么问题
- 单例和缓存不再共享数据;一个副本看到的是空缓存,而另一个副本则在填充它。
- 注册表(例如消息处理程序列表)最终会分散在两个实例之间,导致其中一方实际上是空的。
- 事件监听器被绑定了两次,可能导致重复处理或内存膨胀。
- WebAssembly (WASM) 模块加载两次,浪费带宽和初始化时间。
- 这种失败是无声的:不会抛出未捕获的异常,只会导致依赖缺失状态的下游逻辑出现异常。
在项目中检测该问题
通过对构建后的资产进行快速 grep 搜索,可以发现是否有任何 chunk 导入了 Worker 的入口文件:
grep -l 'from"./your.worker-' dist/assets/*.js
如果命令列出了任何文件,那么这些导入很可能触发了 Safari 中的双重运行 Bug。
实际的解决方法
从 Worker 入口中提取共享代码。
配置打包工具将公共库放入它们自己的 chunk 中(例如使用 Rollup 的manualChunks)。这样,Worker 和任何懒加载模块都会从这个第三个文件中导入该库,从而消除了导入 Worker 入口点的需求。使用轻量级入口文件。
将 Worker 的入口脚本简化为仅一行代码,用于重新导出实际的实现:// worker-entry.js import("./main.js");只要没有其他 bundle 导入
worker-entry.js,Safari 就不会看到第二次导入请求,因此入口脚本只会运行一次。
这两种方法都能确保 Worker 的初始化代码在整个应用程序中保持单例特性。
为什么这个 Bug 至关重要
Web Worker 是将重度计算(视频编码、图像处理、加密)从主线程卸载的常用模式。状态的无声分裂可能使一个功能完备的特性变成一个间歇性的故障,且仅出现在 Safari 上——而 Safari 是大量桌面和移动设备上的默认浏览器。由于错误表现为通用的媒体加载失败,开发者可能会花费数小时去追踪错误的症状。
该 Bug 还凸显了一个更广泛的风险:依赖于在不同浏览器中实现并不统一的模块加载器语义。当打包工具的优化策略假设模块是单个共享实例时,任何偏差都会破坏这一假设。
反方观点与开放性问题
Safari 的行为符合其自身的模块解析规则,这些规则在涉及 Worker 的边缘案例时与规范存在细微差异。一些开发者认为,打包工具应该完全避免将共享代码放在 Worker 的入口文件中,从而使该问题成为构建规范的问题,而非浏览器缺陷。另一些人则指出,Safari 的这种偏差并未被记录,导致开发者无法可靠地预见它。
Apple 尚未公开承认该问题,目前也没有已知的修复时间表。在 Safari 更改其加载器之前,开发者仍需承担重构 bundle 或在 CI 流水线中添加检测逻辑的责任。
后续关注
- 浏览器更新 – 密切关注 Safari 的发行说明,查看是否有关于 module-worker 处理的相关说明。
- 打包工具社区补丁 – Vite、Rollup 等工具可能会引入警告或自动分块策略,以避免触发该 bug 的模式。
- 测试实践 – 在发布前,在 Safari 上结合真实的媒体文件进行全栈 worker 测试,可以及早发现这种静默失败。
总结
如果您的 Safari 用户遇到了无法解释的 worker 相关故障,请检查是否有任何非 worker 的 bundle 导入了 worker 的入口脚本。这种双重执行的 bug 会在无声无息中破坏单例状态,但通过将共享代码移出入口点,或者将入口点简化为轻量级的重新导出(re-export),即可在无需等待浏览器修复的情况下恢复正常行为。
