Web Worker 实战 把重计算挪出主线程
咖啡与风衣
7 min read
Web Worker 实战 把重计算挪出主线程
主线程卡顿的直接原因是"计算挤占了渲染时间"。Web Worker 能在独立线程跑脚本,主线程只负责收发消息,把重活交给 Worker,交互就不容易掉帧。很多页面卡顿不是因为数据量大,而是因为一段本可异步的循环被同步放在了主线程,Worker 正是为这类场景准备的逃生通道。
何时该用 Worker
经验上,满足下面任一条就值得考虑 Worker:单次数组处理超过数万项、复杂排序/过滤、图像或音视频的逐像素处理、加解密或大体积 JSON 解析。反之,几十次循环的小计算放进 Worker,光通信开销就亏了。判断标准很简单:用性能面板录一段操作,如果长任务(Long Task)落在主线程的脚本执行上,且这段脚本是纯计算、不碰 DOM,那它就该搬走。
通信:postMessage 与结构化克隆
主线程创建 Worker 并收发消息:
const worker = new Worker('calc.js');
worker.postMessage({ type: 'sum', data: bigArray });
worker.onmessage = (e) => {
console.log('结果', e.data);
};
Worker 内部监听并回传:
onmessage = (e) => {
if (e.data.type === 'sum') {
const sum = e.data.data.reduce((a, b) => a + b, 0);
postMessage(sum);
}
};
注意:postMessage 默认走结构化克隆,对象会被深拷贝,不能传函数、DOM 节点、或带方法的对象。大数组克隆本身有成本,可用 Transferable 把缓冲区所有权转移给 Worker,省去拷贝:
const buf = new ArrayBuffer(bigArray.length * 4);
worker.postMessage({ buf }, [buf]); // 转移后主线程不再持有
转移后主线程那份内存即失效,适合"算完就不再需要原数据"的场景;若主线程还要用,则只能接受克隆开销或手动分块传。
一个图像处理例子
把一张图做灰度化,逐像素计算放在 Worker,避免界面冻结:
// main
canvas.toBlob(blob => {
blob.arrayBuffer().then(buf => {
worker.postMessage({ type: 'gray', buf }, [buf]);
});
});
// worker
onmessage = (e) => {
const pixels = new Uint8ClampedArray(e.data.buf);
for (let i = 0; i < pixels.length; i += 4) {
const g = (pixels[i] + pixels[i+1] + pixels[i+2]) / 3;
pixels[i] = pixels[i+1] = pixels[i+2] = g;
}
postMessage(pixels.buffer, [pixels.buffer]);
};
这种"主线程取像素 -> Worker 算 -> 回传"的模式,能把原本几百毫秒的卡顿变成几乎无感,且处理期间界面仍可滚动响应。
边界与坑
- Worker 里没有
window、document,DOM 操作、弹窗、路由都不可用;需要时把数据算完回传主线程再渲染。
- 小计算放 Worker 反而因通信开销变慢,只有"重且可分批"的计算才划算;可以先设阈值,超过再走 Worker。
- 多个 Worker 并行要控制数量,移动端核心数有限,开太多会互相抢资源;可用一个 Worker 池复用,避免频繁创建销毁。
- 出错时 Worker 不会自动冒泡到主线程的 try/catch,要在 Worker 内监听
error 事件并回传,否则失败会静默吞掉。
- 模块化 Worker(
type: 'module')支持 import,但要注意构建工具对 Worker 产物的打包方式,路径错会导致 404。
与主线程的协作模式
Worker 不是孤立的,它和主线程是生产者-消费者关系。主线程负责采集输入、派发任务、接收结果并渲染;Worker 只做纯计算。为了不让 Worker 成为新的瓶颈,可以把一个大任务拆成多个小消息,每处理完一块就回传中间结果,主线程借此更新局部 UI,而不是干等。这种模式特别适合"边算边显"的场景,比如大列表的逐批格式化。另一种常见协作是池化多个 Worker 并行处理数组分片,最终在主线程合并结果,相当于在前端做了一层轻量 map-reduce,能把原本串行的计算摊到多核上。另外,Worker 中无法访问 localStorage,需要共享状态时改走 IndexedDB 或主线程中转,否则会因环境差异踩坑。
任务拆分与进度回报
当计算量极大(如处理几十万条记录)时,即便放进 Worker,单次回传也要等全部算完,用户看不到进度。可以把数据分块,每块算完用 postMessage 回报进度,主线程更新进度条,处理完再回传最终结果。这样既避免 Worker 长时间无响应被误判卡死,也改善了体验。分块大小要权衡:太细通信次数多,太粗进度不平滑,按数据量和可接受的回报间隔折中。
Worker 池与生命周期
频繁创建销毁 Worker 有开销,稳定场景应复用:启动时建一个固定大小的 Worker 池,任务投进去排队,空闲 Worker 认领。页面卸载时记得 worker.terminate() 释放线程,否则后台可能残留占用。要注意 Worker 脚本的缓存与版本:更新 Worker 逻辑后,旧 Worker 实例可能仍在跑旧代码,必要时通过带版本号的 URL(如 calc.js?v=2)强制刷新 Worker 文件。还有同源限制:Worker 脚本必须与主页面同源,跨域需借助代理或 Blob URL 包裹。调试 Worker 时建议开独立日志通道,因为 Worker 内的 console 在部分工具里层级显示不同,异常容易被看漏,定位问题时优先看上报而非只盯控制台。
经验上,Worker 是"把卡顿拆走"的手段,不是银弹。先 profiling 确认瓶颈在主线程计算,再引入,否则只是增加了复杂度。引入后记得对比前后 Long Task 数量,确认真的把计算挪走了,而不是只是把卡顿换了个地方。
1 view
0 likes
0 comments