Web Worker 实战:RPC 封装、Transferable 零拷贝与线程池限流
主线程为什么会被拖垮
浏览器里 JS 执行、样式计算、布局、绘制和输入响应基本都在主线程排队。60fps 下一帧只有约 16.7ms 预算,一段 200ms 的 CSV 解析、图片像素处理或大数组排序,就足以让点击、滚动、动画全部卡顿,用户直接感知到「页面假死」。常见的 setTimeout 分片只是把长任务切成几段,仍然占用主线程,治标不治本。
Web Worker 提供了一条真正的并行线程:它有独立的事件循环和全局作用域,适合放纯计算任务。代价是不能碰 DOM、window、document,与主线程只能靠 postMessage 通信。本文从最小可用写法出发,一路封装到 RPC、零拷贝传输与线程池,最后把工程里最容易踩的坑集中列一下。
一、最小可用写法
主线程 new Worker(url) 起一个线程,双方各自监听 onmessage。Worker 里的全局对象是 self,不是 window。
// main.js —— 主线程
const worker = new Worker('./compute.worker.js', { type: 'module' });
worker.onmessage = (e) => {
console.log('结果', e.data);
};
worker.postMessage({ type: 'SUM', nums: [1, 2, 3, 4, 5] });
// compute.worker.js —— Worker 线程(没有 window / document)
self.onmessage = (e) => {
const { type, nums } = e.data;
if (type === 'SUM') {
const sum = nums.reduce((a, b) => a + b, 0);
self.postMessage(sum);
}
};
三个前提要记牢:Worker 脚本受同源策略约束,跨域要服务端配合或走 Blob;type: 'module' 才能在 Worker 里用 import,但老浏览器支持不一;传递的数据走结构化克隆,函数、DOM 节点、class 实例的方法都传不过去。
二、把消息封装成 RPC
直接在业务代码里写 onmessage 分支,很快就会变成一堆全局变量和回调。更实用的模型是「一次调用 = 一个 Promise」:给每个请求分配自增 id,用 Map 存下 resolve/reject,收到回包按 id 取出来结算。
// 主线程:把「发消息 + 等回包」封装成一次 Promise 调用
function createWorkerRPC(url) {
const worker = new Worker(url, { type: 'module' });
const pending = new Map();
let seq = 0;
worker.onmessage = (e) => {
const { id, ok, result, error } = e.data;
const task = pending.get(id);
if (!task) return; // 迟到的回包,忽略
pending.delete(id);
clearTimeout(task.timer);
ok ? task.resolve(result) : task.reject(new Error(error));
};
function call(type, payload, timeout = 10000) {
const id = ++seq;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('Worker 调用超时: ' + type));
}, timeout);
pending.set(id, { resolve, reject, timer });
worker.postMessage({ id, type, payload });
});
}
return { call, terminate: () => worker.terminate() };
}
const rpc = createWorkerRPC('./compute.worker.js');
const sum = await rpc.call('SUM', { nums: [1, 2, 3] });
// compute.worker.js —— 与主线程约定统一的 { id, ok, result, error } 信封
self.onmessage = async (e) => {
const { id, type, payload } = e.data;
try {
let result;
switch (type) {
case 'SUM':
result = payload.nums.reduce((a, b) => a + b, 0);
break;
case 'PARSE_CSV':
result = parseCSV(payload.text);
break;
default:
throw new Error('未知任务类型: ' + type);
}
self.postMessage({ id, ok: true, result });
} catch (err) {
// Error 对象不能被结构化克隆完整传递,手动转成字符串
self.postMessage({ id, ok: false, error: err.message });
}
};
这个封装顺手解决了三个问题:并发请求不会串包(靠 id 对应)、调用方能 await(不用在业务里注册回调)、异常会 reject(Worker 捕获后以 ok:false 回传)。注意 Error 对象不能被结构化克隆完整传递,要手动转成 message 字符串;回包迟到时要检查 pending 里还有没有这个 id,否则超时后重复结算。
三、超时、取消与错误边界
Worker 一旦进入死循环或抛了未捕获异常,主线程会永远等下去,所以每个调用都必须有超时。上面 call 里的 timer 就是兜底:超时后从 pending 删除并 reject,但此时 Worker 线程还在跑,必要时要 terminate() 重建。另外建议在主线程监听 worker.onerror 与 onmessageerror,前者捕获 Worker 内未处理的异常,后者在「数据无法被结构化克隆」时触发——后者非常隐蔽,比如不小心把函数塞进了 payload,消息会静默发不出去。
四、Transferable:避免大内存复制
默认情况下 postMessage 会克隆整份数据,一个 64MB 的 ArrayBuffer 传进 Worker,主线程和 Worker 就各占 64MB。对图像、音频、大表格这类场景,应该用 Transferable 直接移交所有权,实现零拷贝。
// 默认 postMessage 会结构化克隆,大数组要复制一份,主线程和 Worker 各占一份内存
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
const view = new Uint8Array(buffer);
// 移交(transfer)所有权:零拷贝,但主线程这边的 buffer 立即失效
worker.postMessage({ type: 'HASH', buffer }, [buffer]);
console.log(view.byteLength); // 0 —— 已被 neutered,别再读
// Worker 处理完再转移回来,形成「乒乓」复用同一块内存
self.postMessage({ type: 'HASH_DONE', buffer }, [buffer]);
postMessage 的第二个参数是转移列表,只能是 ArrayBuffer、MessagePort、ImageBitmap 等可转移对象。转移后原线程的那块内存立即失效(byteLength 变 0),再读写会报错,这是零拷贝的代价。工程上常用「乒乓」模式:Worker 算完再把同一块 buffer 转移回来,全程不复制。普通对象、字符串无法转移,仍走克隆。
五、线程池与并发控制
一个任务起一个 Worker 是最常见的误区:Worker 本身有启动开销和内存占用,起几十个反而更慢,而且 navigator.hardwareConcurrency 通常只有 4~16。正确做法是固定大小的线程池 + 任务队列,让并发数贴近 CPU 核数。
function createPool(url, size = navigator.hardwareConcurrency || 4) {
const workers = Array.from({ length: size },
() => createWorkerRPC(url));
const queue = [];
let cursor = 0;
function run(type, payload) {
return new Promise((resolve, reject) => {
queue.push({ type, payload, resolve, reject });
pump();
});
}
function pump() {
// 简单轮询分发;也可改成「谁空闲谁领任务」的 ack 机制
while (queue.length) {
const idle = workers.find((w, i) => !w.busy);
if (!idle) break;
const task = queue.shift();
idle.busy = true;
idle.call(task.type, task.payload)
.then(task.resolve, task.reject)
.finally(() => { idle.busy = false; pump(); });
}
}
return {
run,
terminate: () => workers.forEach(w => w.terminate())
};
}
const pool = createPool('./compute.worker.js', 4);
const results = await Promise.all(
chunks.map(c => pool.run('PARSE_CSV', { text: c }))
);
示例用 busy 标记做简单分发,实际项目可以改成 Worker 主动回报空闲再领任务,负载更均匀。还要留意:CPU 密集型任务按核数限流,但 IO 密集型可以适当放大;同一个大 ArrayBuffer 不能同时派给两个 Worker,转移后要等它回来才能复用;页面隐藏或组件卸载时要 terminate() 整池,否则后台线程会一直吃 CPU。
六、内联 Worker、模块 Worker 与生命周期
不想为一个几十行的小函数单独建文件,可以用 Blob + URL.createObjectURL 内联;用完后要 revokeObjectURL 释放。需要复用工具库时用 { type: 'module' } 加载模块 Worker。
// 不想多建一个文件:用 Blob 内联 Worker
function inlineWorker(fn) {
const src = '(' + fn.toString() + ')()';
const blob = new Blob([src], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
// 用完后记得释放 objectURL,否则一直占着内存
worker.addEventListener('message', () => {
// ...
});
return { worker, revoke: () => URL.revokeObjectURL(url) };
}
inlineWorker(() => {
self.onmessage = (e) => self.postMessage(e.data * 2);
});
// 用不上时主动 terminate,Worker 里的定时器和循环才会停
// worker.terminate();
其余常见坑集中列一下:
- Worker 里没有 DOM:不能
document.querySelector,也拿不到localStorage(indexedDB、fetch、WebSocket可用); - 别传不可克隆的值:函数、Symbol、DOM 节点、含方法的类实例都会失败,且错误发生在
onmessageerror,很容易漏掉监听; - 转移后原 buffer 失效,读写前要确认所有权,别在同一线程里继续用;
- 线程数不是越多越好,按
hardwareConcurrency限流,避免起一堆 Worker 互相抢 CPU; - 必须有超时和 terminate,否则死循环会永久挂起 Promise 且后台持续占 CPU;
- 调试方式不同:Worker 的 console 在浏览器开发者工具的独立线程上下文里,断点也要切过去;
- 构建工具要单独配置:
new Worker(new URL('./x.worker.js', import.meta.url))是 Vite/webpack 5 推荐的写法,直接写字符串路径容易被打包漏掉; - 小任务别用 Worker:消息往返本身有序列化与调度开销,几毫秒的计算搬过去反而更慢。
小结
Web Worker 的价值不是「能开线程」,而是给主线程减负:把纯计算搬走,让 16.7ms 的帧预算留给渲染和交互。落地时真正花功夫的是通信层——用自增 id 把消息封装成 RPC 让调用方可 await,给每个调用加超时与错误回传,大内存用 Transferable 零拷贝,再用线程池把并发压到核数附近。边界同样重要:不能碰 DOM、值必须可克隆、Worker 用完要 terminate。把这套封装沉淀成几十行工具函数,之后处理大文件解析、图像运算、批量排序就能直接复用,不必每次重搭。
本文为原创实践记录,示例代码均可直接运行,欢迎交流更稳妥的多线程任务调度与内存复用方案。
- 点赞
- 收藏
- 关注作者
评论(0)