Web 流式写入文件

本文最后更新于:2025年6月5日 早上

背景

由于吾辈之前使用的一个域名即将到期,需要将 IndexedDB 数据迁移到新的域名,因此这两天创建了一个新的浏览器扩展 IDBPort,用于迁移 IndexedDB 数据到其他域名。而在迁移数据时,需要将数据导出为并下载到本地,然后导入到新的域名。由于数据量较多,同时包含一些图像之类的二进制数据,所以需要使用流式写入的方式来避免内存占用过高。

首先,Web 中有什么 Target 可以流式写入数据吗?
实际上,是有的,例如 Blob+Response,或者 OPFS 私有文件系统,它们都支持流式写入数据到磁盘,考虑到 OPFS 仍然相对较新,所以下面使用 Blob+Response 来实现。

流式写入

如果不考虑流式写入,可以将数据全部都放在内存中的话,那么直接使用一个 string[] 来存储数组,然后一次性创建一个 Blob 对象,也是一种选择。但如果数据有数百 M(包含图像或视频)甚至上 G,那么内存就会爆掉,需要使用流式写入保持内存不会线形增长。在之前 在 Web 中解压大型 ZIP 并保持目录结构 中有提到过,但由于当时使用 zip.js,而它们直接提供了 BlobWriter/BlobReader 来使用,所以并未深入研究实现,也没有尝试手动实现。这里涉及到几个关键 API

  • Blob: 二进制数据存储接口,它会在数据过多时透明的从内存转移到磁盘 [1],这保证了内存占用不会太大
  • Response: Response 允许接收一个 ReadableStream 并创建一个 Blob 对象
  • TransformStream:提供一个通道,提供一个 ReadableStream 和 WritableStream,让流式写入变的简单
  • TextEncoderStream: 将一个文本流转换为 Uint8Array 流,这是 Response ReadableStream 所需要的数据格式

基本流程

  1. 创建 TransformStream
  2. 使用 ReadableStream 结合 TextEncoderStream 创建 Response
  3. 立刻获取 blob,触发 ReadableStream 的拉取
  4. 使用 WritableStream 开始写入
  5. 关闭 TransformStream
  6. await promise blob 来获取写入完成的 blob

10 行代码即可验证

1
2
3
4
5
6
7
8
9
10
11
12
13
const transform = new TransformStream<string, string>()
const blobPromise = new Response(
transform.readable.pipeThrough(new TextEncoderStream()),
).blob()
const writable = transform.writable.getWriter()
await writable.ready
await writable.write('line1\n')
await writable.write('line2\n')
await writable.close()
const blob = await blobPromise
console.log(await blob.text())
// line1
// line2

流式读取

相比之下,流式读取使用的 API 要更少,只需要使用 blob.stream() 即可流式读取一个 Blob(或者一个一个 File)。几个关键的 API

由于 blob.stream() 返回的 chunk 可能存在截断或不完整,例如假使预期的 chunk 是按照换行分割点文本 line1\nline2\nblob.stream() 可能会返回 line1 甚至截断的 line1\nli,所以必须使用自定义的 TransformStream 来将默认的流转换为预期的按行分割的流。

  1. 用户选择文件
  2. 得到 File(Blob 子类)
  3. file.stream() 流式读取
  4. 使用 TextDecodeStream 解码 Uint8Array 为文本
  5. 自定义 LineBreakStream 根据 line 分割 chunk
  6. 流式遍历
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class LineBreakStream extends TransformStream<string, string> {
constructor() {
let temp = ''
super({
transform(chunk, controller) {
temp += chunk
const lines = temp.split('\n')
for (let i = 0; i < lines.length - 1; i++) {
const it = lines[i]
controller.enqueue(it)
temp = temp.slice(it.length + 1)
}
},
flush(controller) {
if (temp.length !== 0) controller.enqueue(temp)
},
})
}
}

然后来验证它是否有效,下面写入了 3 个不规则的 chunk,但最终得到的结果仍然是 [ "line1", "line2" ],也就是说,LineBreakStream 生效了。

1
2
3
4
5
6
7
8
9
10
11
12
13
const transform = new TransformStream<Uint8Array, Uint8Array>()
const readable = transform.readable
.pipeThrough(new TextDecoderStream())
.pipeThrough(new LineBreakStream())
const promise = Array.fromAsync(readable) // 触发拉取
const writer = transform.writable.getWriter()
const encoder = new TextEncoder()
await writer.ready
await writer.write(encoder.encode('line1'))
await writer.write(encoder.encode('\nli'))
await writer.write(encoder.encode('ne2\n'))
await writer.close()
console.log(await promise) // [ "line1", "line2" ]

现在,来使用它读取 Blob 就很简单了。

1
2
3
4
5
6
7
8
9
10
11
const blob = new Blob(['line1\nline2\n'])
const readable = blob
.stream()
.pipeThrough(new TextDecoderStream())
.pipeThrough(new LineBreakStream())
const reader = readable.getReader()
while (true) {
const chunk = await reader.read()
if (chunk.done) break
console.log(chunk.value)
}

总结

在浏览器中创建和读取大型文本文件似乎是个小众的需求,但如果确实需要,现代浏览器确实可以处理。考虑到之前做过的在线压缩工具,确认甚至可以处理数十 GB 尺寸的文件。


Web 流式写入文件
https://blog.rxliuli.com/p/4163a2afe21f4cbe907a7beeccd7730c/
作者
rxliuli
发布于
2025年5月28日
许可协议