不引入 JSZip,自己写一个 ZIP 打包器:CRC32、中央目录与 UTF-8 文件名

107 阅读4分钟

做批量图片工具时,一次要下载几十张切片,逐个点下载体验很差,得打包成 ZIP。JSZip 当然能用,但对一个总产物只有 600KB 的静态站来说,为了打包再引入几十 KB 的依赖不太划算。

ZIP 格式的公开规范足够简单,自己写一个“只存储、不压缩”的打包器,大概 80 行代码。

ZIP 的基本结构

一个最简 ZIP 由四部分组成,顺序不能乱:

  1. 每个文件的本地头(30 字节 + 文件名)
  2. 紧跟其后的文件数据
  3. 全部文件写完后,写中央目录(每个文件一条 46 字节记录 + 文件名)
  4. 最后是中央目录结束记录 EOCD(22 字节)

解压软件通常先读末尾的 EOCD,拿到中央目录的偏移和大小,再跳到中央目录逐个读文件位置。所以中央目录必须最后写。

第一步:CRC32

每个文件都要计算校验值。用查表法,一次构建 256 项的表,之后每个字节只做一次查表和异或:

var TABLE = new Uint32Array(256);
for (var n = 0; n < 256; n++) {
  var c = n;
  for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
  TABLE[n] = c >>> 0;
}
function crc32(bytes) {
  var c = 0xFFFFFFFF;
  for (var i = 0; i < bytes.length; i++) {
    c = TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
  }
  return (c ^ 0xFFFFFFFF) >>> 0;
}

第二步:本地文件头

关键字段:签名 0x04034B50、压缩方法(0 = 存储)、CRC、原始大小、文件名长度。

var nameBytes = new TextEncoder().encode(entry.name);
var local = new Uint8Array(30 + nameBytes.length);
var lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true);   // 本地头签名
lv.setUint16(4, 20, true);           // 解压所需版本
lv.setUint16(6, 0x0800, true);       // 关键:UTF-8 文件名标志
lv.setUint16(8, 0, true);            // 压缩方法:0 = 存储
lv.setUint32(14, crc, true);
lv.setUint32(18, data.length, true);
lv.setUint32(22, data.length, true);
lv.setUint16(26, nameBytes.length, true);
local.set(nameBytes, 30);

中文文件名最容易出错的地方就是第 6 字节那个标志位。 不设 0x0800,文件名会按 CP437 解码,中文变成乱码。设置了它,Windows、macOS、7-Zip 都能正确显示中文。

第三步:中央目录与偏移量

中央目录每条记录要写 CRC、大小、文件名,以及最重要的——本地头在整个 ZIP 中的字节偏移。这个偏移必须把之前所有文件的“本地头 + 数据”都算进去:

var cd = new Uint8Array(46 + nameBytes.length);
var cv = new DataView(cd.buffer);
cv.setUint32(0, 0x02014b50, true);   // 中央目录签名
cv.setUint16(8, 0x0800, true);       // UTF-8 标志
cv.setUint32(16, crc, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint32(42, offset, true);      // 本地头偏移
cd.set(nameBytes, 46);

offset += local.length + data.length; // 为下一个文件累加

偏移量写错,解压软件会报“文件损坏”或解出空文件,而且不告诉你哪一条错了。调试时先打一个只含单文件的最小 ZIP,再扩到多文件。

第四步:EOCD

最后 22 字节记录条目数、中央目录大小和偏移:

var end = new Uint8Array(22);
var ev = new DataView(end.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, centralSize, true);
ev.setUint32(16, offset, true);

存储 vs 压缩:为什么我选了存储

ZIP 支持 deflate,但图片(JPG/PNG/WebP)本身已经是压缩格式,再 deflate 一遍通常只能小 1%~3%,却要多写几百行压缩代码、多花几秒 CPU。浏览器里虽然可以用 CompressionStream,但 Safari 的支持起步较晚。

所以这个打包器只做“存储”(方法 0):文件字节原样写入,兼容性最好,速度最快。代价是 ZIP 体积约等于原文件总和,对批量图片下载完全可接受。

测试清单

  • Windows 资源管理器右键解压,中文名不乱码
  • macOS 双击解压,文件数量与顺序一致
  • 7-Zip “测试压缩包”通过,无 CRC 错误
  • 单个大文件(接近 100MB)能正常解压
  • 0 个文件时不生成空 ZIP,直接给提示

已知上限:不支持 ZIP64,总大小超过 4GB 或条目超过 65535 会失败。批量图片场景远达不到这个量级,真到了再补。

线上用例(批量重命名并打包下载,纯浏览器):picartisan.com/tools/renam…