内存翻倍往往发生在格式转换的交界处:原始字节还在,新的字符串、像素或 Worker 副本已经生成。优化目标不是让变量数量最少,而是让同一时刻必须存活的数据表示最少。先画出读取、处理、导出和预览链路,再逐段削减副本。
先列出同时存活的数据
假设一个 100 MiB 文件先读为 ArrayBuffer,再转成 Base64,并把原 Buffer 克隆给 Worker:主线程 Buffer 和 Worker Buffer 就有约 200 MiB,Base64 又约有 133.3 MiB 个字符;字符串实际占用取决于引擎,不能一律当成每字符两字节。File 可能由磁盘支持,也不能机械地再加一个完整文件大小。
| 操作 | 是否新增完整表示 | 替代方式 |
|---|---|---|
| new Uint8Array(buffer) | 只建视图,共享底层 Buffer | 需要只读访问时直接使用 |
| typedArray.slice() | 复制选中范围 | 只需视图时用 subarray() |
| blob.arrayBuffer() / file.text() | 物化完整字节或文本 | 支持增量时使用 stream() |
| postMessage(buffer) | 无 transfer 时通常克隆字节 | 转移所有权或分块发送 |
| toDataURL() | 生成 Base64 字符串 | 二进制导出优先 toBlob() |
共享视图也有代价:保留一个很小的 subarray,会让整个底层大 Buffer 保持可达。如果只需长期留下十几个字节,把这小段复制出来,反而能释放大 Buffer。不能把“零复制”当作不考虑生命周期的固定规则。
流式处理必须连输出端一起设计
下面是一个保留原字节的流式复制函数,destination 必须是真正逐块写出的目标,例如已获得授权的文件写入流。它不是图片压缩器;插入转换器时,还要确认算法能增量运行,并限制转换器内部缓存。pipeTo 会传播背压,并默认在完成时关闭目标,在错误或取消时传播终止。
async function copyWithBackpressure(
file: File,
destination: WritableStream<Uint8Array>,
signal: AbortSignal,
): Promise<void> {
await file.stream().pipeTo(destination, { signal });
}如果目标只是把 chunks 推进数组,最后 new Blob(chunks),内存仍随总输出增长。new Response(stream).blob() 也会汇集完整结果。对缺少文件流写入能力的浏览器,可以限制输出大小或切换桌面工具,不要宣称只要用了 stream() 就是恒定内存。
把所有权交给 Worker,而不是复制输入
// Main thread: worker is an existing Worker.
const buffer = await file.arrayBuffer();
worker.postMessage({ buffer }, [buffer]);
// buffer.byteLength is now 0: ownership was transferred.
// Do not read or reuse views backed by buffer here.transfer 列表转移的是 ArrayBuffer,不是 Uint8Array 视图;数据还必须出现在消息体中。转移后主线程的 Buffer 及其视图不可继续使用,应只保留任务 ID、进度和必要的文件信息。Worker 内部的解码、WASM 堆拷贝和输出分配仍可能增加内存。
此示例仍完整读取文件。真正的大文件增量任务可每次发送一个块,等 Worker 确认消费后再读下一块;只顾 transfer 而不停 postMessage 会把内存压力移到消息队列。普通 JSON.parse、某些 ZIP 库和整图编码器未必支持增量输入,应先检查处理库的契约。
限制并发与结果保留
- 先把 Promise.all(files.map(process)) 改为逐个处理,记录单任务峰值后再决定是否允许两个并发。
- 处理完一项就把输出写出;界面只保留文件名、状态和小缩略图,不把所有大 Blob 永久留在 state。
- 替换预览时释放旧 Object URL,结束使用 ImageBitmap 后 close();移除事件监听和不再需要的历史结果。
- 取消任务时停止读取与排队,并让 Worker 丢弃旧任务结果;中断 CPU 密集 Worker 时清理关联预览和输出。
流式输入不代表可以任意切分内容。文本块可能在 UTF-8 多字节字符中间结束,要使用支持跨块状态的 TextDecoder;按行处理还需要限制单行长度。否则一个没有换行的巨大输入会让“残留半行”缓存变成新的完整文件。
如何证明优化有效?
固定同一浏览器、文件和操作顺序,记录处理前、处理中峰值、清理后的内存。重复运行十轮,观察清理后的基线是否持续上升,同时比较耗时和结果正确性。既看 JS 堆,也看标签页进程内存;Blob、图像和原生编解码资源不一定完整体现在堆快照里。具体数字来自实测,不能保证优化后恰好减少一半。