Web Worker 和主线程怎么通信?
两种通信方式:拷贝和共享
Worker 和主线程之间不共享内存(SharedArrayBuffer 除外),数据必须"过桥"。过桥有两种方式:
结构化克隆(默认):数据完整拷贝一份,双方各持一份,互不影响。类似你复印一份文件给同事。
Transferable 转移:数据所有权直接移交,发送方丧失访问权。类似你把原件直接递给同事,自己手里没了。
javascript// 结构化克隆(默认)—— 数据拷贝 worker.postMessage({ data: largeArray }); // 主线程和 Worker 各有一份,largeArray 仍在 // Transferable 转移 —— 所有权移交 const buffer = new ArrayBuffer(1024 * 1024); // 1MB worker.postMessage({ buffer }, [buffer]); // buffer.byteLength === 0,主线程不能再用了
选哪种?小数据无所谓,大数据(超过 100KB 的 ArrayBuffer、Blob)用 Transferable,否则拷贝开销能吃掉你 Worker 带来的全部性能收益。
结构化克隆支持什么
postMessage 不是 JSON.stringify,它用的是浏览器内置的结构化克隆算法,能处理的东西比 JSON 多:
能传的:对象、数组、字符串、数字、布尔值、Date、RegExp、Blob、File、ArrayBuffer、TypedArray、Map、Set、ImageData、Error
不能传的:函数、DOM 节点、Symbol、有循环引用的对象(部分情况)
一个容易踩的坑:对象的方法和原型链不会被克隆。你传一个 class 实例过去,对面收到的是一个纯数据对象,方法全丢了。如果 Worker 需要调用方法,要么传纯数据重新构造,要么用 RPC 模式。
双向通信的实战写法
简单的 echo 通信谁都会写,但生产环境里你需要的是"请求-响应"模式——主线程发任务,Worker 算完回结果,最好还能 Promise 化。
javascript// 主线程:封装 RPC 风格的 Worker 通信 class WorkerRPC { constructor(url) { this.worker = new Worker(url); this.id = 0; this.pending = new Map(); this.worker.onmessage = (e) => { const { id, result, error } = e.data; const { resolve, reject } = this.pending.get(id); this.pending.delete(id); error ? reject(new Error(error)) : resolve(result); }; } call(method, params) { return new Promise((resolve, reject) => { const id = ++this.id; this.pending.set(id, { resolve, reject }); this.worker.postMessage({ id, method, params }); }); } } // 使用 const rpc = new WorkerRPC('worker.js'); const sorted = await rpc.call('sort', { data: largeArray });
javascript// worker.js:处理 RPC 调用 const handlers = { sort: ({ data }) => data.sort((a, b) => a - b), filter: ({ data, condition }) => data.filter(condition), }; self.onmessage = async (e) => { const { id, method, params } = e.data; try { const result = await handlers[method](params); self.postMessage({ id, result }); } catch (err) { self.postMessage({ id, error: err.message }); } };
这样主线程就可以 await rpc.call('sort', data) 了,比裸写 postMessage + onmessage 干净很多。
SharedArrayBuffer:真正的共享内存
结构化克隆和 Transferable 本质上还是"传数据",有拷贝或转移开销。如果你要的是两个线程同时读写同一块内存,用 SharedArrayBuffer。
javascript// 主线程:创建共享内存 const shared = new SharedArrayBuffer(1024); const view = new Int32Array(shared); worker.postMessage({ shared }); // Worker:直接读写同一块内存 self.onmessage = (e) => { const view = new Int32Array(e.data.shared); // 用 Atomics 做原子操作,避免竞态 Atomics.add(view, 0, 1); Atomics.store(view, 1, 42); };
关键点:共享内存没有自动同步机制,必须用 Atomics API 做原子操作,否则两个线程同时写一个位置,数据就乱了。Atomics 提供了 add、sub、compareExchange、wait/notify 等操作,基本够用。
注意:SharedArrayBuffer 有安全限制,服务端必须返回 Cross-Origin-Opener-Policy: same-origin 和 Cross-Origin-Embedder-Policy: require-corp 两个响应头,否则浏览器直接拒绝。很多开发者在本地调试时发现能用,部署到生产环境就不行,就是这个头没配。
其他通信通道
除了 postMessage,还有几个不太常见但特定场景好用的通信方式:
MessageChannel:创建一对互相连接的端口,可以传给 Worker 作为私有通道。适合多个 Worker 之间直接通信,不经过主线程中转。
javascriptconst channel = new MessageChannel(); worker1.postMessage({ port: channel.port1 }, [channel.port1]); worker2.postMessage({ port: channel.port2 }, [channel.port2]); // 两个 Worker 现在可以直接通信了
BroadcastChannel:同源下所有标签页和 Worker 都能收发的广播通道。适合跨标签页同步状态。
javascriptconst bc = new BroadcastChannel('app-sync'); bc.postMessage({ type: 'data-updated', payload: newData }); bc.onmessage = (e) => { /* 收到其他页面的广播 */ };
通信性能的实际影响
很多人以为 Worker 通信开销可以忽略,实际上结构化克隆的耗时跟数据量正相关。实测数据:
| 数据量 | 结构化克隆耗时 | Transferable 耗时 |
|---|---|---|
| 10KB | ~0.1ms | ~0.05ms |
| 1MB | ~5ms | ~0.1ms |
| 10MB | ~50ms | ~0.2ms |
| 100MB | ~500ms | ~0.5ms |
数据量越大,结构化克隆越慢,Transferable 优势越明显。10MB 以上的数据,不用 Transferable 基本等于白用 Worker——拷贝时间比计算时间还长。
实践建议:如果 Worker 间通信频率高(每秒几十次以上),即使单次数据量小,也要考虑 SharedArrayBuffer + Atomics,省掉反复序列化的开销。
错误处理别忘了
Worker 内部抛出的异常不会冒泡到主线程,必须显式监听:
javascriptworker.onerror = (e) => { console.error('Worker 出错了:', e.message); console.error('文件:', e.filename, '行号:', e.lineno); // 可以选择重新创建 Worker }; // Worker 内部也要处理异常 self.onmessage = (e) => { try { const result = riskyOperation(e.data); self.postMessage({ id: e.data.id, result }); } catch (err) { self.postMessage({ id: e.data.id, error: err.message }); } };
生产环境里 Worker 挂了不重启,等于你的后台任务全停了。建议封装一个自动重启的 Worker 管理器:onerror 触发后 terminate 旧 Worker,new 一个新的,再把未完成的任务重放一遍。