前端上传大文件,Worker 常驻 + postMessage 传数据,听起来是标准答案,可你真跑起来会发现,postMessage 默认那套“结构化克隆算法”会把你的大 Buffer 完整复制一份,数据越大越亏;换上 Transferable 做“零拷贝”移交,性能上去了,但发送方手里的 ArrayBuffer 直接变废铁,后面一用就报 detached。这篇不绕弯子,把 Worker 常驻、postMessage 的结构化克隆算法、Transferable 的真实代价讲透,顺便给出一套大文件分片上传的实战方案。适合正在用 Worker 做文件处理、图像处理、音视频工具的前端,也适合想搞清楚“零拷贝到底省在哪、亏在哪”的进阶读者。
先交代一下背景:这几年我做了不少和音视频、文件上传、图像编辑打交道的页面,从“new Worker 一把梭”到“常驻 Worker 消息协议化”,再到“Transferable 无脑用”之后被 detached 坑了两次,才真正把 postMessage 这条链路摸明白。文章里的代码都是我在实际项目里跑过的,你可以直接抄,也可以按第六节的取舍思路改成适合自己的方案。
1. Worker 常驻是前提:消息通道决定了性能上限
1.1 临时 Worker 的启动成本被严重低估
早几年的文章都在教“把耗时任务丢给 Worker”,但很少有人提醒:Worker 本身不是免费的。new Worker 一次,浏览器要重新解析脚本、创建独立线程或独立进程、初始化事件循环和内部上下文,在低端移动设备上这一套下来可能要几百毫秒,而且脚本加载过程还会阻塞创建流程。如果你在上传一个 1GB 文件时,每处理一个分片都 new Worker 再 terminate,那光反复销毁重建就会吃掉整个上传耗时的很大一部分。
真正让 Worker 成本失控的还有上下文里的资源积累。Worker 内如果有 WebSocket 连接、缓存的计算结果、甚至只是被结构化克隆进来的大对象,terminate 的时候全部被强制回收,下一次任务又得从头建立。所以我的第一个原则很简单:凡是页面生命周期内可能被复用两次以上的后台任务,一律做成常驻 Worker。
常驻 Worker 还会让 postMessage 的优化更值得投入。因为启动成本被摊薄了,消息通道就成了吞吐瓶颈。这个时候你才会真正去研究“每次消息传递到底做了什么”,才会触到结构化克隆算法和 Transferable。反过来说,如果你还在用临时 Worker,优化的优先项应该是先改成常驻,而不是急着研究零拷贝。
1.2 常驻 Worker 的消息协议设计
常驻之后第一个问题是“多请求多响应怎么对应”。Worker 只有一个 onmessage 入口,主线程可能同时发“计算哈希”“读取分片”“上报进度”三类消息,而 Worker 返回的也是一堆消息,不做协议的话根本分不清哪条对应哪个请求。
我用的是一套非常简单的 requestId + Map 方案,核心代码不到 30 行:
// rpc.js —— 主线程侧 let msgId = 0; const pending = new Map(); function callWorker(worker, type, payload, transfer) { return new Promise((resolve, reject) => { const id = ++msgId; pending.set(id, { resolve, reject }); try { worker.postMessage({ id, type, payload }, transfer || []); } catch (err) { pending.delete(id); reject(err); } }); } function bindWorker(worker) { worker.onmessage = (e) => { const { id, ok, data, error } = e.data || {}; if (!id || !pending.has(id)) return; const p = pending.get(id); pending.delete(id); ok ? p.resolve(data) : p.reject(new Error(error)); }; }// worker 侧统一入口 self.onmessage = async (e) => { const { id, type, payload } = e.data || {}; try { const data = await handleMessage(type, payload); self.postMessage({ id, ok: true, data }); } catch (err) { self.postMessage({ id, ok: false, error: err && err.message }); } };这套协议有几个好处:主线程可以用 Promise 的方式等结果,不用到处写回调;Worker 内部可以按 type 分发到不同处理函数;transfer 列表作为 callWorker 的第四个参数传进去,后续做零拷贝移交不需要改协议。消息里的信封 { id, type, payload } 都是小对象,走普通结构化克隆完全没问题,真正的大数据只在 payload 里出现,需要优化时只优化 payload 即可。
协议设计本身不复杂,但它让后面的 Transferable 用得很有底气:你不用担心“转移一个 ArrayBuffer 过去之后,返回的进度消息里 id 对不上”这种问题,因为信封和 payload 生命周期彻底分开了。我在实际项目里还加过 5 秒超时定时器,pending 里过期就 reject,避免异常情况下 Promise 永远悬挂。另外要注意,postMessage 在同一个端口对之间是严格 FIFO 的,所以只要把消息编号对齐,乱序问题基本不用操心。
1.3 常驻之后要管的生命周期
常驻不是“永远不关”。页面隐藏、组件销毁、用户取消上传,这些时机都应该主动释放 Worker。我的做法是在页面 beforeunload 和组件卸载时调用 worker.terminate(),同时把 Worker 内部的定时器、fetch 请求都挂到可取消的控制器上。Worker 内部如果持有大量 File 引用或 ArrayBuffer,terminate 之后浏览器会立刻回收,比干等 GC 快得多。
这里还想提醒一个反直觉的点:Worker 常驻不代表 Worker 内部的全局状态可以随便写。因为是复用的,上一次任务的残留变量可能会污染下一次任务。我习惯在每个任务开始时重置 Worker 内部状态,或者干脆把任务相关的所有状态都收进 init 消息里。否则你排查半天数据不对,最后发现是上次任务留下的大对象还在 Worker 全局作用域里引用着。
2. postMessage 的默认路径:结构化克隆算法到底复制了什么
2.1 支持类型与“看起来像引用”的陷阱
postMessage 的默认行为不是“传引用”,而是把要发送的对象完整序列化一份,到接收线程再反序列化回来。这个过程用的是 HTML 规范里定义的结构化克隆算法(Structured Clone Algorithm)。它比 JSON.stringify 能力大得多,Date、RegExp、Map、Set、ArrayBuffer、TypedArray、DataView、Blob、File、ImageData 都能克隆,甚至 Error 及其子类都能保留类型,对象循环引用也能正确还原。
但它有明确的禁区:函数不能克隆、DOM 节点不能克隆、Symbol 不能克隆,class 实例克隆过去会丢掉原型链上的方法,变成只有自有属性的普通对象。下面是我平时快速判断的心智模型:
| 类型 | 是否支持结构化克隆 | 说明 |
|---|---|---|
| 原始类型、普通对象、数组 | 支持 | 任意嵌套与循环引用均可 |
| Date、RegExp、Map、Set | 支持 | 保留内部结构 |
| ArrayBuffer、TypedArray、DataView | 支持 | 深拷贝字节内容 |
| Blob、File、FileList | 支持 | 底层数据共享,不是深拷贝 |
| ImageData、ImageBitmap | 支持 | 像素数据会被处理 |
| Error 及子类 | 支持 | 保留 name、message、stack |
| function、Symbol、DOM 节点 | 不支持 | 直接抛 DataCloneError |
| class 实例 | 弱支持 | 属性保留,方法丢失 |
这里藏着一个非常经典的坑:很多人以为“我把 File 对象 postMessage 给 Worker,文件数据也被复制了一份”。实际上结构化克隆算法处理 Blob/File 时,克隆的是指向底层数据的“引用描述”,底层文件数据并不会被复制。因为 Blob 的设计本身不允许任意修改底层字节,浏览器可以安全地共享同一份底层数据。所以直接把 File 对象丢给 Worker 是很便宜的操作,这也是后面实战里路线 A 能成立的理论基础。
但 ArrayBuffer 就完全不同了。结构化克隆会把整个字节缓冲区深拷贝一份,发送端和接收端各持有一份完全独立的内存。这意味着同样的数据在内存里出现两次,拷贝过程本身还要吃掉带宽和 CPU 时间。所以真正昂贵的是 ArrayBuffer 以及基于它的 TypedArray、DataView 这一类二进制数据。
2.2 复制大数据的真实开销
拷贝开销有多大?我自己的笔记本实测(Chrome 最新版,8 代 i5,双通道内存):把一个约 400MB 的 ArrayBuffer postMessage 给同页面的 Worker,默认结构化克隆大约耗时 1.2 到 1.8 秒,期间因为主线程要参与拷贝和内存整理,帧率明显往下掉。数据越大,耗时线性增长,而且 GC 还要频繁处理克隆出来的垃圾副本。
你可以这么估算:假设内存拷贝能达到 2GB/s 到 4GB/s,一个 200MB 的 ArrayBuffer 光数据复制就需要 50ms 以上,再加上克隆过程中的对象包装和 GC 压力,实际感知会更差。更关键的是这个过程在主线程上做,用户会直观地感到“页面卡了一下”。
所以我的第二个原则是:默认的 postMessage 只用来传控制信息和小数据(KB 级别),凡是 MB 级以上的二进制数据,必须认真评估走不走 Transferable。这也是“零拷贝”这个词在前端语境里开始流行的原因——但 Transferable 并不是免费的午餐,真实代价我放到下一节讲。
2.3 什么时候可以放心用默认克隆
默认克隆也没有那么可怕。小对象传起来几乎无感,好消息是现在主流浏览器对常见对象都有优化,几千字节的 JSON 结构在 1ms 内就能完成序列化。另外,如果数据量中等(几百 KB)但结构很简单,克隆开销约等于一次浅拷贝加一次分配,大部分场景下可以接受。真正要避免的是“大二进制 + 高频消息”叠加的情况,比如每帧把 Canvas 像素数据经 postMessage 传过去做滤镜处理,这种场景你就算用 Transferable,也得想清楚谁持有谁释放。
3. Transferable 的“零拷贝”真相:哪些省了,哪些其实更贵
3.1 Transferable 支持的对象与基本写法
Transferable 的意思是“转移”,不是“复制”。调用 postMessage 时传入第二个参数——转移列表,把某个对象的底层所有权从当前线程移交到接收线程。发送方在移交后立刻失去访问权限,ArrayBuffer 会被置为 detached 状态,byteLength 变为 0,任何读写操作直接抛 TypeError。
目前常见的可转移对象包括:ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas、WebAssembly.Module、WebAssembly.Memory,以及部分环境下的 ReadableStream、WritableStream、TransformStream。最常见的写法:
// 主线程 const buffer = new ArrayBuffer(16 * 1024 * 1024); worker.postMessage({ type: 'chunk', buffer }, [buffer]); // 转移完成后,buffer.byteLength === 0,不能再使用// worker 侧 self.onmessage = (e) => { const { buffer } = e.data; console.log(buffer.byteLength); // 16777216,拿到的是原始数据 };对二进制数据来说,默认克隆必须复制字节,而转移则是把字节的所有权移动过去,数据指针从一个线程交到另一个线程手里。这就是前端“零拷贝”的来源:真正需要搬移的字节数几乎为零,省掉了 O(n) 的深拷贝。
3.2 Transferable 的真实代价清单
但请把“零拷贝”理解成“省掉了数据复制这一项”,而不是“完全免费”。我踩过坑之后总结出下面五条真实代价:
第一,发送方失去所有权。这是最容易被忽略的。你把 buffer 转移出去后,如果主线程后续还要用这段数据做展示、做二次计算,那就得重新读取或重新分配,这个成本往往比一次深拷贝还高。所以 Transferable 只适合数据一次性单向流动的场景。
第二,信封仍然走结构化克隆。postMessage 的第一个参数整体还是会被结构化序列化,只是序列化到大 Buffer 时走的不是“复制字节”而是“转移引用”的快路径。消息头、业务字段、对象层级这些元信息照样有序列化开销,虽然通常很小,但当消息非常频繁时也会积累成瓶颈。
第三,跨进程时存在底层映射成本。浏览器经常让 Worker 跑在独立进程里,ArrayBuffer 的底层内存必须从主进程映射或传递到 Worker 进程。这是操作系统层面的内存映射、句柄传递,不是把数据复制一遍,但也不是绝对零成本。我实测转移 100MB 数据耗时在个位数毫秒到几十毫秒之间,和当时的 GC、内存压力、平台实现都有关系。
第四,接收方拿到的是“新内存”,不能原地复用发送方的分配。有人以为转移之后,发送方那块内存被释放了。实际上对接收方来说,这只是它掌控的一块内存,如果它处理完想继续用同一块内存,再传回给发送方,就是一次往返两次转移,来回交接的开销可能超过一次浅拷贝。所以“为了省拷贝而反复转移”往往是负优化。
第五,小数据转移反而更贵。Buffer 只有几个字节时,结构化克隆几乎瞬间完成,而转移要处理所有权交接、控制协议、潜在的底层映射,整体开销反而更大。我通常以 1MB 作为经验阈值:单个二进制块低于 1MB 时直接走默认克隆,超过 1MB 且一次性使用,才走 Transferable。
为了更直观,我把两者的适用场景做了个对照:
| 维度 | 默认结构化克隆 | Transferable 转移 |
|---|---|---|
| 数据复制 | 全量深拷贝 | 字节不复制 |
| 发送方访问 | 仍可访问原对象 | 立即失效(detached) |
| 适合数据量 | 小对象、控制消息 | MB 级以上二进制块 |
| 适合方向 | 双向、复用 | 单向、一次性 |
| 元信息开销 | 有 | 有(信封仍克隆) |
| 常见报错 | DataCloneError | detached ArrayBuffer |
3.3 真正双向零拷贝是 SharedArrayBuffer
如果你需要主线程和 Worker 之间反复读写同一块数据,零拷贝的选项其实还有 SharedArrayBuffer。它表示一块多个线程共享的二进制内存,postMessage 传过去的是共享引用,不是所有权交接,两边都能随时读写,不需要每次通信都搬数据。
但 SharedArrayBuffer 不是免费的:它要求页面处于跨源隔离状态,需要服务端配合返回 COOP/COEP 响应头,否则浏览器直接拒绝创建;共享内存必然带来并发访问的竞争问题,读写必须配合 Atomics 原子操作或自己实现锁,否则就是数据竞态;一旦某个线程访问越界或状态错乱,排查难度也比普通 ArrayBuffer 高一个量级。我的建议是:除非你在做需要高频共享状态的重度计算(游戏引擎、音视频渲染管道这类),否则优先考虑 Transferable。Transferable 虽然“半零拷贝”,但语义简单、没有竞态,出错概率低得多。
顺便说一句,用过 Netty、Kafka、ZeroMQ 的同学对“零拷贝”应该不陌生,那里的零拷贝主要指 sendfile、页缓存、免去用户态和内核态之间的多次复制。前端的 Transferable 在理念上类似,但层级完全不同——它解决的是线程之间 JS 对象所有权的移动问题,不是磁盘到网卡的传输路径。别把两套语境混着讲,否则会得出“Transferable 应该让上传速度翻倍”这种错误预期。实际上传速度仍然受带宽和服务器限制,Transferable 解决的只是主线程卡顿和内存翻倍的问题。
4. 实战:Worker 常驻 + 分片 + Transferable 处理大文件上传
4.1 场景拆解与两条技术路线
大文件上传的前端需求一般有三个:分片、秒传校验(哈希)、断点续传。分片避免一次性把大文件塞进 FormData 导致浏览器内存爆炸;秒传校验需要在上传前算出文件整体或分片哈希;断点续传需要记录已上传分片并跳过。这三个任务都适合放 Worker,因为计算哈希、读大文件都是 CPU 和内存密集型操作,放主线程会直接卡 UI。
但“数据在哪里”决定了要不要用 Transferable。这里其实是两条路线:
路线 A:直接把 File 对象 postMessage 给 Worker。因为 Blob/File 的底层数据不会被结构化克隆复制,这条消息很便宜。Worker 拿到 File 后,自己在后台用 file.slice() + arrayBuffer() 读取分片数据,在 Worker 内部完成哈希、上传,最后只把进度、结果这些小对象传回主线程。全程不需要跨线程搬大二进制,Transferable 没有用武之地,但这是大多数场景下最干净的方案。
路线 B:二进制数据已经存在于主线程内存中,比如刚从 fetch().arrayBuffer() 拿到、从 Canvas 提取的像素数据、从 WebSocket 收到的消息,或者本来就是业务方在主线程构造的分片 Buffer。这时候要把数据交给 Worker 处理,就必须决定复制还是转移。一次性使用且数据量超过 1MB 的,直接转移。
我见过不少方案把这两条路线搞混:明明第一个参数传 File 就能解决,却偏要在主线程先 file.arrayBuffer() 读出几百 MB,再像传普通 Buffer 一样 postMessage 给 Worker,结果白白承受了一次深拷贝。下面的实战会同时给出两种写法的正确姿势。
4.2 路线 A 的完整实现:File 直接进 Worker
主线程只负责选择文件、创建常驻 Worker、渲染进度:
// main.js const worker = new Worker('./upload-worker.js'); const CHUNK_SIZE = 4 * 1024 * 1024; // 4MB 一个分片 let uploadTaskId = 0; async function startUpload(file) { const taskId = ++uploadTaskId; const total = Math.ceil(file.size / CHUNK_SIZE); // File 对象直接 postMessage,底层文件数据不会复制 worker.postMessage({ type: 'init', taskId, file, fileName: file.name, fileSize: file.size, chunkSize: CHUNK_SIZE, }); return taskId; } worker.onmessage = (e) => { const msg = e.data || {}; switch (msg.type) { case 'progress': renderProgress(msg.taskId, msg.done, msg.total); break; case 'done': renderDone(msg.taskId, msg.uploadId); break; case 'error': renderError(msg.taskId, msg.message); break; } };Worker 侧自己读分片、算哈希、上传:
// upload-worker.js let file = null; let chunkSize = 0; async function sha256(buffer) { // 注意:crypto.subtle 需要安全上下文(https 或 localhost) const digest = await crypto.subtle.digest('SHA-256', buffer); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } async function uploadChunk(buffer, index, total, meta) { const fd = new FormData(); fd.append('file', new File([buffer], `${meta.fileName}.part${index}`, { type: 'application/octet-stream' })); fd.append('index', index); fd.append('total', total); fd.append('taskId', meta.taskId); const resp = await fetch('/api/upload-chunk', { method: 'POST', body: fd }); if (!resp.ok) throw new Error(`chunk ${index} upload failed: ${resp.status}`); return resp.json(); } async function processFile(meta) { let done = 0; const total = Math.ceil(meta.fileSize / chunkSize); for (let index = 0; index < total; index++) { const start = index * chunkSize; const end = Math.min(start + chunkSize, meta.fileSize); // 在 Worker 内部读取分片数据,不影响主线程 const buffer = await file.slice(start, end).arrayBuffer(); const hash = await sha256(buffer); const result = await uploadChunk(buffer, index, total, meta); done++; self.postMessage({ type: 'progress', taskId: meta.taskId, done, total, index, hash, uploadId: result.uploadId, }); } self.postMessage({ type: 'done', taskId: meta.taskId }); } self.onmessage = (e) => { const msg = e.data || {}; if (msg.type === 'init') { file = msg.file; chunkSize = msg.chunkSize; processFile(msg).catch((err) => { self.postMessage({ type: 'error', taskId: msg.taskId, message: err.message }); }); } };这条路线里,主线程从头到尾没有碰过任何一个大 Buffer,所有大块内存都留在 Worker 内部。如果你做的是纯上传工具,这基本就是最优解。进度回调里的 done、total 都是小数字,走默认结构化克隆毫无压力。
但注意,Worker 内部这个 for 循环是串行的,一个分片走完哈希加网络上传,才读下一个分片。这么做代码简单,但网络等待时间被串联了,上传带宽利用率不高。要提速的话,可以在 Worker 内部做一个简单的并发窗口,比如同时处理 3 个分片,每个分片用独立 index,完成后从队列里补下一个。这时候要小心的就是内存:并发窗口乘以分片大小不能超过可接受的上限,3 个 4MB 分片同时驻留约 12MB,很安全。
4.3 路线 B 的完整实现:主线程已有 Buffer 时的移动方式
如果数据真的在主线程手里,比如另一个库返回了 ArrayBuffer,或者用户拖拽文件后你解析出了 Buffer,那就不该克隆。这时用 Transferable 把 Buffer 所有权移交到 Worker:
// main.js —— 主线程持有 buffer,需要 Worker 做哈希或压缩 const worker = new Worker('./process-worker.js'); async function sendBufferToWorker(buffer, index) { // 第二个参数传转移列表,buffer 所有权交给 Worker worker.postMessage({ type: 'process', index, buffer }, [buffer]); // 此后主线程绝不能再碰 buffer } // 实例:读取分片后立刻转移 async function readAndSend(file, index) { const start = index * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const buffer = await file.slice(start, end).arrayBuffer(); sendBufferToWorker(buffer, index); // 这里 buffer 已经 detach,不能再用于任何计算 }// process-worker.js self.onmessage = async (e) => { const { index, buffer } = e.data; const hash = await sha256(buffer); const compressed = await compress(buffer); // 假设有压缩函数 // 处理结果是小对象,走默认克隆返回 self.postMessage({ type: 'done', index, hash, compressedSize: compressed.byteLength }); };这里有个细节值得强调:主线程调用 file.slice().arrayBuffer() 时,本来就会产生一个新的 ArrayBuffer,这个 Buffer 在主线程手里没有任何复用价值,唯一的用途就是送给 Worker,所以 Transferable 是零成本收益——分配完直接转移,连复制都不用。很多人担心的 detached 问题在这里根本不构成障碍,因为你本来就不想再碰它。
反过来,如果主线程的 Buffer 还要拿来画图、传给另一个库、参与后续计算,那转移就是灾难。这种情况下有两个选择:要么先克隆一份给 Worker,自己保留原件;要么直接改造流程,让 Worker 成为数据的唯一处理者。我一般优先选后者,因为“复制 + 转移”看起来解决了问题,实际花的是两份内存两次拷贝,性能反而最差。
4.4 分片大小、并发窗口与流控
最后讲讲参数怎么定。分片大小我常用 4MB 或 8MB,理由有三个:这个量级内存占用可控;单分片哈希和上传时间在几百毫秒以内,方便做进度展示;很多服务端和网关对请求体大小有限制,4MB 是相对稳妥的默认值。如果上传的是好几个 GB 的超大文件,分片可以提到 16MB,但并发窗口要降到 2,避免内存峰值过高。
并发窗口和分片大小的乘积决定了驻留内存的高水位。3 个并发、每个 8MB,就是 24MB,这在桌面浏览器里没问题,低端手机上就要小心。我给过一个简化公式:内存峰值约等于(并发窗口 + 1)乘以分片大小,因为总有一个分片正在读取或正在序列化。
流控还有一个容易被忽略的点:不管用不用 Transferable,消息队列都不是无限大的。主线程如果一口气把几百个分片 Buffer 全部 postMessage 出去,Worker 的消息队列会瞬间堆满,内存飙高,极端情况下浏览器会直接终止 Worker。所以正确做法是事件驱动式地控制投递速率:只有收到 Worker 的空闲通知或进度回调,才投递下一个分片。最粗暴有效的实现是维护一个 in-flight 计数,主线程最多保持 N 个未完成分片,完成一个补一个:
let inFlight = 0; let nextIndex = 0; const MAX_IN_FLIGHT = 3; function pump(total) { while (inFlight < MAX_IN_FLIGHT && nextIndex < total) { inFlight++; const index = nextIndex++; readAndSend(file, index); } } worker.onmessage = (e) => { const msg = e.data; if (msg.type === 'chunk-done') { inFlight--; pump(msg.total); updateProgress(msg.index); } };这套流控代码看起来简单,但我在多个项目里都靠它避免了内存峰值爆炸。特别是配合 Transferable 时,如果不做流控,每个分片都是马上分配、马上转移,表面上主线程没压力,Worker 侧却可能积压几十个待处理 Buffer,GC 和内存占用一起失控。
5. 常见问题与排查技巧实录
5.1 postMessage 报错速查表
做 Worker 通信这么久,我收集到的报错基本可以分成下面几类:
| 报错信息 | 真实原因 | 解决方案 |
|---|---|---|
| DataCloneError: xxx could not be cloned | 消息里带了函数、DOM 节点、Symbol 或不可克隆对象 | 检查 payload,把不可克隆数据拆开或转成可克隆结构 |
| Cannot perform operation on a detached ArrayBuffer | 对已转移的 ArrayBuffer 或其视图进行读写 | 发送后立即放弃原引用;如仍需使用,先克隆再转移 |
| Failed to execute 'postMessage' on 'DOMWindow': target origin ... does not match ... | 错用了 window.postMessage,把 message 和 targetOrigin 参数格式搞混 | Worker 场景用 worker.postMessage(message, transfer),别套 window.postMessage(message, targetOrigin) 的签名 |
| The MessagePort is already detached | 转移了 MessagePort 后又继续向它 postMessage | 转移即移交所有权,原端口应立即弃用 |
| InvalidStateError on service worker registration | 这是 Service Worker 注册失败,非普通 Worker 问题 | 检查是否非安全上下文、是否重复注册、开发工具 webview 是否支持 |
最后一条要特别说明:Service Worker 和本文说的 Web Worker(DedicatedWorker)完全是两回事。Service Worker 是用来做离线缓存、推送、后台同步的代理,有自己的生命周期和注册机制,报 InvalidStateError 时通常是因为页面不在安全上下文(需要 HTTPS 或 localhost)、注册脚本路径错误、或者在某些嵌入 webview 环境里不被支持。别看到 “worker” 就往 postMessage 上排查,先分清是哪种 Worker。
5.2 性能对比的实测方法
当你拿不准“这个 Buffer 该克隆还是该转移”的时候,不要拍脑袋,直接在代码里埋点测量。主线程发送前记录 performance.now(),Worker 收到消息时也记录一个时间戳,两者差值近似为“消息投递耗时”。多做几轮取中位数,比任何理论分析都靠谱。
我一般这样测:
// 主线程 const t0 = performance.now(); worker.postMessage({ type: 'bench', sentAt: t0, buffer }, [buffer]); // 或去掉第二参数走克隆// worker 侧 self.onmessage = (e) => { if (e.data.type === 'bench') { const delay = performance.now() - e.data.sentAt; self.postMessage({ type: 'bench-ack', delay, size: e.data.buffer.byteLength }); } };对比结果时要注意两点:一是多测几轮,避免 GC 抖动;二是同时看主线程的帧率表现,结构化克隆在主线程上的卡顿往往比耗时数字更致命。我还习惯在 DevTools 的 Performance 面板里录一段,观察主线程长任务和内存曲线。如果发现明明用了 Transferable,内存还在涨,基本就是 Worker 侧积压太多消息没处理,去查流控,别赖 Transferable。
5.3 我踩过的一些坑
这里想多说几个常规文档里不会写的细节。
第一个坑是“视图没 detach,但数据已经没了”。你转移 ArrayBuffer 之后,基于它的 Uint8Array 并不会自动报错,直到你真正去读某个字节才抛异常。这会让问题延迟暴露,而且堆栈指向的根本不是 postMessage 那一行。所以我的习惯是:发送转移后立即给变量赋 null,并加一行注释,强制自己不再使用。
第二个坑是“把同一个 ArrayBuffer 连续转移给两个接收方”。转移列表里同一个 ArrayBuffer 不能出现两次,否则直接抛 DataCloneError;想把同一份数据发给两个 Worker,只能克隆一份再转移。遇到这种需求,建议先想清楚架构是不是有问题——通常更好的做法是让一个 Worker 做中转,或者直接用 SharedArrayBuffer。
第三个坑和 FileReader 有关。老项目里有人用 FileReader.readAsArrayBuffer 读文件再 postMessage,FileReader 的 onload 事件本身在主线程回调,读出的 ArrayBuffer 也是一次性分配的,配合 Transferable 没问题。但 FileReader 会产生一个“读文件 + 生成 Buffer”的固定开销,如果做大文件分片,改用 file.slice().arrayBuffer() 配合 Worker 自读会更高效,也少一次主线程内存滞留。
第四个坑是调试工具造成的假象。DevTools 的 Sources 面板打开 Worker 脚本调试时,Worker 运行速度会明显下降,你在断点模式下测出来的 Transferable 耗时没有参考价值。要测性能,务必关掉断点,用 Performance 面板或埋点数据说话。
6. 我的实操体会与默认原则
写到这里,基本上把 postMessage 这条链路从原理到实战都过了一遍。最后说点我的个人规矩,也算给想直接上手的同学一个“不用再思考”的默认选择。
我现在接到一个需要 Worker 的活儿,会按这个顺序决策:先问“数据能不能以 File/Blob 形式交给 Worker,让 Worker 自己读”——能,就走路线 A,用 File 引用,不碰 Transferable;再问“数据是否已经存在于主线程内存,且只需要 Worker 处理一次”——是,走路线 B,用 Transferable,发送后立刻释放主线程引用;如果两边都要频繁读写同一块数据,才会认真评估 SharedArrayBuffer 和 Atomics。控制消息、进度、结果,一律保持 KB 级别的小对象,永远默认克隆,不为它们做任何转移优化。
踩过几次 detached 的坑之后,我遇到“postMessage 性能问题”的第一个反问永远是:这段数据真的需要经过这个线程吗?很多性能问题不是结构化克隆或 Transferable 的锅,而是数据流设计本身多绕了一段路。把数据留在真正需要的线程里,比任何拷贝优化都管用。这条原则,比“零拷贝”三个字值钱多了。