一个 ArrayBuffer 从主线程搬进 Worker,以前要整个复制一遍内存——这个月 transfer() 终于在所有浏览器都稳了,搬的是所有权不是数据
一个 ArrayBuffer 从主线程搬进 Worker,以前要整个复制一遍内存——这个月 transfer() 终于在所有浏览器都稳了,搬的是所有权不是数据
前端处理大块二进制数据的场景其实比你想的多:图片像素、音频 buffer、WASM 内存、Excel 导入解析、几百万行的数据排序。这些活儿一旦搬进 Worker 并行跑,主线程是清爽了,但你多半在 postMessage 那一步悄悄多复制了一遍内存。ArrayBuffer 的 transfer() 就是来治这个病的——它搬的是所有权,不是数据。
先说结论:零拷贝搬的到底是什么
postMessage 默认行为是结构化克隆:你发什么,它就原样复制一份到接收端。一个 64MB 的像素 buffer 发过去,内存瞬间多占 64MB,还是同步复制,主线程该卡的照样卡。
而 transfer 的语义完全不同。ArrayBuffer 是一种「可转移对象」(Transferable),它拥有(owns)一块内存。transfer 时,那块内存被从原缓冲区摘下来,挂到新线程的对象上——指针挪一下而已,一个字节都不复制。这就是 MDN 说的 zero-copy,耗时常数级,跟 buffer 多大完全无关。
// 主线程
const worker = new Worker('worker.js');
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
const pixels = new Uint8Array(buffer);
// ...填充像素...
// 第二个参数是 transfer 列表,把 buffer 的所有权交出去
worker.postMessage({ pixels }, [buffer]);
console.log(buffer.byteLength); // 0 ← 已经 detached,内存归 Worker 了
关键陷阱也在这里:发完之后,发送方的 buffer 就「废了」(detached)。byteLength 变 0,再读再写直接抛 TypeError: ArrayBuffer is detached。很多人第一次用 transfer 踩的就是这个——transfer 不是「发送」,是「移交」,交出去你就没资格再碰了。
transfer() vs transferToFixedLength():什么时候用哪个
除了在 postMessage 里传 transfer 列表,ArrayBuffer 实例本身现在也有两个方法(ES2024 加法):
const buf1 = new ArrayBuffer(8);
new Uint8Array(buf1)[0] = 42;
const buf2 = buf1.transfer(16); // 内容不变,但扩容到 16 字节
console.log(buf1.detached); // true
console.log(buf1.byteLength); // 0
console.log(buf2.byteLength); // 16
console.log(new Uint8Array(buf2)[0]); // 42,原字节跟着走了
transfer(newByteLength?):把当前 buffer 摘下来,返回一个新 buffer,可以截断或零扩展。如果源是可调整大小(resizable)的 buffer,结果会保留 resizable 特性。transferToFixedLength(newByteLength?):行为一样,唯一区别是结果保证是定长 buffer,哪怕源是可调整大小的。
这俩和已经废弃的静态 ArrayBuffer.transfer() 不是一回事,别搞混了。老的静态方法语义没定义清楚,现在推荐用实例方法。
顺带一提,还有个更省心的兄弟:可调整大小的 ArrayBuffer。new ArrayBuffer(8, { maxByteLength: 16 }) 建出来的 buffer 可以原地 resize(),不用重新分配、不用复制,适合做流式增长的场景。
为什么这个月值得升级
transferable ArrayBuffer 正式进入 Baseline Widely available(全浏览器稳定可用),日期是 2026 年 9 月 5 日:
- Chrome 114(2023-05-30)
- Edge 114(2023-06-02)
- Firefox 122(2024-01-23)
- Safari 17.4(2024-03-05)
也就是说,从 Safari 17.4 往后,四大内核全都有了。Chrome Platform Status 显示当前只有约 0.041% 的页面加载用到了它——这意味着绝大多数项目还在用「复制一份」的老写法,白白多占内存。这波属于「能力早就有了,认知还没跟上」。
落到项目里的三个坑
坑一:把 TypedArray 直接塞进 transfer 列表。
Uint8Array、Float64Array 这些是可序列化的,但不是可转移对象。能转移的是它们底下的 buffer。所以正确写法永远是 [data.buffer],不是 [data]:
// ❌ 会抛异常,TypedArray 本身不可转移
worker.postMessage(data, [data]);
// ✅ 转移它底下的 buffer
worker.postMessage(data, [data.buffer]);
坑二:为了「保险」两个方向都复制。
常见错误是主线程 transfer 过去,Worker 处理完又 slice() 一份发回来。既然已经用 transfer 了,回程也用 transfer,才能全程零拷贝:
// worker.js
self.onmessage = (e) => {
const arr = e.data; // 所有权已经在这边了
arr.sort((a, b) => a - b); // 比如几百毫秒的排序
postMessage(arr, [arr.buffer]); // 回程同样 transfer
};
注意:Worker 不会让你的算法变快,排序还是那么久。它改变的是在哪儿跑——主线程发完活儿立刻回去画帧,spinner 转得丝滑,结果好了再以事件形式回来。
坑三:transfer 之后还拿着旧引用读。
detached 之后的 buffer byteLength 是 0,任何读写都抛异常。如果你代码里 transfer 完之后还有兜底逻辑会碰这个 buffer,记得先判 buffer.detached。
可落地的下一步
- 全局搜一遍
postMessage(,凡是传大块二进制(ArrayBuffer/TypedArray/ImageBitmap/OffscreenCanvas)的地方,检查有没有第二参数的 transfer 列表。 - 有 transfer 的地方,确认发送方之后不再读那个 buffer;顺手把回程也改成 transfer。
- 需要动态扩容的流式 buffer,评估换成
{ maxByteLength }+resize(),省掉反复分配复制。 - 想零成本验证效果,用
structuredClone(buf, { transfer: [buf] })单线程跑一段感受 detached 行为,不用起 Worker。
一句话:transfer 不是让数据「过去」,而是让数据「换个主人」。理解这一点,内存和卡顿就一起省下来了。
评论区
登录后可评论。