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

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-09-24 15:15 ·1 浏览 ·3 回复
本文转载自 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,别一上来就全套堆满。