在 Web 中解压大型 ZIP 并保持目录结构

本文最后更新于:2025年4月25日 晚上

背景

最初是在 reddit 上看到有人在寻找可以解压 zip 文件的 Firefox 插件 [1],好奇为什么还有这种需求,发现作者使用的是环境受限的电脑,无法自由的安装本地程序。于是吾辈便去检查了现有的在线解压工具,结果却发现排名前 5 的解压工具都没有完全支持下面两点

  1. 解压大型 ZIP 文件,例如数十 G 的 ZIP
  2. 解压目录时保持目录结构

下面的视频展示了当前一些在线工具的表现

实际上,只有 ezyZip 有点接近,但它也不支持解压 ZIP 中的特定目录。

实现

在简单思考之后,吾辈考虑尝试使用时下流行的 Vibe Coding 来创建一个 Web 工具来满足这个需求。首先检查 zip 相关的 npm 包,吾辈之前经常使用的是 jszip,但这次检查时发现它的不能处理大型 ZIP 文件 [2]。所以找到了更快的 fflate,但遗憾的是,它完全不支持加密解密功能,但作者在 issue 中推荐了 zip.js [3]

流式解压

官网给出的例子非常简单,也非常简洁明了。如果是解压文件并触发下载,只需要结合使用 BlobWriter/file-saver 即可。

1
2
3
4
5
6
7
8
9
10
11
import { saveAs } from 'file-saver'

const zipFileReader = new BlobReader(zipFileBlob)
const zipReader = new ZipReader(zipFileReader)
const firstEntry = (await zipReader.getEntries()).shift()

const blobWriter = new BlobWriter() // 创建一个解析 zip 中的文件为 blob 的适配器
const blob = await firstEntry.getData(blobWriter) // 实际进行转换
await zipReader.close() // 关闭流

saveAs(blob, 'test.mp4') // 保存到磁盘

这段代码出现了一个有趣之处:BlobWriter,它是如何保存解压后的超大型文件的?毕竟数据总要在某个地方,blob 似乎都在内存中,而且也只允许流式读取而不能流式写入。检查一下 GitHub 上的源代码 [4]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class BlobWriter extends Stream {
constructor(contentType) {
super()
const writer = this
const transformStream = new TransformStream()
const headers = []
if (contentType) {
headers.push([HTTP_HEADER_CONTENT_TYPE, contentType])
}
Object.defineProperty(writer, PROPERTY_NAME_WRITABLE, {
get() {
return transformStream.writable
},
})
writer.blob = new Response(transformStream.readable, { headers }).blob()
}

getData() {
return this.blob
}
}

是的,这里的关键在于 Response,它允许接受某种 ReadableStream [5] 类型的参数,而 ReadableStream 并不保存数据到内存,它只是一个可以不断拉取数据的流。

例如下面手动创建了一个 ReadableStream,它生成一个从零开始自增的无限流,但如果没有消费,它只会产生第一条数据。

1
2
3
4
5
6
7
8
9
let i = 0
const stream = new ReadableStream({
pull(controller) {
console.log('generate', i)
controller.enqueue(i)
i++
},
})
const resp = new Response(stream)

1745601286178.jpg

如果消费 100 次,它就会生成 100 个值。

1
2
3
4
5
6
7
// before code...
const reader = resp.body!.getReader()
let chunk = await reader.read()
while (!chunk.done && i < 100) {
console.log('read', chunk.value)
chunk = await reader.read()
}

1745601480457.jpg

而在 zip.js 解压时,通过 firstEntry.getData(blobWriter) 将解压单个文件产生的二进制流写入到了 Response 并转换为 Blob 了。但是,难道 await new Response().blob() 不会将数据全部加载到内存中吗?

是的,一般认为 Blob 保存的数据都在内存中,但当 Blob 过大时,它会透明的转移到磁盘中 [6],至少在 Chromium 官方文档中是如此声称的,JavaScript 规范并未明确指定浏览器要如何实现。有人在 Stack Overflow 上提到 Blob 只是指向数据的指针,并不保存真实的数据 [7],这句话确实非常正确,而且有趣。顺便一提,可以访问 chrome://blob-internals/ 查看浏览器中所有的 Blob 对象。

解压目录

解压目录主要麻烦的是一次写入多个目录和文件到本地,而这需要利用浏览器中较新的 File System API [8],目前为止,它在浏览器中的兼容性还不错 [9],所以这里利用它来解压 ZIP 中的目录并写入本地。无论如何,只要做好降级处理,使用这个新 API 是可行的。

首先,可以通过拖拽 API 或者 input File 来获取一个目录的 FileSystemDirectoryHandle 句柄。一旦拿到它,就可以访问这个目录下所有的文件,并且可以创建子目录和写入文件(支持流式写入)。假设我们有一个要写入的文件列表,可以轻松使用下面的方法写入到选择的目录中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const list = [
{
path: 'test1/test1.txt',
content: 'test1',
},
{
path: 'test1/test2.txt',
content: 'test2',
},
{
path: 'test3/test3.txt',
content: 'test3',
},
]

function fs(rootHandle: FileSystemDirectoryHandle) {
const dirCache = new Map<string, FileSystemDirectoryHandle>()
dirCache.set('', rootHandle)
async function mkdirp(path: string[]): Promise<FileSystemDirectoryHandle> {
if (path.length === 0) {
return rootHandle
}
const dirPath = path.join('/')
if (dirCache.has(dirPath)) {
return dirCache.get(dirPath)!
}
const parentPath = path.slice(0, -1)
const parentDir = await mkdirp(parentPath)
const newDir = await parentDir.getDirectoryHandle(path[path.length - 1], {
create: true,
})
dirCache.set(dirPath, newDir)
return newDir
}
return {
async write(path: string, blob: Blob) {
const pathParts = path.split('/').filter(Boolean)
const dir = await mkdirp(pathParts)
const fileHandle = await dir.getFileHandle(pathParts.pop()!, {
create: true,
})
const writable = await fileHandle.createWritable()
await blob.stream().pipeTo(writable) // 流式写入文件到本地
},
}
}

const rootHandle = await navigator.storage.getDirectory() // rootHandle 是拖拽 API 或者 input File 获取的句柄,这里只是用来测试
const { write } = fs(rootHandle)
for (const it of list) {
console.log('write', it.path)
await write(it.path, new Blob([it.content]))
}

局限性

尽管 File System API 已经可以胜任普通的文件操作,但它仍然有一些局限性,包括

  1. 用户可以选择的目录是有限制的,例如,无法直接选择 ~/Desktop 或 ~/Downlaod 目录,因为这被认为是有风险的 [10]
  2. 无法保存一些特定后缀名的文件,例如 *.cfg 或者以 ~ 结尾的文件,同样被认为有风险 [11]

总结

这是一个很早之前就有人做过的事情,但直到现在仍然可以发现一些有趣的东西。尤其是 Blob 的部分,之前从未真正去了解过它的存储方式。

基于本文探讨的技术,吾辈最终实现了一个名为 MyUnzip 的在线解压工具,欢迎访问 https://myunzip.com 试用并提出反馈。


在 Web 中解压大型 ZIP 并保持目录结构
https://blog.rxliuli.com/p/7b8a2da0ca00490cb79b6574b5b8744e/
作者
rxliuli
发布于
2025年4月24日
许可协议