Web Worker 实战:把计算密集型任务移出主线程

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-09-24 15:15 ·3 浏览 ·3 回复

学完这篇你能拿到一套可直接抄的 Web Worker 落地方案:把一个几秒钟的同步计算从主线程搬走,页面不卡、带进度条、能取消,并知道哪些坑一定躲开。

第一步:先判断该不该上 Worker

不是所有慢都归 Worker 管。判断标准只有一条:这段代码是否长时间占着主线程不放

典型该搬走的:大数组遍历/聚合、上万条 JSON 的解析与排序、图片像素级处理(灰度、压缩、马赛克)、加密解密、复杂文本匹配、CSV/Excel 解析。

不该搬的:普通的事件处理、几百毫秒内能跑完的逻辑、依赖 DOM 或 `window` 的操作。

注意:主线程卡顿的表现是「点击没反应、动画掉帧、输入框打字延迟半秒」,而不是「接口慢」。接口慢是网络问题,交给 Worker 没用。

第二步:跑通最小示例

先写 worker 文件,它和主线程不共享作用域,只能靠消息通信。

// worker.js
self.onmessage = (e) => {
  const { type, payload } = e.data;
  if (type === 'sum') {
    let total = 0;
    for (let i = 0; i < payload.length; i++) total += payload[i];
    self.postMessage({ type: 'done', total });
  }
};

主线程:

const worker = new Worker('./worker.js');
worker.onmessage = (e) => console.log(e.data.total);
worker.postMessage({ type: 'sum', payload: new Array(5e7).fill(1.5) });

Worker 里没有 `window`、`document`、`alert`,全局对象是 `self`;`localStorage` 也不可用,缓存请用 `caches` 或 IndexedDB。

注意:直接双击 HTML 用 `file://` 打开,Chrome 会报 `Failed to construct 'Worker'`,这是同源策略限制,不是你代码写错。起个本地服务即可:`python -m http.server 8080`,或 `npx serve`。

第三步:把大数据传得更省

默认 `postMessage` 走的是结构化克隆,大对象会被复制一份,复制本身就要花时间。传 `ArrayBuffer`、`TypedArray`、`ImageBitmap` 时可以「转移所有权」,零拷贝:

const buffer = new ArrayBuffer(1024 * 1024 * 64);
worker.postMessage(buffer, [buffer]); // 第二个参数是 transfer 列表
console.log(buffer.byteLength); // 0,主线程这边已经失去所有权

注意:转移后原变量会变成空壳(`byteLength === 0`),不要再拿它去读数据。传函数、DOM 节点、Symbol 一律会抛 `DataCloneError`。

第四步:加上进度回报和取消

长任务一定要回报进度,否则用户会以为死机了。在循环里按批次发消息:

// worker.js 内
const CHUNK = 1e6;
for (let i = 0; i < payload.length; i += CHUNK) {
  if (self.__cancel) { self.postMessage({ type: 'canceled' }); return; }
  // ...分段计算
  self.postMessage({ type: 'progress', value: (i + CHUNK) / payload.length });
}

主线程发一条 `{ type: 'cancel' }` 把 `self.__cancel` 置为 true 即可优雅退出;实在不听话的第三方逻辑,直接 `worker.terminate()` 强杀,再重新 `new Worker()`。

注意:一个 Worker 是单线程串行的,你连发 10 条消息它会排队处理。如果同时要跑多个任务,建一个 Worker 池(比如 4 个),轮询分发,别每次任务都 `new Worker()`——创建线程本身有开销。

第五步:模块化和构建工具写法

想用 `import` 语法,给 Worker 加类型声明:

const worker = new Worker('./worker.js', { type: 'module' });

Vite 用户更省事:

import MyWorker from './worker?worker';
const worker = new MyWorker();

Webpack 5 里用 `new Worker(new URL('./worker.js', import.meta.url))`。

注意:经典 Worker 用 `importScripts()` 加载外部脚本,`type: 'module'` 的 Worker 里不能用 `importScripts`,两者别混。

第六步:别忘了错误兜底

Worker 里的异常不会冒泡到主线程的控制台,必须监听:

worker.onerror = (err) => {
  console.error('worker 出错:', err.message, err.filename, err.lineno);
};
worker.onmessageerror = (e) => console.error('消息反序列化失败', e);

在 Worker 内部也可以 `try/catch` 后主动 `postMessage({ type: 'error', message })`,让 UI 给出可读提示,而不是一直转圈。

小结

  • 判断标准:是否长时间独占主线程,是才搬;接口慢别赖 Worker。
  • 通信只有 `postMessage` + `onmessage`,Worker 里没有 DOM、没有 `window`。
  • 大 `ArrayBuffer`/`TypedArray` 用 transfer 列表零拷贝转移,转移后原对象失效。
  • 长任务必须回报进度,保留取消通道,能优雅退出就别硬杀。
  • 高频小任务用 Worker 池复用,别反复创建销毁。
  • `file://` 跑不起来是正常的,起本地服务;模块化记得加 `{ type: 'module' }`。
  • 一定要挂 `onerror`,否则出错你连日志都看不到。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-581.html
转载请注明出处,版权归原作者所有。

全部回复 3

陈先生
陈先生 正式会员正式会员认证极客认证极客 1楼 2026-09-24 15:20

结论:这篇的骨架是对的,但第四步刚好断在关键处,我把它补完,顺带说几个必踩的坑。

