JavaScript / 前端性能 / Web Workers
Web Workers 实战:把重计算移出浏览器主线程
前端导入十万行 CSV、压缩图片、计算复杂报表时,即使代码写成 async function,页面仍可能完全卡住。原因很简单:async/await 只改变异步控制流,不会自动把 CPU 计算搬到另一条线程。
Web Workers 提供了真正的后台线程。主线程负责 DOM、输入与绘制,Worker 负责纯计算,两边通过消息传递数据。它不能让算法凭空变快,却能避免一段计算持续占用 UI 主线程。
一、先确认问题适合 Worker
Worker 最适合这些任务:
- 大量数据解析与聚合;
- 图片像素、音视频片段处理;
- 压缩、哈希和复杂数学计算;
- 本地搜索索引;
- 可以明确输入和输出的纯计算。
网络请求本身通常不会持续占用主线程,把一次普通 Fetch 放进 Worker 收益有限。频繁操作 DOM 的代码也不适合,因为 Worker 不能直接访问 document 和页面节点。
先在性能面板确认主线程存在长任务,再决定是否引入 Worker。算法复杂度能从 O(n²) 降到 O(n log n) 时,优先优化算法;Worker 是隔离计算,不是掩盖低效实现。
二、创建一个模块 Worker
主线程:
1 | const worker = new Worker( |
Worker 文件:
1 | self.addEventListener('message', (event) => { |
new URL(..., import.meta.url) 让构建工具能够定位 Worker 文件;type: 'module' 允许在 Worker 内使用 ES Modules。具体打包支持仍要以项目工具链为准。
三、消息数据默认会复制
MDN 的 Worker 指南指出,主线程与 Worker 之间通过 postMessage() 发送的数据会被复制,而不是直接共享。底层使用结构化克隆,可以处理数组、普通对象、Map 等多种结构,但函数和 DOM 节点不能这样传递。
1 | worker.postMessage({ rows: hugeArray }); |
如果 hugeArray 很大,序列化与复制本身可能成为开销。不要每处理一条数据就发一次消息,也不要反复传输整份状态。更合适的是:
- 以合理批次发送;
- Worker 内保留计算所需状态;
- 返回聚合结果而不是完整中间数据;
- 测量消息传输时间,不只测计算函数。
四、大型二进制数据使用 Transferable
ArrayBuffer 可以转移所有权,避免复制整块内存:
1 | const buffer = await file.arrayBuffer(); |
传输后,主线程中的原 buffer 会被分离,不能继续读取。这不是 bug,而是所有权已经交给 Worker。
Worker 处理后也可以转回:
1 | self.postMessage( |
Transferable 很适合图片、音频和大文件,但对小对象滥用只会增加代码复杂度。先用实际数据测量。
五、设计消息协议,不要只传裸数组
随着功能增加,消息应具有明确类型和请求 ID:
1 | let nextRequestId = 0; |
请求 ID 防止并发任务结果对应错调用方。消息结构应当像一个小型 API:有版本、类型、输入、成功结果和可展示错误,而不是靠数组下标猜含义。
Worker 内部错误对象不能假设能完整跨线程保留原型与堆栈。生产环境可以返回受控错误码,同时在 Worker 的 error 事件中记录诊断信息。
六、取消与终止
worker.terminate() 会立即终止整个 Worker:
1 | cancelAllButton.addEventListener('click', () => { |
它适合页面离开或 Worker 不再需要,但会中断其中所有任务。若一个 Worker 同时处理多个请求,应设计协作式取消:
1 | // 主线程 |
纯同步大循环中,Worker 的消息事件也要等当前任务结束才能处理。示例在每批之后让出当前任务,让 Worker 有机会读取取消消息;批次大小仍要根据实际计算耗时调整。
七、Worker 不能直接做什么
Worker 没有页面的 window 和 DOM,不能调用 document.querySelector()。它可以使用一部分 Web API,例如 Fetch、定时器和某些存储能力,但可用范围应查对应 Worker 文档。
正确分工通常是:
1 | 主线程:收集输入 → 发送数据 → 展示进度 → 渲染结果 |
不要让 Worker 决定页面显示哪个弹窗或修改哪个按钮;那会把 UI 状态分散到线程两边。Worker 返回领域结果,主线程决定呈现。
八、进度反馈与批次
耗时两秒以上的任务应给用户进度:
1 | for (let index = 0; index < rows.length; index += batchSize) { |
进度消息也不要过密。每条记录都上报会让消息成本和主线程渲染抵消 Worker 收益。按时间间隔或较大批次上报更合理。
九、上线前检查清单
- 性能记录是否证明瓶颈是 CPU 长任务,而不是网络或 DOM?
- Worker 输入输出是否足够小、可序列化?
- 大型 ArrayBuffer 是否适合转移所有权?
- 并发任务是否有请求 ID 和错误协议?
- 页面离开、重复计算和用户取消时如何清理?
- Worker 加载失败时是否有可理解的回退提示?
- 低端设备上的计算时间与内存是否实测?
Web Workers 的核心不是“多线程很快”,而是把线程职责分开。只要输入输出边界清楚、传输成本受控、错误和取消协议完整,主线程就能把宝贵的时间留给输入与渲染,让复杂计算不再等同于页面冻结。
参考资料
- MDN:Using Web Workers — https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers