← 返回博客
架构2026-08-28 12:49:178 分钟 · 2,274 0

在浏览器里造一个 TinyPNG:本地图片压缩工具的三代演进实录

一次上传自动产出十个变体的矩阵式图片压缩工具,全程跑在浏览器里零上传。从 jsquash 到 Squoosh 编码器再到手写 Rust 量化器——三代演进、PNG 签名缺失之坑、zopfli 的速度陷阱与 SIMD 探测回退的完整复盘。

我的博客工具箱里用得最凶的,是自己写的那个图片压缩工具——写文章配图前,截图扔进去,挑一个体积画质平衡点下载,全流程两秒钟。它对标 TinyPNG,但有两个根本不同:一次上传自动产出十个变体供我对比挑选,以及全程跑在浏览器里,架构上不存在上传通道

这篇文章复盘它的三代演进:v1 用现成 jsquash 起步,v2 换 Squoosh 同款编码器,v3 重构成矩阵模式并手写 Rust 量化器。以及路上踩的三个值得写下来的坑。

一、产品形态先于技术:为什么是"矩阵"

TinyPNG 的交互是黑盒:上传一张,吐回一张,你只能信它。但配图场景里我真正想要的是对比——同一张图,WebP 75 和 JPEG 85 谁更小?索引色 PNG 画质损失能不能接受?挨个格式试要上传四次。

所以这个工具的核心交互从第一天就定了:一次上传,自动产出 格式×质量 的完整矩阵——WebP 四档、JPEG 四档、PNG 两档,共十个变体平铺展示,每张标体积和节省百分比,点开滑动对比画质,挑中意的下载。

由此推出两条设计约束:

  1. 质量档位对用户隐藏。不暴露 0-100 滑杆,后台固定四档(92/85/75/60),界面上只显示「极致/高清/均衡/高压缩」。参数是工程师的思维方式,普通用户要的是"给我几个选"。
  2. 必须够快,否则矩阵就是灾难。十次编码如果串行卡 UI,体验直接崩掉——这决定了后面所有的架构选型。

二、三代演进

v1:jsquash 起步,一天踩进 quality 语义坑

第一版直接用 jsquash 系列编码器(Rust 编译的 mozjpeg/libwebp WASM 绑定),跑通了解码→编码→下载的最小闭环。第一个 bug 来得很快:同一张图,质量参数传 0.85 输出反而比 85 更糊。翻文档才发现 jsquash 的 quality 是 1-100 整数,传 0.85 被 clamp 成 1——等于用最低质量编码。典型的参数语义没核对就开写。

v2:换成 Squoosh 同款编码器

jsquash 够用,但 Google Squoosh 项目(web 版图片压缩的祖师爷)的编码器配置更完整:MozJPEG 带渐进式扫描和 trellis 量化,libwebp 参数调优过。v2 把 WebP/JPEG 整体迁到 Squoosh 的 wasm 产物上,直接抄它的默认参数再覆写 quality——站在巨人肩膀上,编码质量立刻对齐一线在线工具。

同时确定了编码器分工:WebP/JPEG 用 Squoosh 成熟产物,PNG 走自研(后面细说)。不重复造轮子,也不把命运全交给别人。

v3:矩阵模式 + 解码一次复用十次

v3 是体验重构造。关键决策在 Worker 编排上:解码一次,十个变体共享同一份像素

// matrix.worker.mjs 核心流程
const bitmap = await createImageBitmap(new Blob([buf]), {
  imageOrientation: "from-image",   // EXIF 方向自动校正,手机照片不横躺
});
// OffscreenCanvas 可选缩放后,getImageData 只做一次
const imageData = ctx.getImageData(0, 0, w, h);

for (const q of [92, 85, 75, 60]) {
  const bytes = await encodeWebP(imageData, q);
  post({ type: "variant", key: `webp-${q}`, bytes }, [bytes]); // 逐个渐进回传
}

每产出一个变体立刻 postMessage 回传渲染——用户看到的是矩阵卡片一张张"长出来",而不是白屏十秒后集体出现。感知速度比实际速度重要。

三、PNG 量化:手写一个 TinyPNG

TinyPNG 的核心魔法是索引色量化:真彩色 PNG(每像素 4 字节)→ 256 色调色板(每像素 1 字节),体积降 60-70%,照片观感几乎无损。这套算法没有现成的 WASM 包能直接用,于是用 Rust 手写复刻,编译成 240 行的 WASM 模块。四个步骤,每步都有一个决策点:

1. NeuQuant 神经网络学调色板。用 color_quant crate 的实现,采样因子设 10——与 pngquant 默认一致,速度比全量学习快一个数量级,256 色的代表性足够。

