做批量图片工具时,一次要下载几十张切片,逐个点下载体验很差,得打包成 ZIP。JSZip 当然能用,但对一个总产物只有 600KB 的静态站来说,为了打包再引入几十 KB 的依赖不太划算。
ZIP 格式的公开规范足够简单,自己写一个"只存储、不压缩"的打包器,大概 80 行代码。
ZIP 的基本结构
一个最简 ZIP 由四部分组成,顺序不能乱:
- 每个文件的本地头(30 字节 + 文件名)
- 紧跟其后的文件数据
- 全部文件写完后,写中央目录(每个文件一条 46 字节记录 + 文件名)
- 最后是中央目录结束记录 EOCD(22 字节)
解压软件通常先读末尾的 EOCD,拿到中央目录的偏移和大小,再跳到中央目录逐个读文件位置。所以中央目录必须最后写。
第一步:CRC32
每个文件都要计算校验值。用查表法,一次构建 256 项的表,之后每个字节只做一次查表和异或:
ini
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、原始大小、文件名长度。
scss
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 中的字节偏移。这个偏移必须把之前所有文件的"本地头 + 数据"都算进去:
javascript
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 字节记录条目数、中央目录大小和偏移:
ini
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...