背景
由于吾辈之前使用的一个域名即将到期,需要将 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 所需要的数据格式
基本流程
- 创建 TransformStream
- 使用 ReadableStream 结合 TextEncoderStream 创建 Response
- 立刻获取 blob,触发 ReadableStream 的拉取
- 使用 WritableStream 开始写入
- 关闭 TransformStream
- 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\n,blob.stream() 可能会返回 line1 甚至截断的 line1\nli,所以必须使用自定义的 TransformStream 来将默认的流转换为预期的按行分割的流。
- 用户选择文件
- 得到 File(Blob 子类)
- file.stream() 流式读取
- 使用 TextDecodeStream 解码 Uint8Array 为文本
- 自定义 LineBreakStream 根据 line 分割 chunk
- 流式遍历
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 尺寸的文件。