2. Floyd–Steinberg 蛇形扫描误差扩散。量化必然有色差,FS 算法把每个像素的误差按 7/16、5/16、3/16、1/16 扩散给右、下、左下、右下邻域,渐变区域的色带就被噪点掩盖了。两个细节:蛇形扫描(偶数行从左到右、奇数行从右到左,误差核镜像)比单向扫少一条"纹理断层";抖动强度压到 0.65 而不是教科书式的 1.0——满强度抖动的噪点熵太高,反而伤害后面的 deflate 压缩,0.65 是过渡平滑与压缩率的实测平衡点。

3. 调色板频序重排。统计每个颜色的使用频次,把最高频的重排到 index 0——索引数据里重复字节序列最大化,deflate 的 LZ77 才有更多匹配机会。这步零成本,白捡几个点的压缩率。

4. 直写 PNG chunk。IHDR 标 color type 3(索引色)+ PLTE + tRNS(有半透明时)+ IDAT + IEND。行内 filtering 强制 None——索引数据本质是噪声,filter 变换只会破坏可压缩性。

双 bug:产出打不开的图片

第一版跑通时兴冲冲下载验证——图片查看器全部报"文件损坏"。用十六进制编辑器打开对比正常 PNG,两处差异:

正常: 89 50 4E 47 0D 0A 1A 0A | 00 00 00 0D 49 48 44 52 ...
产出: 00 00 00 0D 49 48 44 52 ...   ← 开头 8 字节签名没了

坑一:PNG 8 字节文件签名漏写。PNG 规范要求文件以固定的 \x89PNG\r\n\x1a\n 开头,png crate 的 Encoder 会自动写——但我当时为了控体积没用它的完整编码器,手拼 chunk 时把签名漏了。

坑二:IDAT 是裸 deflate 流。手拼时直接把 deflate 压缩结果塞进 IDAT,但 PNG 规范要求 IDAT 内是 zlib 包装流(2 字节头 + deflate + 4 字节 Adler32 校验)。解压器按 zlib 解裸流,必然报错。

两个坑同一晚修掉,产出图片全平台打开正常。教训很经典:手写二进制格式,每一层包装都要对着规范核对——PNG 的"签名 + chunk + zlib"三层结构,漏任何一层都是这个症状。

zopfli 实验:4 个百分点换 5-10 倍耗时

量化完成后,最后一步 deflate 还能再榨:zopfli 是 Google 的极致 deflate 实现,比 zlib Best 多压 3-6%。我在终段接了 zopfli,实测压缩率多约 4 个百分点,但耗时是 zlib Best 的 5-10 倍——一张 4MB 截图的量化从 800ms 变成 5 秒以上,而它只是矩阵十格中的一格。

矩阵模式下这个账很好算:九个格子几百毫秒,一个格子五秒,用户盯着最后一格转圈。浏览器端工具的速度就是功能的一部分,4 个百分点买不来 5 秒等待。revert,回 zlib Best,并在代码注释里留下这组数据防止后人(包括未来的我)手痒再试。

四、三个工程细节

SIMD 特征检测,一探一备。Squoosh 的 WebP 编码器有 SIMD 版(快 30%+)和普通版。加载时用一个 38 字节的最小 wasm 模块做能力探测:

function hasSimd() {
  return WebAssembly.validate(new Uint8Array([
    0,97,115,109,1,0,0,0, 1,5,1,96,0,1,123, 3,2,1,0, 10,10,1,8,0,65,0,253,15,253,98,11
  ]));
}

支持 SIMD 加载快版,不支持自动回落——新浏览器跑得快,老浏览器也不炸。

宽高输入绑反。自定义尺寸功能上线后发现"宽框输 123 出来 252"——两个输入框的 onChange handler 互相写反了。五分钟修复,但它提醒我:凡是成对出现的控件,绑完事件必须左右各试一遍

隐私不是承诺,是架构。页面里"图片不上传"那句话,底牌是架构:全部计算在 Worker + WASM 里完成,页面连一个能发图片的 fetch 都不存在。开发者工具 Network 面板可以当场验证。文档里写"我们承诺不上传"是 policy,代码里没有上传通道才是 architecture——后者的说服力是前者的十倍。

五、账单

最终形态:单文件组件 500 行 + Worker 编排 90 行 + Rust 量化器 240 行,依赖仅 color_quant/png/image 三个 Rust crate 和 Squoosh 的静态 wasm 产物。

变体编码器4MB 截图典型输出
WebP 75(均衡)libwebp SIMD~180KB
JPEG 85(高清)MozJPEG~450KB
PNG 索引色 256自研 NeuQuant+FS~700KB(UI 图更狠)
PNG 无损透传(zlib 已优化过,重编码只会更大)原样

最后一行是个反直觉的设计:无损 PNG 档在输入已是 PNG 且不缩放时直接原样返回。PNG 的 zlib 本来就是无损压缩过的,任何"无损重编码"都只是在赌自己的实现比上游更优——多数时候是负优化。知道什么时候什么都不做,和知道怎么做,是同一种能力。

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/155