06. 使用 archiver 创建 ZIP

上一章解决的是“如何安全地读入用户 ZIP”。这一章处理相反方向:把转换后的 HTML、图片和样式表重新打包为 ZIP。

archiver 的核心不是“先在内存中做出一个 ZIP”,而是创建一条可读的数据流。文件一边被读取和压缩,ZIP 字节一边被写入磁盘或 HTTP 响应,因此它很适合后端导出。

1. 最小工作流程

先看完整但不复杂的例子:

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 并结束归档流”。

2. archiver() 的选项

const archive = archiver("zip", {
  zlib: { level: 6 },
});

第一个参数选择归档格式。常用的是 zip;archiver 也支持 tar,但 tar 与 ZIP 是不同格式。

zlib.level 是 Deflate 压缩等级,范围通常为 09

PNG、JPEG、WebP 等资源本身已经压缩,再使用等级 9 往往收益很小。新人项目先使用 6,不必为了少量空间增加服务器负载。

3. 添加内容的四个常用方法

3.1 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

3.2 file():添加一个磁盘文件

archive.file("./work/index.html", {
  name: "index.html",
});

第一个参数是服务器文件路径,name 是下载者在 ZIP 内看到的路径。这样可以把随机的临时存储名映射回清晰的导出名。

3.3 directory():添加整个目录

archive.directory("./work/assets", "assets");

结果类似:

./work/assets/logo.png
  → ZIP 内 assets/logo.png

如果第二个参数为 false,只添加源目录中的内容,不再套一层目录:

archive.directory("./work/result", false);

不要把未经筛选的上传目录整体交给 directory(),否则临时文件、原始 ZIP、密钥文件或其他用户的数据可能一起进入压缩包。更稳妥的做法是只对专用的输出目录打包。

3.4 glob():按照模式添加文件

archive.glob("**/*", {
  cwd: "./work/result",
  ignore: ["**/*.tmp"],
});

它适合规则明确的构建目录,但初学阶段优先使用 file()directory(),路径关系更容易看懂和审查。

4. warningerror

archiver 使用 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。

5. finalize()finishclose

这几个概念容易混淆:

事件可能很快发生,因此监听器应在调用 finalize() 之前注册。使用 finished(output) 可以把目标流的完成或失败转成 Promise:

const outputDone = finished(output);

await archive.finalize();
await outputDone;

console.log("ZIP 字节数", archive.pointer());

finalize() 完成也不等于“浏览器已下载完毕”。它只表示归档内容已经完成生成;网络传输还有自己的生命周期。

6. 输出到文件还是直接输出到 Response

方案 A:先写入临时 ZIP

archiver → 临时 result.zip → res.download()

优点:

缺点:

对于刚入行的开发者,这种方式更容易写对,也更容易排错。

方案 B:直接写入 Response

archiver → res
res.type("application/zip");
res.attachment("markdown-html.zip");
archive.pipe(res);

优点是少一个中间文件、可以尽早开始下载;缺点是一旦写出响应头或 ZIP 字节,后续失败就不能改成 JSON 错误,只能断开连接。第 07、08 章会给出完整处理方式。

7. 下载文件名和 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,不等于可以无条件相信用户输入。

8. 一个适合当前项目的归档 Service

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 删除临时目录。

复盘题

  1. node:zlib 与 archiver 各自负责什么?为什么前者不能直接代替后者创建多文件 ZIP?
  2. append()file()directory() 分别适合什么数据来源?
  3. 为什么应在 finalize() 之前注册所有事件监听器?
  4. archive.finalize() 成功是否代表浏览器已经下载完成?为什么?
  5. 先生成临时 ZIP 与直接向 res 输出各有什么取舍?
  6. 为什么不应手工把用户文件名拼进 Content-Disposition
  7. 为什么不能忽略所有 warning 事件?

官方参考