不引入 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...

相关推荐
SGAMERrain9 小时前
# Nuxt 4 + MediaPipe:如何在浏览器中实现本地 AI 人脸分析?
前端
github_is_geeksKai9 小时前
浏览器端文件处理的边界:PDF 面单、GPX 隐私与 EPUB 修复
前端
deli00710 小时前
从一句反馈到一次升级:Mystudy 的自进化实践
前端
FungLeo11 小时前
成为全栈·Vue 管理后台篇·用 fetch 建一层可控的请求内核
前端·javascript·vue.js·http·fetch·formdata·接口封装
FungLeo13 小时前
成为全栈·Vue 管理后台篇·Vue Router如何守住多角色后台边界
前端·javascript·vue.js·rbac·vue router·路由守卫·前端权限
FungLeo13 小时前
成为全栈·Vue 管理后台篇·分页只显示第一页:从接口total 定位到前端修复
前端·vue.js·状态模式·分页·naive ui·服务端分页·前端问题排查
ZhengEnCi13 小时前
苹果滑动为什么有「肉感」:橡皮筋越界阻尼与弹簧动画的物理原理
前端
Moment13 小时前
不会后期的我,发现了一个开源胶片神器😍😍😍
前端·后端·开源
L1mbo13 小时前
Lite模式 缓解微信开发者工具卡顿,附 uni-app 实践方案
前端·微信小程序
FungLeo14 小时前
成为全栈·Vue管理后台篇·仪表盘不是几张卡片:组合统计接口与角色视图
前端·javascript·vue.js·rbac·仪表盘·管理后台·tanstack query