前段时间,我写过一篇文章 如何在 Web Worker 中高效传输大批量数据(>10MB)。这篇文章是该文章的姊妹篇。
在上一篇文章中,我们介绍了 Web 开发中,Web Worker 是提升性能的重要工具。它能让我们把计算密集型任务放到后台线程执行,从而避免阻塞 UI。但 主线程和 Worker 之间的通信成本,常常成为性能瓶颈。所以,最佳方案是使用 Transferable Objects(可转移对象,如 ArrayBuffer、MessagePort、ImageBitmap),它可以在 postMessage 时通过 转移(transfer)而非复制来传输。这样可以 零拷贝,避免大的开销。
但是,如果要传输给 Web Worker 的数据本身不是 可转移对象,这个时候我们就需要将这些数据编码为 ArrayBuffer,然后在Web Worker 对 ArrayBuffer 的数据进行解码。如果编码和解码的成本比较高,那么使用 可转移对象 是否还是一个正确的选择吗?
在最近的工作中我就碰到了这样的问题,所以我就写了一个基准测试的程序来寻求答案。实际的测试结果和原先的预测有非常大的差异。
🔍 Web Worker通信机制的原理
在查看具体的测试结果前,我们先回顾一下Web Worker通信机制的原理。Web Worker 与主线程之间通信依赖于 postMessage API。
默认方式:结构化克隆(Structured Clone Algorithm)
当我们调用:
worker.postMessage(largeObject);
浏览器会使用 结构化克隆算法 把 largeObject 完整复制 一份给 Worker:
- 不是共享内存,而是克隆副本
- CPU 开销大:对象内容需要遍历、复制
- 内存开销大:同一份数据在主线程和 Worker 中会存在两份
对于 几十 MB 甚至上百 MB 的大对象,这种复制可能导致明显的性能瓶颈。
最佳实践:可转移对象(Transferable Objects)
为了解决这个问题,浏览器支持一类特殊的数据类型 —— Transferable Objects。
常见的可转移对象有:
ArrayBufferMessagePortImageBitmap
这类对象在 postMessage 时可以通过 “转移” 而不是复制来传输:
// 转移 ArrayBuffer
worker.postMessage(buffer, [buffer]);
特点:
- 零拷贝:数据的所有权直接转移到 Worker
- 避免 GC 压力:主线程的 buffer 立即失效,不再占内存
- 高效:非常适合传输二进制数据(例如音视频流、图片、typed array)
🤔 那字符串呢?
字符串本身并不是 Transferable Object。因此,如果要借助可转移机制,我们必须先把字符串 编码为 ArrayBuffer,再传输,最后再解码回来。
于是,就有了本文的两个对比方案:
- Direct String Transfer —— 直接传字符串(结构化克隆)
- ArrayBuffer Transfer —— 字符串转 ArrayBuffer,再零拷贝传输
从直觉上看,方法 2 应该更快(因为避免了拷贝),但实际测试的结果却给出了完全不同的答案。
🚀 Benchmark 测试
我写了一个基准测试项目,开源在 GitHub 👉 mlight-lee/web-worker-string-benchmark。
📊 测试方法
方法一:Direct String Transfer
- 使用
postMessage(string)直接发送字符串 - 浏览器用结构化克隆算法处理
- 不涉及额外的编码/解码
方法二:ArrayBuffer Transfer
- 使用
TextEncoder把字符串转成ArrayBuffer postMessage(buffer, [buffer])零拷贝传递- 在 worker 中用
TextDecoder解码回字符串 - 包含额外的 编码/解码开销
🎯 测试参数
- 字符串大小:1MB, 5MB, 10MB, 50MB, 100MB
- 字符串类型:ASCII 随机字符串、Unicode (UTF-8 编码) 字符串
- 运行次数:每种情况 20 次,取平均值、最小值、最大值、中位数
- 浏览器版本:Google Chrome 139
📈 测试结果
这是我在自己 Windnows 笔记本的 Chrome浏览器 上做的测试的测试结果。在不同机器和浏览器上测试,测试结果可能会有所差异。但是,我在 Edge 139 测试下来,结论基本一致。
ASCII 字符串
传输的字符串只包含ASCII码大于等于32、小于等于127的字符。
| 大小 | 直接传 (平均) | ArrayBuffer (平均) | 更快方法 | 相对提升 |
|---|---|---|---|---|
| 1 MB | 3.90 ms | 9.42 ms | Direct | 58.6% |
| 5 MB | 13.70 ms | 39.83 ms | Direct | 65.6% |
| 10 MB | 22.40 ms | 66.58 ms | Direct | 66.3% |
| 50 MB | 93.11 ms | 338.79 ms | Direct | 72.5% |
| 100 MB | 211.33 ms | 692.54 ms | Direct | 69.5% |
Unicode 字符串
传输的字符串包含了Unicode编码范围内的任意字符。
| 大小 | 直接传 (平均) | ArrayBuffer (平均) | 更快方法 | 相对提升 |
|---|---|---|---|---|
| 1 MB | 2.92 ms | 16.57 ms | Direct | 82.3% |
| 5 MB | 11.99 ms | 80.48 ms | Direct | 85.1% |
| 10 MB | 26.67 ms | 166.73 ms | Direct | 84.0% |
| 50 MB | 93.90 ms | 700.85 ms | Direct | 86.6% |
| 100 MB | 202.35 ms | 1529.89 ms | Direct | 86.8% |
🔍 结果分析
-
编码/解码成本过高
TextEncoder/TextDecoder的开销非常大,尤其是 Unicode 字符串。- 这使得 ArrayBuffer 的零拷贝优势完全被抵消。
-
字符串越大,差距越明显
- 直接传递在 50MB~100MB 的场景下依然保持 70% 左右的性能优势。
-
ASCII vs Unicode
- 对于 ASCII,差距在 60–70%。
- 对于 Unicode,差距更大,直接传递可快 7–8 倍。
-
内存开销对比
- 直接传:大约 2 倍内存(原始 + 拷贝)
- ArrayBuffer:大约 3 倍内存(原始 + 编码后的字节数组 + 解码后的字符串)
✅ 结论与建议
故事到这里也就有了一个反转:一开始我以为 ArrayBuffer 会是更优解,但经过实际测试发现 —— 直接传字符串才是王道。这让我更坚信一句老话:性能优化不能只靠直觉,一定要用数据说话!
-
赢家:🚀 Direct String Transfer (
postMessage(string)) -
适用场景:
- 如果数据本身就是字符串,直接传递更快、更简单。
- 除非你已经有二进制数据(例如图片、TypedArray),否则不建议多此一举用 ArrayBuffer。
-
一句话总结:
👉 别为了零拷贝而强行转码字符串,结果可能更慢还更费内存。