目录
一、先确认问题:卡的是计算,还是主线程
浏览器的主线程同时负责三件事:执行 JavaScript、样式与布局计算、处理用户输入。它们共享同一个事件循环。一段 200 ms 的同步计算跑起来,页面不会「变慢一点」,而是这段时间里点击没有反馈、动画掉帧、输入框里的字符要等计算结束才出现。Core Web Vitals 里的 INP 量的就是这种延迟。
所以「要不要上 Worker」的判断标准不是「计算复不复杂」,而是:这段计算是否阻塞了主线程,并且它的结果不需要同步返回。两个条件缺一不可。一个必须在点击瞬间同步算出布局位置的 3 ms 计算,放进 Worker 反而更慢;一个 800 ms 的报表导出,即使只跑一次,也值得搬走。
Worker 提供了三种形态,先分清用途:
| 类型 | 创建方式 | 生命周期 | 适合做什么 |
|---|---|---|---|
| Dedicated Worker | new Worker(url) | 跟随创建它的页面 | 页面私有的重计算、解析、编解码 |
| Shared Worker | new SharedWorker(url) | 同源多个页面共享 | 多标签页共享连接或状态 |
| Service Worker | navigator.serviceWorker.register() | 由浏览器管理,随时可能被终止 | 网络代理、离线缓存、推送 |
Service Worker 虽然也跑在独立线程,但它的设计目标是拦截网络请求,浏览器可以在空闲时终止它,不适合承载长计算。本文说的「Worker」默认指 Dedicated Worker,Service Worker 的离线策略见另一篇。
下面按三笔账展开:消息传递、线程开销、共享状态。每一笔都有对应的代码和容易踩的坑。
二、第一笔账:消息不是引用,是深拷贝
2.1 结构化克隆做了什么
worker.postMessage(value) 不会把对象「传过去」,而是按结构化克隆算法把它序列化成一份独立的副本:序列化在发送方线程同步完成,反序列化在接收方线程完成。两边拿到的不是同一个对象,修改互不影响。
能克隆的类型比 JSON.stringify 多得多:Map、Set、Date、RegExp、ArrayBuffer、各种 TypedArray、DataView、Blob、File、ImageData、ImageBitmap、Error、BigInt,以及由这些类型组成的普通对象和数组。
不能克隆的也很明确:
- 函数、
Symbol、Promise、WeakMap、WeakSet; - DOM 节点、
window、document; - 带方法的类实例——克隆后原型链丢失,变成一个普通对象。
如果值里含有不可克隆的成员,postMessage 会在发送方同步抛出 DataCloneError,而不是静默丢弃。这一点在调试时反而好用:错误发生调用栈就在你面前。
class Task { constructor(id) { this.id = id; } run() { return this.id * 2; }}
const task = new Task(1);worker.postMessage(task);// 接收方拿到的是 { id: 1 },task.run 不存在克隆还有几个容易忽略的细节:
- 只复制可枚举的自有属性,原型链、方法、属性描述符都不保留;getter 会被读取成普通数据属性。
undefined、NaN、-0、循环引用都能正确保留,这一点比 JSON 强。- 同一份对象图里的共享引用会被还原成共享引用,不会复制成两份。
最后一条常被误解:如果 const shared = { x: 1 }; postMessage({ a: shared, b: shared }),接收方拿到的 a 和 b 指向同一个对象。克隆保持图的形状,但整张图仍然是副本。
2.2 克隆的成本落在谁头上
关键点:序列化发生在调用 postMessage 的那个线程上。主线程把 50 MB 的 Float32Array 发给 Worker,即使 Worker 立刻开始干活,主线程也要先花时间把数据序列化完,这段时间照样阻塞输入和渲染。
const big = new Float32Array(12_500_000); // 约 50 MBconsole.time('postMessage');worker.postMessage(big);console.timeEnd('postMessage'); // 这段时间主线程是卡住的console.time 量到的是序列化耗时,反序列化在 Worker 侧,不在这个区间里。数据越大、嵌套越深、对象越多,这个停顿越明显。它和你要搬走的计算一样,都发生在主线程。
所以「把计算放进 Worker」并不自动等于「主线程不卡」。如果每帧都把一大块像素数据克隆过去,主线程的负担可能没减多少,只是从「计算」换成了「序列化」。
2.3 Transferable:把所有权交出去
postMessage 的第二个参数是转移列表(transfer list)。列进去的对象不复制,而是把所有权移交给接收方:
const buffer = await response.arrayBuffer(); // 比如 50 MB
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0转移之后,发送方的 ArrayBuffer 被分离(detached):byteLength 变成 0,再基于它创建视图会抛错。数据没有消失,只是换了个线程持有。这条路径的耗时与数据大小基本无关,因为它不搬运字节。
常见的可转移对象包括:
ArrayBuffer(TypedArray 要转移它底层的 buffer,postMessage(view, [view.buffer]));MessagePort(用于建立独立信道);ImageBitmap;OffscreenCanvas;- 部分浏览器还支持
ReadableStream、WritableStream。
Worker 把结果送回来时同样应该转移,否则结果又会被完整复制一遍:
// worker 内self.onmessage = ({ data }) => { const result = new Float32Array(data.length); // ... 计算 self.postMessage({ result }, [result.buffer]);};2.4 一个必须知道的边界
转移只解决「移动」,不解决「共享」。两个线程不能同时读写同一个普通 ArrayBuffer——它同一时刻只属于一边。如果计算需要主线程持续读取中间结果,或者多个 Worker 要操作同一块内存,就进入第三笔账:SharedArrayBuffer。
另外,MessagePort 本身也是转移对象。需要长期的双向信道时,可以创建 MessageChannel,把一端转给 Worker:
const channel = new MessageChannel();worker.postMessage({ port: channel.port2 }, [channel.port2]);channel.port1.onmessage = (event) => { console.log('来自 worker 的独立消息', event.data);};这条信道不经过 worker.onmessage,适合让 Worker 主动推送进度,而不必和请求响应挤在同一个消息队列里。
三、第二笔账:线程本身也有成本
3.1 启动不是免费的
创建 Worker 意味着一个新的 JavaScript 执行环境:加载脚本、编译、初始化堆。模块 Worker 还要处理模块依赖图。这个过程在桌面设备上通常感觉不到,但在低端手机、或者页面刚加载就创建五六个 Worker 时,会实打实地占用 CPU 和内存。
所以 Worker 的正确用法通常是「池 + 复用」,而不是「每个任务 new 一个」。启动成本被摊薄到多次任务上,池里的 Worker 还能保留热身后的 JIT 代码和缓存。
3.2 池开多大:别直接抄 hardwareConcurrency
navigator.hardwareConcurrency 返回的是逻辑处理器数量,但把它直接当成池大小并不合适:
- 浏览器可能对上报值取整或限制,它不等于物理核数;
- 页面自己还要用主线程,留一个核给它;
- 同一台机器上还有其他标签页、其他进程在抢 CPU;
- 任务如果是 I/O 密集(
fetch、解析),线程数可以多于核数;如果是纯 CPU 计算,超配只会增加调度开销。
一个务实的起点是 min(4, max(1, hardwareConcurrency - 1)),然后用真实设备上的任务吞吐量来调。池大小不是越大越好,超出核数的 Worker 只会排队。
3.3 消息队列没有背压
发给同一个 Worker 的消息按顺序处理,但队列本身没有上限。如果主线程以每秒几千条的频率发小消息,Worker 处理不过来,消息会在它的任务队列里堆积,内存持续上涨,而主线程完全感知不到。
两个对策:
- 批量发送:把一小段时间内产生的数据攒成一条消息,把每条消息的固定开销摊薄。
- 自己做信用控制:池里每个 Worker 同时只处理一个任务,主线程的待办队列达到上限时暂停生产,而不是无限
postMessage。
// 批量发送:把同一帧内产生的数据合并let pending = [];let scheduled = false;
function enqueue(sample) { pending.push(sample); if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; const batch = pending; pending = []; worker.postMessage({ type: 'samples', batch }); });}3.4 错误不会自动冒泡
Worker 里未捕获的异常不会出现在主线程的 window.onerror 里,而是触发 Worker 对象上的 error 事件:
worker.onerror = (event) => { console.error('worker 挂了', event.message, event.filename, event.lineno); event.preventDefault(); // 阻止默认的控制台输出,按需使用};event.error 在多数浏览器里能拿到原始 Error 对象。还有一个容易混淆的事件:messageerror,它在反序列化失败时触发,比如对方发来的消息结构损坏;它和业务代码抛错是两回事。
稳健的做法是在 Worker 内部自己包一层 try/catch,把错误结构化成消息发回去,让池统一 reject 对应的 Promise:
self.onmessage = async ({ data }) => { const { id, payload } = data; try { const result = await handle(payload); self.postMessage({ id, result }); } catch (error) { self.postMessage({ id, error: { name: error.name, message: error.message, stack: error.stack }, }); }};3.5 一个可以直接用的 Worker 池
下面这个池做了四件事:固定数量的 Worker、每个 Worker 同时只跑一个任务、用自增 id 匹配请求与响应、出错后把 Worker 放回池里继续用。
export class WorkerPool { #workers = []; #idle = []; #queue = []; #pending = new Map(); #seq = 0; #closed = false;
constructor(url, size = defaultSize(), options = { type: 'module' }) { for (let i = 0; i < size; i += 1) { const worker = new Worker(url, options); worker.onmessage = (event) => this.#onMessage(worker, event); worker.onerror = (event) => this.#onError(worker, event); this.#workers.push(worker); this.#idle.push(worker); } }
run(payload) { if (this.#closed) return Promise.reject(new Error('pool closed')); return new Promise((resolve, reject) => { this.#queue.push({ payload, resolve, reject }); this.#drain(); }); }
#drain() { while (this.#idle.length > 0 && this.#queue.length > 0) { const worker = this.#idle.pop(); const job = this.#queue.shift(); const id = ++this.#seq; this.#pending.set(worker, { id, ...job }); worker.postMessage({ id, payload: job.payload }); } }
#onMessage(worker, { data }) { const job = this.#pending.get(worker); if (!job || data.id !== job.id) return; this.#pending.delete(worker); if (data.error) { const error = new Error(data.error.message); error.name = data.error.name; error.stack = data.error.stack; job.reject(error); } else { job.resolve(data.result); } this.#idle.push(worker); this.#drain(); }
#onError(worker, event) { const job = this.#pending.get(worker); this.#pending.delete(worker); job?.reject(event.error ?? new Error(event.message || 'worker error')); this.#idle.push(worker); this.#drain(); }
terminate() { this.#closed = true; for (const worker of this.#workers) worker.terminate(); this.#workers = []; this.#idle = []; this.#queue = []; this.#pending.clear(); }}
function defaultSize() { const cores = navigator.hardwareConcurrency || 4; return Math.max(1, Math.min(4, cores - 1));}这段代码刻意没有做「任务超时」和「Worker 崩溃后重建」。前者可以用 AbortSignal 加计时器实现;后者要区分「业务抛错」和「线程真的死了」——error 事件里两者都可能出现,简单起见先把 Worker 放回池里,线上再加一个健康检查。
还有一个生命周期细节:Worker 没有「优雅退出」的协议。worker.terminate() 会立刻终止,正在跑的任务不会有机会清理资源。需要清理时,先发一条 { type: 'close' },让 Worker 自己执行 self.close()。
四、第三笔账:共享内存与竞态
4.1 先过一道门槛:跨源隔离
SharedArrayBuffer 默认不可用,页面必须处于**跨源隔离(cross-origin isolated)**状态,浏览器才会暴露这个构造函数。需要两个响应头同时生效:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp生效后 window.crossOriginIsolated === true,可以据此做特性检测:
if (crossOriginIsolated) { // 可以用 SharedArrayBuffer}第二个头会连带影响页面里所有跨源资源:图片、脚本、iframe 都必须提供 CORS 或 Cross-Origin-Resource-Policy,否则会被拦截。这是启用共享内存的真实成本,很多站点在这一步就放弃了。
4.2 共享之后,同步要自己来
共享内存允许多个线程同时读写同一块数据,也意味着数据竞争真实存在。Atomics 提供的是原子操作和等待/唤醒,不是锁,更不是自动同步。
// 主线程const sab = new SharedArrayBuffer(4);const counter = new Int32Array(sab);worker.postMessage({ sab });
// worker 内self.onmessage = ({ data }) => { const counter = new Int32Array(data.sab); Atomics.add(counter, 0, 1); Atomics.notify(counter, 0); // 唤醒等待者};几个硬性限制:
Atomics.wait()不能在主线程调用,会抛TypeError,因为主线程不允许被阻塞;Worker 内可以。- 需要非阻塞等待时用
Atomics.waitAsync(),它返回一个 Promise。 Atomics只保证单个操作的原子性。两个线程各自执行「读—判断—写」三步,仍然会产生竞态,必须用Atomics.compareExchange这类操作自己构造临界区。
共享内存适合的场景很窄:音视频帧、WASM 的线程池、需要持续更新的传感器数据。绝大多数业务计算用不到它,用 Transferable 传递所有权就够了。
4.3 OffscreenCanvas:把渲染也搬走
如果卡顿来自绘制而不是计算,可以把 canvas 的控制权交给 Worker:
const canvas = document.querySelector('canvas');const offscreen = canvas.transferControlToOffscreen();worker.postMessage({ canvas: offscreen }, [offscreen]);// 之后主线程不能再对这个 canvas 调用 getContexttransferControlToOffscreen() 每个 canvas 只能调用一次,调用后主线程失去绘图上下文。Worker 里拿到的是 OffscreenCanvas,可以调用 getContext('2d') 或 getContext('webgl2')。这样动画循环、绘制、甚至部分计算都在 Worker 里完成,主线程只负责布局。
五、工程落地:Vite、Astro 与调试
5.1 让打包器认识 Worker
在 Vite 里,new Worker() 的 URL 必须写成能被静态分析的形式,否则 Worker 文件不会被打包:
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module',});这里有两个细节:
new URL(..., import.meta.url)以模块文件为基准解析路径。直接写new Worker('./worker.js')是相对文档 URL 解析的,路由一变就 404。type: 'module'让 Worker 支持import,但它是 Worker 构造函数的选项,不是打包器选项,两处都要写对。
Vite 还提供 ?worker 后缀的导入方式:
import MyWorker from './worker?worker';const worker = new MyWorker();这种写法更短,但它是 Vite 专有语法,测试环境、其他打包器、服务端执行时都要额外处理。
5.2 Astro 里别在 frontmatter 创建 Worker
在 Astro 组件里,frontmatter 中的代码在构建或服务端执行,那里没有 Worker 全局对象。下面这种写法在 astro dev 里可能不报错,构建时就炸:
---const worker = new Worker(new URL('./worker.ts', import.meta.url)); // 服务端执行---正确做法是把创建逻辑放进客户端脚本,并确保它只在浏览器里运行:
<script> import { WorkerPool } from '../lib/worker-pool';
const pool = new WorkerPool(new URL('../workers/heavy.ts', import.meta.url)); pool.run({ type: 'parse', text: '...' }).then(render);</script>如果 Worker 文件本身依赖浏览器 API,也要避免它被服务端导入。用 new URL(..., import.meta.url) 而不是顶层 import,打包器会把它当成独立入口,不会打进主包。
5.3 调试与观测
- DevTools 线程面板:Chrome DevTools 的 Sources 面板会列出页面用到的 Worker,可以单独打断点、看调用栈。Performance 面板里 Worker 有独立的线程轨道,能看出它是不是真的在并行干活。
- 主线程的 long task:用
PerformanceObserver观察longtask条目(Chromium 系支持),确认卡顿是否真的消失,而不是被挪到了别处。 - 消息往返时间:在发送前
performance.mark(),收到响应后performance.measure(),把「排队时间」和「计算时间」分开看。如果排队占大头,说明池太小或任务粒度太碎。 - Worker 内部也能用
performance.now(),但两边的时钟原点不同,跨线程比较时间戳要先做一次校准。
六、什么时候不该用 Worker
把计算搬出去是有成本的。下面这些情况,留在主线程或者换别的办法更划算:
- 任务只有几毫秒。一次
postMessage的固定开销加上调度延迟,可能比计算本身还长。 - 必须访问 DOM 或
window。Worker 里没有document、localStorage,也没有布局信息。 - 需要同步返回结果。Worker 的通信本质是异步的,任何「先拿到结果再继续」的写法都会把调用链改成 Promise。
- 高频小消息。每秒上千条消息的序列化开销会抵消并行收益,应该先合并数据。
- 每次都要复制大量数据。要么改用 Transferable,要么重新设计数据流;如果两者都做不到,Worker 只是把阻塞换了位置。
- 共享可变状态很多。多线程加锁的复杂度会迅速超过性能收益,单线程反而更可维护。
在决定上 Worker 之前,还有几个更轻的替代手段值得先试:
| 手段 | 解决的问题 | 代价 |
|---|---|---|
| 拆分长任务,主动让出主线程 | 输入延迟 | 需要改算法结构,状态要可中断 |
requestIdleCallback | 低优先级后台任务 | 空闲时机不可控,可能长时间不执行 |
scheduler.postTask() | 按优先级调度任务 | 浏览器支持情况要按目标用户确认 |
| CSS 动画 / Web Animations | 避免 JS 驱动的逐帧动画 | 只适用于声明式能表达的效果 |
| WebAssembly | 计算本身太慢 | 需要额外的编译与数据边界处理 |
requestIdleCallback 和 scheduler.postTask() 都不是所有浏览器都支持,上线前要按目标浏览器查兼容性,并准备降级到 setTimeout 的方案。
七、一份上手检查清单
- 确认卡顿来自主线程的同步计算,而不是布局、字体或网络;
- 任务结果不需要同步返回,且单次执行时间值得跨线程;
- 发送的数据用 Transferable 而不是默认克隆;
- TypedArray 转移的是
view.buffer,转移后不再访问原 buffer; - Worker 用池复用,池大小按真实设备调过,没有直接抄
hardwareConcurrency; - 消息做了批量合并,队列有上限或信用控制;
- Worker 内部的异常被捕获并结构化回传,主线程有
onerror兜底; - 组件卸载或页面切换时调用了
terminate(); - 用
new URL(..., import.meta.url)创建 Worker,路径不依赖文档 URL; - 在 SSR 框架里,Worker 只在客户端脚本中创建;
- 用
SharedArrayBuffer前确认响应头已开启跨源隔离,并处理了跨源资源的 CORS; - 在低端设备和真实网络下测过,而不是只在开发机上测。
Worker 的价值不在于「多线程」这个词,而在于把一段确定会阻塞主线程的工作,挪到一个不会影响输入和渲染的地方。想清楚数据怎么过去、线程怎么复用、状态怎么同步,这三笔账算明白了,再用它。