约 9 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
主线程只有一份。它同时负责解析 HTML、执行脚本、计算样式、布局和绘制,任何一段同步代码占住它超过 50ms,用户就会感到输入延迟;超过 100ms,滚动和动画开始肉眼可见地掉帧。Web Worker 解决的正是这一类问题:把纯计算搬到另一个线程,让主线程回到「响应输入」这一件事上。但它不是通用加速器。搬错任务的代价通常比不搬更高——序列化要时间,调试少一层调用栈,构建多一个入口文件。合理的判断顺序是:先用 Performance 面板量出这段代码真实阻塞了多久,再看它是否碰 DOM,最后才决定搬不搬。反过来先写 new Worker 再找理由,得到的往往是更慢的页面和更难查的 bug。顺带说明,Worker 的线程模型是真正的并行,不是协程:两个 Worker 可以在两个 CPU 核心上同时跑,这和 async/await 有本质区别,后者只是在一条线程上切换任务。
| 方案 | 适合的任务 | 代价 |
|---|---|---|
| 主线程 | 读写 DOM、需要同步返回值、5ms 以内的轻量计算 | 超过 50ms 就掉帧,输入延迟直接可感 |
| Web Worker | 纯计算:大 JSON 解析、加解密、排序、像素处理、语法分析 | 结构化克隆要拷贝数据,拿不到 DOM,调试和打包各多一层 |
| requestIdleCallback | 可延迟、可丢弃的收尾工作,如埋点上报、预取 | 只在帧空闲时执行,Safari 长期未实现,不能用来跑重活 |
Worker 的全局环境是 DedicatedWorkerGlobalScope。能用的东西不少:self、postMessage、addEventListener、fetch、XMLHttpRequest、WebSocket、IndexedDB、crypto.subtle、TextEncoder、setTimeout、queueMicrotask。拿不到的东西同样明确:window、document、alert、localStorage,以及任何 DOM 节点——连读取一个 input 的值都做不到。这不是规范疏漏,而是前提:DOM 只属于主线程。因此判断标准很干脆,只要逻辑需要读写 DOM,或者必须同步把结果返回给调用方,它就不适合进 Worker;纯函数、纯数据变换、不依赖页面状态的计算才是候选。附带一句,Worker 里可以创建 OffscreenCanvas 直接绘制,但像素最终仍要交回主线程才能上屏。还有一处容易忽略的边界:两个 Worker 之间通信不必经过主线程。用 MessageChannel 建一对端口,主线程只负责把端口转交过去,之后数据在 Worker 之间流动时不占用主线程时间片。把一个大数组切成四份并行处理时,这一点很关键,否则每一片都要中转一次,并行就退化成了串行。
// 经典 Worker:可以用 importScripts,不能用 import
const legacy = new Worker('/workers/parse.js');
// 模块 Worker:可以用 import / export,importScripts 不可用
// 必须显式传 type: 'module',否则 import 语句直接报语法错误
const worker = new Worker(new URL('./parse.worker.js', import.meta.url), {
type: 'module',
});
worker.postMessage({ cmd: 'parse', text });
worker.onmessage = (e) => render(e.data.rows);
worker.onerror = (e) => console.error(e.message, e.filename, e.lineno);postMessage 传的是拷贝。默认走结构化克隆:对象被递归复制,函数、DOM 节点、原型链全部丢失,遇到函数或循环引用直接抛 DataCloneError。关键在于这次复制同步发生在发送方线程上——向 Worker 发一个 50MB 的普通对象,主线程要停下来把它序列化,Worker 那边再反序列化一遍,这笔开销足以把并行收益吃干净,页面照样卡。真正零拷贝的路径只有一条:把 ArrayBuffer、TypedArray 的 buffer、MessagePort、ImageBitmap、OffscreenCanvas 放进 postMessage 的第二个参数(转移列表),所有权被移动而不是复制。移动之后发送方那一侧的引用立刻变成空壳,byteLength 归零,再读就是空的。设计消息协议时尽量传 ArrayBuffer 或扁平的纯数据对象,别把嵌套很深的业务实体整个丢过去。序列化耗时和对象结构强相关:同样是 10MB,扁平的数字数组远比嵌套很深、键名很长的对象便宜。所以「数据量」本身不是可靠指标,唯一可信的做法是在真实数据上量一次,看主线程那段耗时是否真的从 800ms 降到 20ms,而不是从 800ms 变成 700ms 加一次 100ms 的卡顿。
// 主线程:转移 64MB 缓冲区,不发生拷贝
const buffer = new ArrayBuffer(64 * 1024 * 1024);
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0,所有权已经交出
// worker 内部:处理完再把所有权转移回来
self.onmessage = (e) => {
const view = new Uint8Array(e.data.buffer);
for (let i = 0; i < view.length; i++) view[i] ^= 0xff;
self.postMessage({ buffer: view.buffer }, [view.buffer]);
};实际动手时按这个顺序推进:第一步在 Performance 面板里定位长任务,只有超过 50ms 且不碰 DOM 的才进入候选名单;第二步把计算切成「可序列化的输入」和「可序列化的输出」,数据量超过几 MB 就改用 Transferable;第三步给 Worker 补齐 onerror、超时和重试兜底,因为它的失败对主线程完全静默;第四步再考虑用 workerpool 之类的池化管理,避免每个请求都 new 一个 Worker。始终记住 requestIdleCallback 不是 Worker 的替代品,它只适合把不重要的收尾工作挪到帧末尾,重活放进去只会被无限推迟。最后补一句可观测性:Worker 崩溃时主线程只收到一个 error 事件,堆栈指向 Worker 文件的行号,和业务代码的调用栈对不上。上线前把 sourcemap 一起产出,错误上报里带上 Worker 文件名和构建版本,排查成本会明显下降。另外,Worker 里的 console 输出会汇总到主线程控制台并标注来源,调试不需要开额外开关,但生产环境记得压掉日志级别,否则日志本身就会拖慢 Worker。
不能。Worker 没有 localStorage 和 sessionStorage,持久化只能用 IndexedDB。
任务要尽快完成、结果有时序要求,用 Worker;任务可以无限延后甚至丢弃(埋点上报、预取),用 requestIdleCallback。后者在 Safari 长期未实现,不能当主力方案。
因为 new Worker('./x.js') 里的字符串在运行时才解析,打包器看不到这个入口。改成 new URL('./x.js', import.meta.url),并确认构建产物里该文件被单独输出到正确目录。
页面没有开启跨源隔离。需要响应头 Cross-Origin-Opener-Policy: same-origin 和 Cross-Origin-Embedder-Policy: require-corp,两者同时生效后 self.crossOriginIsolated 才会是 true。