上一章解决的是“如何安全地读入用户 ZIP”。这一章处理相反方向:把转换后的 HTML、图片和样式表重新打包为 ZIP。
archiver 的核心不是“先在内存中做出一个 ZIP”,而是创建一条可读的数据流。文件一边被读取和压缩,ZIP 字节一边被写入磁盘或 HTTP 响应,因此它很适合后端导出。
先看完整但不复杂的例子:
import archiver from "archiver";
import { createWriteStream } from "node:fs";
import { finished } from "node:stream/promises";
async function createZip(outputPath: string): Promise<void> {
const output = createWriteStream(outputPath);
const archive = archiver("zip", {
zlib: { level: 6 },
});
archive.on("warning", (error) => {
if (error.code === "ENOENT") {
console.warn("跳过了不存在的文件", error);
return;
}
output.destroy(error);
});
archive.on("error", (error) => {
output.destroy(error);
});
archive.pipe(output);
archive.append("<h1>Hello</h1>", {
name: "index.html",
});
archive.file("./assets/logo.png", {
name: "assets/logo.png",
});
archive.directory("./assets/css", "assets/css");
await archive.finalize();
await finished(output);
}
顺序可以记成:
创建 archive 和目标流
→ 注册错误监听器
→ archive.pipe(目标流)
→ 添加文件
→ archive.finalize()
→ 等待目标流真正结束
不要漏掉 finalize()。它表示“不会再添加 Entry,请写出 ZIP 的 Central Directory 并结束归档流”。
archiver() 的选项const archive = archiver("zip", {
zlib: { level: 6 },
});
第一个参数选择归档格式。常用的是 zip;archiver 也支持 tar,但 tar 与 ZIP 是不同格式。
zlib.level 是 Deflate 压缩等级,范围通常为 0~9:
0:不压缩,CPU 消耗低,文件较大;6:zlib 默认等级,通常是良好的起点;9:尽可能压缩,但会使用更多 CPU,并不保证体积明显变小。PNG、JPEG、WebP 等资源本身已经压缩,再使用等级 9 往往收益很小。新人项目先使用 6,不必为了少量空间增加服务器负载。
append():添加字符串、Buffer 或流添加内存中的 HTML:
archive.append(renderedHtml, {
name: "index.html",
});
添加小型 JSON 清单:
archive.append(
JSON.stringify({ generatedAt: new Date().toISOString() }, null, 2),
{ name: "manifest.json" },
);
也可以添加可读流:
import { createReadStream } from "node:fs";
archive.append(createReadStream("./assets/logo.png"), {
name: "assets/logo.png",
});
name 是 ZIP 内部的路径,不是服务器上的真实路径。不要把用户提供的未经验证的文件名直接作为 name。
file():添加一个磁盘文件archive.file("./work/index.html", {
name: "index.html",
});
第一个参数是服务器文件路径,name 是下载者在 ZIP 内看到的路径。这样可以把随机的临时存储名映射回清晰的导出名。
directory():添加整个目录archive.directory("./work/assets", "assets");
结果类似:
./work/assets/logo.png
→ ZIP 内 assets/logo.png
如果第二个参数为 false,只添加源目录中的内容,不再套一层目录:
archive.directory("./work/result", false);
不要把未经筛选的上传目录整体交给 directory(),否则临时文件、原始 ZIP、密钥文件或其他用户的数据可能一起进入压缩包。更稳妥的做法是只对专用的输出目录打包。
glob():按照模式添加文件archive.glob("**/*", {
cwd: "./work/result",
ignore: ["**/*.tmp"],
});
它适合规则明确的构建目录,但初学阶段优先使用 file() 和 directory(),路径关系更容易看懂和审查。
warning 与 errorarchiver 使用 EventEmitter 报告异步问题:
archive.on("warning", (error) => {
// 可恢复问题,例如待添加文件不存在
});
archive.on("error", (error) => {
// 无法继续完成 ZIP
});
两者并不是“警告可全部忽略、错误才处理”。官方示例只将 ENOENT 当作可记录的 warning,其他 warning 仍应当作失败:
archive.on("warning", (error) => {
if (error.code === "ENOENT") {
logger.warn({ error }, "归档文件不存在");
return;
}
output.destroy(error);
});
也不要在异步事件监听器里简单 throw error,因为它不会自动变成外层 async Route 的 rejected Promise。应销毁目标流,或显式把事件包装进 Promise。
finalize()、finish 与 close这几个概念容易混淆:
archive.finalize():停止添加 Entry,完成 ZIP 格式尾部;finish:所有数据已经交给底层 Writable;close:文件描述符已经关闭;archive.pointer():目前生成的归档字节数。事件可能很快发生,因此监听器应在调用 finalize() 之前注册。使用 finished(output) 可以把目标流的完成或失败转成 Promise:
const outputDone = finished(output);
await archive.finalize();
await outputDone;
console.log("ZIP 字节数", archive.pointer());
finalize() 完成也不等于“浏览器已下载完毕”。它只表示归档内容已经完成生成;网络传输还有自己的生命周期。
archiver → 临时 result.zip → res.download()
优点:
Content-Length;缺点:
对于刚入行的开发者,这种方式更容易写对,也更容易排错。
archiver → res
res.type("application/zip");
res.attachment("markdown-html.zip");
archive.pipe(res);
优点是少一个中间文件、可以尽早开始下载;缺点是一旦写出响应头或 ZIP 字节,后续失败就不能改成 JSON 错误,只能断开连接。第 07、08 章会给出完整处理方式。
Content-Disposition推荐让 Express 生成响应头:
res.attachment("项目导出.zip");
它会设置类似:
Content-Disposition: attachment; filename="..."; filename*=UTF-8''...
实际格式由 Express 使用的内容处置逻辑决定。不要自己拼接:
// 不推荐:引号、换行、中文和特殊字符都可能出问题
res.setHeader(
"Content-Disposition",
`attachment; filename="${req.body.name}.zip"`,
);
下载名仍应先做业务规范化,例如去掉控制字符、限制长度并提供默认名称。res.attachment() 能正确编码 Header,不等于可以无条件相信用户输入。
import archiver from "archiver";
import { createWriteStream } from "node:fs";
import { finished } from "node:stream/promises";
export async function archiveDirectory(
sourceDirectory: string,
outputPath: string,
): Promise<number> {
const output = createWriteStream(outputPath, {
flags: "wx",
});
const archive = archiver("zip", {
zlib: { level: 6 },
});
archive.on("error", (error) => {
output.destroy(error);
});
archive.on("warning", (error) => {
if (error.code !== "ENOENT") {
output.destroy(error);
}
});
archive.pipe(output);
archive.directory(sourceDirectory, false);
const outputDone = finished(output);
await archive.finalize();
await outputDone;
return archive.pointer();
}
flags: "wx" 表示目标已存在时失败,避免无意覆盖。实际工程还要在调用方用 try/finally 删除临时目录。
node:zlib 与 archiver 各自负责什么?为什么前者不能直接代替后者创建多文件 ZIP?append()、file() 和 directory() 分别适合什么数据来源?finalize() 之前注册所有事件监听器?archive.finalize() 成功是否代表浏览器已经下载完成?为什么?res 输出各有什么取舍?Content-Disposition?warning 事件?