本章先建立整体认识:为什么 Node.js 的
zlib不能代替 ZIP 库,以及yauzl和archiver在 Markdown 转换服务中分别负责什么。
当前 Markdown ZIP 转换功能可以概括为:
用户上传 ZIP
↓
Multer 将上传文件保存到临时目录
↓
yauzl 逐个读取 ZIP 中的目录项(Entry)
↓
校验路径、数量、大小和允许的文件类型
↓
提取 Markdown、图片等文件
↓
Markdown 转换成 HTML
↓
archiver 将 HTML 和资源重新打包为 ZIP
↓
Express 返回下载响应
↓
清理临时文件
这里有两条方向相反的流水线:
| 方向 | 库 | 主要工作 |
|---|---|---|
| 导入 | yauzl |
打开并读取已有 ZIP |
| 导出 | archiver |
创建一个新的 ZIP |
不要把两个库混为一谈。yauzl 不负责把 HTML 打包输出,archiver 也不是用来安全检查用户上传 ZIP 的主要工具。
ZIP 是一种归档容器格式。一个 ZIP 可以包含多个文件、目录及其元数据,例如:
markdown-project.zip
├── README.md
├── chapters/
│ └── getting-started.md
└── assets/
└── architecture.png
ZIP 内除了文件正文,还有:
Deflate 则是一种压缩算法,它关心的是“怎样把一段字节压小”,并不知道一个归档中有哪些文件。
可以用一个不严格但容易记忆的比喻:
Deflate = 折叠衣服的方法
ZIP = 装有多件衣服、标签和清单的行李箱
node:zlib 为什么不够Node.js 内置的 node:zlib 提供 gzip、Deflate、Brotli 等压缩流:
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream("report.md"),
createGzip(),
createWriteStream("report.md.gz"),
);
这会得到一个 .gz 文件,但它没有替我们实现完整的 ZIP 目录结构,也不能让我们方便地列出 ZIP 中所有成员。
因此:
.gz 数据流,可以直接使用 node:zlib;.zip,通常需要 yauzl 等 ZIP 库;.zip,可以使用 archiver。yauzl 内部仍可能使用 Node.js 的解压能力来处理 Deflate 数据,但它额外实现了 ZIP 格式的解析。
一个普通 ZIP 的概念结构大致如下:
[文件 A 的本地头部][文件 A 数据]
[文件 B 的本地头部][文件 B 数据]
[文件 C 的本地头部][文件 C 数据]
[Central Directory]
[End of Central Directory]
Central Directory 像一张总清单,记录每个文件的名称、大小、压缩方式和数据位置。yauzl 会利用它产生一个个 Entry。
注意:Entry 主要是元数据,收到 entry 事件并不表示文件正文已经全部读取到内存。真正需要正文时,再调用:
zipFile.openReadStream(entry, callback);
得到可读取的数据流。
这种“先读目录项,需要时才打开正文流”的设计,正适合服务器处理不可信上传文件。
extractAll() 麻烦yauzl 是相对底层的 ZIP 读取库。它通常要求我们自己完成:
这确实比“一行解压全部文件”多一些代码,但它让应用有机会在每个文件写入之前检查:
../;对于服务端接收用户上传文件,这种控制能力比 API 简短更重要。
假设 ZIP 中有一个 300 MiB 的图片。如果把整个文件收集成 Buffer:
ZIP 文件 Buffer
+ 解压后的图片 Buffer
+ 转换过程中的临时数据
+ 多个并发请求
Node.js 内存可能迅速增加。
流式处理的思路是:
yauzl Entry ReadStream
↓ 一小块一小块读取
文件 WriteStream
后续代码会使用:
import { pipeline } from "node:stream/promises";
await pipeline(entryStream, outputStream);
pipeline() 会连接读取端和写入端,并处理背压:如果磁盘暂时写不过来,读取端会减慢,而不是无限制把数据堆入内存。
适合当前服务的默认方案:
Multer DiskStorage
→ yauzl.open(zipPath)
→ 逐项读取
优点:
代价是必须可靠清理临时目录。
也可以使用:
Multer MemoryStorage
→ yauzl.fromBuffer(req.file.buffer)
适合体积非常小、数量和并发都有严格上限的文件。虽然 Entry 正文仍可流式读取,但整个 ZIP Buffer 已经在内存中,直到 ZipFile 和 Buffer 不再被引用。
因此,fromBuffer() 并不等于“没有内存风险”。
转换完成后,archiver 接收文件、目录、Buffer 或 Stream,并把生成的 ZIP 写到输出流:
生成的 index.html ─┐
assets/ 目录 ──────┼→ archiver → result.zip 或 Express Response
manifest.json ─────┘
最小概念示例:
import archiver from "archiver";
import { createWriteStream } from "node:fs";
const output = createWriteStream("result.zip");
const archive = archiver("zip", {
zlib: { level: 6 },
});
archive.pipe(output);
archive.file("work/index.html", { name: "index.html" });
archive.directory("work/assets", "assets");
await archive.finalize();
这一章只需记住:要先监听错误、再添加内容、最后调用 finalize()。后续 archiver 专章会讨论输出到 Express Response 时的错误和中断问题。
当前服务同时面对三类风险,不能只解决其中一种:
| 层次 | 典型风险 | 主要处理位置 |
|---|---|---|
| ZIP 结构 | Zip Slip、ZIP 炸弹、过多 Entry | yauzl 遍历阶段 |
| 文件内容 | 伪造扩展名、危险 SVG、恶意文档 | 文件验证或扫描阶段 |
| HTML 内容 | Markdown 内嵌 HTML、脚本、危险 URL | Markdown 渲染和 HTML 清理阶段 |
例如,路径检查通过只代表文件不会写出临时目录,并不代表该文件内容安全。
不要第一次就把全部功能写在一个 Express Route 中。可以按以下顺序练习:
pipeline() 逐项写入临时目录;这样每一步都能单独验证,出现问题时也容易判断属于 ZIP、转换还是 HTTP 层。
node:zlib 可以生成 .gz,却不能直接替代 yauzl 列出 ZIP 中的多个文件?Entry 和 Entry 文件正文有什么区别?extractAll() 更适合?fromBuffer() 能流式读取 Entry,为什么仍然可能带来内存压力?pipeline() 中的背压解决了什么问题?