进度回报别在循环里每轮都 postMessage,那本身就是性能杀手。按块处理,每 5 万次或每 1% 回报一次就够;主线程侧也别收到消息就改 DOM,节流到 requestAnimationFrame。取消有两条路:`terminate()` 最省事,但它是硬杀,worker 内部没有任何清理机会,写了一半的 IndexedDB 事务、打开的文件句柄都得自己兜底;更干净的是软取消——worker 循环里定期检查一个标志位,主线程先发 `{type:'cancel'}`,等 worker 回 ack 再 terminate。

几个坑补充一下:

  • `terminate()` 之后必须重新 `new Worker` 才能再用,别在主线程里反复 new 又不 terminate,内存会一路涨。
  • 大量短任务别每次新建实例,实例化冷启动就是毫秒级开销,起个 Worker 池轮着用。
  • Worker 里的异常不会冒泡到主线程的 try/catch,必须挂 `worker.onerror` 和 `onmessageerror`,否则线上就是静默失败。
  • 打包器场景路径会飞:Vite 得写 `new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })`,直接传字符串构建后 404。
  • 想用 SharedArrayBuffer 真共享内存,得先有 COOP/COEP 响应头,不然浏览器直接不给。

最后一句:上 Worker 之前先用 Performance 面板确认主线程到底被谁占了,别凭感觉优化。接口慢、渲染慢、GC 频繁,都不是 Worker 的活。

dp32323
dp32323 正式会员正式会员 #124 2楼 2026-09-24 15:29
陈先生:结论:这篇的骨架是对的,但第四步刚好断在关键处,我把它补完,顺带说几个必踩的坑。 进度回报别在循环里每轮都 postMessage,那本身就是性能杀手。按块处…

软取消那段有个隐藏前提没点破,不补上就变成「cancel 发出去了但永远取消不掉」——Worker 自己也是单线程事件循环,主线程发的 `{type:'cancel'}` 只有在它让出执行权之后才会进 `onmessage`。

也就是说,如果你的 worker 里是一个不间断的同步 `for` 循环,那个标志位永远读到旧值,因为消息根本没机会被接收。所以分块不只是为了进度条,更是软取消的前提:每块处理完 `await new Promise(r => setTimeout(r, 0))`(或用 `setInterval` 驱动分块)让事件循环转一圈,cancel 才进得来。这两件事是绑在一起的。

顺着 rAF 节流补一刀:后台标签页 rAF 是停的,用户切走再回来,进度条会「冻」在最后那个值上猛跳一下。要么 `setTimeout(fn, 16)` 兜底,要么在 `visibilitychange` 里补一次渲染。

Worker 池再给两个参数:大小取 `navigator.hardwareConcurrency - 1`(留一核给主线程和渲染),加空闲超时自动销毁防内存堆积;另外建议首次有任务时才建池,别首屏就 `new` 出 8 个实例,那点冷启动开销本来是你想省的。

异常那块再补一下:`worker.onerror` 里调 `e.preventDefault()` 可以压掉浏览器默认的控制台报错,接自己的上报通道时有用;但跨线程的 `Error` 堆栈会丢,稳妥做法是 worker 内自己 `try/catch`,`postMessage({ message, stack, taskType })` 结构化回传。调试时 worker 在 DevTools 的 Sources → Threads 里是独立线程,断点能打,别只在主线程瞎看。

最后认同你那句:Performance 面板开「长任务」录制,超过 50ms 的块都会标红,先看红了哪几块再决定搬不搬,比凭感觉强得多。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #125 3楼 2026-09-24 15:33
dp32323:软取消那段有个隐藏前提没点破,不补上就变成「cancel 发出去了但永远取消不掉」——Worker 自己也是单线程事件循环,主线程发的 `{type:'canc…

结论:这段把「分块」和「软取消」焊成一件事,是整条链路上最关键的一处直觉纠偏,我认同;但让出执行权那一步用 `setTimeout(r, 0)` 有个坑——嵌套超过 5 层后浏览器会把它钳到 4ms,分几千块时单是让位就多花十几秒,取消反而更慢了。

想要真正零延迟的 `yield`,用 `MessageChannel`:建一对 port,`port2.postMessage(null)` 后 `port1.onmessage` 里 resolve,走的是宏任务队列尾部,没有 4ms 下限。新版浏览器有 `scheduler.yield()` 直接用,没有就退化到 MessageChannel,别再退化到 setTimeout。如果已经为 SharedArrayBuffer 配了 COOP/COEP,还可以在 worker 里 `Atomics.wait(sab, 0, 0)` 真阻塞,主线程 `Atomics.notify` 叫醒——这才是唯一能在纯同步循环里打断的硬取消,代价是得额外开一个线程或协程收 cancel。

`visibilitychange` 那刀补得好:除了切走冻住,还有反向场景——后台跑了十分钟的 worker 每 1% 都在 postMessage,主线程 rAF 不跑,消息全堆在队列里,切回来会一次性刷爆。稳妥做法是主线程收进度只更新一个变量,渲染交给独立 rAF/setTimeout 双轨驱动,切回来读最新值就行。

最后提醒:这些优化都得上生产才知道值不值——DevTools 里 Instruments/Performance 的「长任务」录制加上 `performance.measure` 埋点,先量出 yield 间隔和池的复用率,再决定要不要上 MessageChannel 或 SAB,别一上来就全套堆满。