目录
4476 字
22 分钟
Web Worker 实战:把计算搬出主线程,要付的三笔账

一、先确认问题:卡的是计算,还是主线程#

浏览器的主线程同时负责三件事:执行 JavaScript、样式与布局计算、处理用户输入。它们共享同一个事件循环。一段 200 ms 的同步计算跑起来,页面不会「变慢一点」,而是这段时间里点击没有反馈、动画掉帧、输入框里的字符要等计算结束才出现。Core Web Vitals 里的 INP 量的就是这种延迟。

所以「要不要上 Worker」的判断标准不是「计算复不复杂」,而是:这段计算是否阻塞了主线程,并且它的结果不需要同步返回。两个条件缺一不可。一个必须在点击瞬间同步算出布局位置的 3 ms 计算,放进 Worker 反而更慢;一个 800 ms 的报表导出,即使只跑一次,也值得搬走。

Worker 提供了三种形态,先分清用途:

类型创建方式生命周期适合做什么
Dedicated Workernew Worker(url)跟随创建它的页面页面私有的重计算、解析、编解码
Shared Workernew SharedWorker(url)同源多个页面共享多标签页共享连接或状态
Service Workernavigator.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 MB
console.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 处理不过来,消息会在它的任务队列里堆积,内存持续上涨,而主线程完全感知不到。

两个对策:

  1. 批量发送:把一小段时间内产生的数据攒成一条消息,把每条消息的固定开销摊薄。
  2. 自己做信用控制:池里每个 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-origin
Cross-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 调用 getContext

transferControlToOffscreen() 每个 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 的价值不在于「多线程」这个词,而在于把一段确定会阻塞主线程的工作,挪到一个不会影响输入和渲染的地方。想清楚数据怎么过去、线程怎么复用、状态怎么同步,这三笔账算明白了,再用它。

Web Worker 实战:把计算搬出主线程,要付的三笔账
https://www.hehonglei.cn/posts/web-worker-structured-clone-transferable-worker-pool/
作者
Honglei He
发布于
2026-11-03
许可协议
CC BY-NC-SA 4.0