一、无损压缩不是「画质差不多就行」
给站点提速时,图片几乎总是最大的一块。一页 2MB 里有 1.6MB 是图片,这种比例在内容站里非常正常。压缩图片有两条完全不同的路线:
有损压缩(JPEG 质量 85、WebP quality 80)通过丢掉人眼不敏感的信息来减小体积,压缩率高但每压一次都不可逆,反复压会累积劣化。无损压缩只优化编码方式,像素值一个都不变——解压回来的图和原图逐像素相同。
无损压缩适合的场景很明确:截图、图表、带文字的示意图、需要反复二次编辑的素材、Logo 与图标、以及任何「压缩后还要进 CDN 长期分发」的图。这类图如果做有损压缩,文字边缘会糊、图表的细线会断,老板一眼就能看出来。
关键认知:PNG 的无损压缩收益,主要不在「换算法」,而在「丢掉不必要的数据」。一张从 Photoshop 导出的 PNG,可能有 8 位 alpha 通道全是 255、可能带着 ICC 配置文件和 EXIF、可能是 24 位真彩色而实际只用了 12 种颜色。这些冗余占了体积的一大半,而它们和「画质」毫无关系。
二、先把工具装齐
Debian/Ubuntu 上一条命令装完,都是命令行工具,方便脚本化:
apt install -y pngquant optipng zopflipng jpegoptim libimage-exiftool-perl webp gifsicle各自的定位先分清,用错了才叫白干:
| 工具 | 作用 | 是否无损 |
|---|---|---|
| optipng | 优化 PNG 内部结构(filter、zlib 参数) | 是 |
| zopflipng | 用 zopfli 算法重新压,比 optipng 慢但更小 | 是 |
| pngquant | 降到 256 色调色板 | 否,有损 |
| jpegoptim | 去掉 EXIF/注释,可选质量重压 | 加 --strip-all 时才部分无损 |
| exiftool | 删除元数据 | 是 |
| cwebp | 转 WebP,支持 -lossless | 可选 |
最常见的错误是把 pngquant 当无损工具用。它降到 256 色,渐变图会出现明显色带。用它之前必须自己做逐像素对比,或者干脆不用。
三、PNG 无损压缩的正确顺序
顺序很重要,因为前面的步骤会改变后面步骤的效率。正确顺序是:删元数据 → 优化编码 → 比对验证。
# 1) 先删元数据(用副本试,先备份)
mkdir -p /tmp/imgbak
cp screenshot.png /tmp/imgbak/
exiftool -all= -overwrite_original screenshot.png
# 2) optipng -o3 是速度/体积的平衡点,-o7 慢很多但收益很小
optipng -o3 -strip all screenshot.png
# 3) 想再小一点,用 zopflipng(慢,适合一次性批量跑)
zopflipng -y --lossy_transparent screenshot.png screenshot.png-strip all 会去掉 PNG 里的所有元数据块,包括时间戳、软件名、ICC 配置文件。这里有一个必须提醒的坑:带 ICC 配置文件的图去掉之后,颜色可能变。宽色域(Display P3)截图在 sRGB 屏幕上会显得发灰或过饱和。判断方法是用 exiftool file.png | grep -i icc 看有没有 ICC 块;如果有,先确认它是不是 sRGB,是的话删掉无影响,不是的话保守一点保留。
关于 --lossy_transparent:zopflipng 的这个选项会把完全透明的像素的 RGB 值清零。透明像素的颜色本来就不会显示,所以视觉上无损——但它确实改了像素数据,某些需要「透明区带颜色信息」的特殊用法(比如做遮罩)会受影响。日常截图随便用。
四、批量脚本与「压缩前先备份」的硬规矩
批量处理一定要写脚本,但必须先备份、必须记录前后体积。我用的脚本大致是这样:
#!/bin/bash
set -u
SRC="$1"
BAK="/tmp/imgbak_$(date +%s)"
mkdir -p "$BAK"
before=0; after=0; n=0
while IFS= read -r -d '' f; do
cp "$f" "$BAK/" || exit 1
b=$(stat -c%s "$f")
exiftool -all= -overwrite_original -q "$f"
optipng -o3 -strip all -quiet "$f"
a=$(stat -c%s "$f")
before=$((before+b)); after=$((after+a)); n=$((n+1))
echo "$f $b -> $a"
done < <(find "$SRC" -type f -name '*.png' -print0)
echo "files=$n before=$before after=$after"
echo "backup=$BAK"几个细节:用 find -print0 加 read -d '',避免文件名里有空格时出错——中文文件名在服务器上非常常见。set -u 让未定义变量直接报错,比默默跑完安全。-quiet 让输出只留自己打印的行,方便后面 diff。
硬规矩:压缩后的图必须做一次逐像素比对。无损压缩出错的情况不多,但一旦出错(比如工具 bug、磁盘满导致半写),损失是静默的。比对方法:
compare -metric AE original.png compressed.png null: 2>&1
# 输出 0 表示完全一致;非 0 就是像素有差异compare 来自 ImageMagick。几百张图跑一遍也就几十秒,值得。
五、JPEG 那边的情况完全不同
JPEG 本身就是有损格式,所以「无损压缩 JPEG」只能做两件事:删元数据、无损重排(optimize Huffman 表)。
# 只删元数据 + 无损优化,不动质量
jpegoptim --strip-all --all-progressive photo.jpg
# 加 --max=85 就是有损重压了,注意
jpegoptim --strip-all --max=85 photo.jpg--all-progressive(渐进式 JPEG)只改变加载顺序,文件往往更小且首屏有模糊预览,对 LCP 感知有正面作用。这条几乎总是该加。
收益预期要现实:一张 300KB 的 JPEG,删掉 EXIF 加无损重排,通常只省 5~15%。想要省 50%,只能降低质量或换格式。这就是为什么「图片优化」的答案通常不是「压」,而是「换格式」。
六、WebP 无损:什么时候值得换
WebP 的无损模式对 PNG 类图像(截图、图表)通常能小 20~40%,对照片则几乎没优势。转换命令:
cwebp -lossless -z 9 -mt screenshot.png -o screenshot.webp-z 9 是最强压缩档,慢,适合一次性批量;-mt 开多线程。-q 100 在无损模式下无意义,别混用。
上线前必须处理两件事:
- 浏览器兼容:用
<picture>提供回退,而不是直接换扩展名。<source type="image/webp">加<img>兜底。 - CDN 缓存被污染:如果你让 Nginx 按
Accept头自动返回 WebP,必须加Vary: Accept,否则中间缓存会把 WebP 返回给不支持它的客户端。这个事故我踩过,现象是「部分用户看到裂图」。
location ~* \.(png|jpg)$ {
add_header Vary Accept;
# 其他配置省略
}七、字体文件也能无损瘦身
中文字体动辄 5~10MB,这是很多站首屏慢的隐形原因。无损方向有两条:
其一,去掉用不到的字符(子集化)。这一步严格说不是「无损」——它删了字符,只是你的内容用不到。工具是 pyftsubset(fonttools):
pip install fonttools brotli
pyftsubset MyFont.ttf \
--unicodes="U+0020-007E,U+4E00-9FFF" \
--flavor=woff2 --output-file=MyFont-subset.woff2常见做法是把站内实际用到的字符(正文 + 标题)抓出来生成字符表,体积能降到原来的 10% 以内。风险是动态内容(用户评论、后台新发的文章)里会出现缺失字符,于是字体回退,视觉不一致。安全做法:常用字表用全量 CJK 区间(U+4E00-9FFF 约 2 万字,子集后约 1~2MB),或者干脆只对西文/数字做自定义字体,中文走系统字体。
其二,转换格式到 WOFF2。同样一份字体,TTF 转 WOFF2(Brotli 压缩)体积通常小 30~50%,这是真正的无损转换。--flavor=woff2 就是干这个。
顺带一个常被忽略的收益:WOFF2 不需要额外的 gzip。如果你在 Nginx 里对字体再压一次,是白烧 CPU,正确做法是让 .woff2 直接长缓存且不压缩。
八、结果怎么验收,别只看「文件变小了」
压缩不是终点,必须验证三件事,缺一件就可能上了线才发现问题:
一、像素一致性。无损类操作全部跑 compare -metric AE,汇总成一行「已比对 N 张,全部为 0」。
二、页面视觉抽查。挑首页、列表页、一篇长文页,用浏览器打开对比。特别是带细线的图表、深色背景上的文字、透明 PNG 叠加在彩色背景上的效果。
三、真实传输体积的变化。用浏览器开发者工具的 Network 面板看「transferred」而不是「size」,因为开了 gzip/brotli 之后 PNG 本来就不会再被压缩(已经是压缩格式),而 HTML/CSS/JS 的数字会骗人。看一轮压缩前后整页的实际下载字节数,才是老板能感知的「快了」。
最后提醒一句:压缩脚本一定要能在生产目录之外先预演。把 /usr/uploads 整目录 rsync 到 /tmp/test 跑一遍,比对结果、抽查图片,确认无误再上生产。图片是不可再生资产,一次误操作覆盖原图,比省下的那 30% 体积贵得多。
九、动图与 GIF:该换的其实是格式
图片压缩里最容易被忽略的一类,是 GIF。很多人以为 GIF 是「无损格式」,其实它只支持 256 色,一张渐变的动态图在转成 GIF 时早就被量化过一遍了。gifsicle 能做的优化有三类:
# 只做无损优化(重排帧、去重复像素、合并相同帧)
gifsicle -O3 --lossless=50 input.gif -o out.gif
# 有损再压一档(改变色板,看情况用)
gifsicle -O3 --lossy=80 input.gif -o out.gif-O3 是最强优化等级,它会尝试多种重排方式并选最小的一个。只加 -O3 完全无损,通常能省 10~30%,尤其是那些「背景不变、只有一小块在动」的录屏 GIF,收益非常可观。而 --lossy 会改变色板,视觉效果要自己盯着看。
但真正值得做的判断是:这个 GIF 是不是该换成 WebP 或 MP4?一段 10 秒的录屏 GIF 往往有 8MB,转成同画质的 WebP 动图能到 1MB 以内,转成 MP4/H.264 能到 300KB 以内。浏览器对动图 WebP 的支持已经很完整,唯一要处理的是老版本 Safari。判断标准很简单:
- 需要透明背景的动图:动画 WebP 是唯一好选择,GIF 换掉,别折腾 APNG(体积比 WebP 大很多)。
- 不透明的屏幕录制:用
<video autoplay muted loop playsinline>播 MP4,体积最小、还能让用户暂停。 - 一两帧的「动图」:那根本不是动图,直接换成静态图,省 99% 体积。
转 MP4 时用 ffmpeg -i in.gif -movflags +faststart -pix_fmt yuv420p -crf 28 out.mp4,+faststart 让元数据前置以支持边下边播,yuv420p 保证所有浏览器都能解码。这两个参数漏掉任一,都会出现「本地好好的,线上某些浏览器黑屏」。
十、让压缩结果长期有效:目录约定与体积预算
压缩做一次,新上传的图片还会把体积涨回来。真正有效的做法是给上传目录定一条固定的处理链,而不是靠人记得手动跑脚本。
我的约定是三层目录:
/data/uploads/original/ # 原图,永不修改,不进 CDN
/data/uploads/optimized/ # 无损压缩后的图,供页面引用
/data/uploads/webp/ # 有损/无损 WebP,供 picture 引用上传时原图进 original,一个监听任务(inotify 或定时扫描)负责生成 optimized 与 webp。页面只引用后两层。这样即使哪天换了压缩参数,也可以从原图重跑一遍,永远不会因为「压过头了」而无法回头。
然后定一条体积极限,用一条命令就能巡检:
# 列出优化目录里超过 300KB 的图,这些是要人工看的
find /data/uploads/optimized -type f -size +300k -printf '%s %p\n' | sort -rn | head -30超过 300KB 的 PNG 截图,通常还有很大空间;超过 500KB 的 JPEG,八成是忘了开渐进式和删元数据。每月跑一次这条命令,比装任何监控都直接——它给出的是一份「具体该处理哪几张图」的清单,而不是一个抽象的分数。
最后补一条关于 CDN 的经验:图片压缩上线后,记得清一次 CDN 缓存并确认源站返回的 Content-Length 已经是新值。我见过「文件换了但 CDN 还按旧的 ETag 返回旧文件」的情况,也会反过来:文件变小了,但 CDN 上缓存的边沿节点还是旧的,浏览器拿到的还是大图,于是「压了没效果」。用 curl -sI 直接看响应头里的长度,一眼就能分辨是压缩没生效,还是缓存没刷新。