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

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

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

ZIP 的基本结构

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

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

相关推荐
全栈项目管理程序猿37 分钟前
ArcGIS JS 基础教程(34):体元系列 - 等值面 VoxelIsosurface
前端·javascript
代码搬运媛39 分钟前
如何使用 @codemirror/view 库创建一个简单的代码编辑器
前端
huali42 分钟前
草稿该存在哪:per-session 是怎么成为共识的
前端·产品经理·产品
IPIPD43 分钟前
Proxifier规则为什么没有生效?从连接日志定位规则命中与DNS路径
前端
baiyunfan44 分钟前
微信小程序做 PDA 扫码出入库:库存并发控制与 4 个坑
前端
ZoeLandia44 分钟前
React6:组件通信
前端·react.js·typescript
爱勇宝44 分钟前
AI 都这么强了,咱们程序员以后还能干点啥?
前端·后端·程序员
FPGA小徐1 小时前
【一生一芯 / PA】AM 核心文件逐行解析(上):从 start.S 到 gpu.c
前端·算法
三十而立洋1 小时前
知枝 · MindSprout:可以直接将 markdown 转换为思维导图的 vscode 插件
前端·visual studio code