一、图片从来不是"压缩一下就好"
内容站最矛盾的一件事是:明明文字只有几千字,首屏却要加载两兆图片。编辑从网络上收图、从手机截图、从设计稿导出,尺寸从 400 像素到 4000 像素不等,格式有 PNG、JPEG、WebP。这些图直接上传,然后指望服务器开个 gzip 或者前端加个懒加载就完事——这是大多数站点首屏慢的根本原因。
我这边的实测数据是这样:一个图文列表页原本 11 张图、总计 3.2MB,加懒加载之后首屏只加载 4 张,仍有 1.9MB,最大的那一张单图 3.1MB,是手机直出的 4032 像素宽照片。把它按需缩到 800 像素宽并转成 WebP,体积降到 96KB——不是优化了百分之三十,而是优化了百分之九十七。
所以图片优化的核心不是"调质量参数",而是在上传那一刻就把图片整理成它实际显示的尺寸。这篇文章讲一条可以自动跑的管线:原始图入库 → 生成多种尺寸 → 按浏览器能力分发 → 清理孤儿文件。落点在 Nginx 与 shell 脚本,不依赖任何前端框架。
二、先量化:一张图到底该多大
决定尺寸前先确定三件事:显示宽度、像素密度、可接受的画质。现代手机普遍是二倍屏甚至三倍屏,所以"显示 400 像素宽"的位置实际需要 800 像素甚至 1200 像素的图。盲目只给一张大图,等于让所有用户在手机上下载桌面尺寸资源;盲目只给一张小图,等于在高分屏上糊成一片。
实践中的档位建议固定为四档,覆盖绝大多数内容站场景:
- 320 宽:小缩略图、侧栏、移动端列表。
- 800 宽:正文内插图、列表卡片、手机全宽。
- 1280 宽:桌面端正文大图、文章头图。
- 1920 宽:整屏横幅,只有极少数页面需要。
四档之外不加档,是为了控制文件数量。多余的档位不会让画质更好,只会让磁盘和备份变复杂。
质量参数上,WebP 用 75 到 80 足够,JPEG 用 80 到 85。低于 70 时文字边缘和大面积渐变会出现明显块状;高于 90 时体积翻倍而肉眼几乎无感。这两个区间是反复对比后的经验值,可以放心作为默认。
三、处理管线:一条命令处理整批
管线用的是 ImageMagick 或 libvips 的工具。libvips 的 vipsthumbnail 内存占用更低、更适合批量,写进脚本更省事:
#!/bin/bash
# img_pipeline.sh 用法: ./img_pipeline.sh 原图目录 输出目录
set -euo pipefail
src="$1"; out="$2"
mkdir -p "$out"
for f in "$src"/*.{jpg,jpeg,png,JPG,PNG}; do
[ -e "$f" ] || continue
base=$(basename "${f%.*}")
# 先修正 EXIF 方向,再缩放,否则手机图会躺倒
for w in 320 800 1280; do
vipsthumbnail "$f" --size "${w}x" --rotate \
-o "$out/${base}-${w}.webp[Q=78,strip]"
vipsthumbnail "$f" --size "${w}x" --rotate \
-o "$out/${base}-${w}.jpg[Q=82,strip,optimize-coding]"
done
done三个细节决定成败。第一是 --rotate,手机照片的方向信息在 EXIF 里,缩放工具若不读 EXIF,图会横过来,而你在本地看图软件里是正常的——这个坑几乎每个人都会踩一次。第二是 strip,去掉 EXIF、ICC、缩略图缓存,一张照片的 EXIF 有时就有几十 KB,还可能包含拍摄地坐标,属于该丢的数据。第三是 --size 800x 里的 x 表示"宽度 800,高度按比例",不要写成 800x800,那会强制裁剪成方形。
生成两份格式(WebP 与 JPEG)是有意为之。WebP 在同等画质下通常小 25% 到 35%,但老设备和某些爬虫仍需要 JPEG 兜底。
四、按浏览器能力分发
有两种分发方式,各有适用场景。第一种是 HTML 层的 picture,让浏览器自己选:
<picture>
<source type="image/webp"
srcset="/img/post-42-320.webp 320w,
/img/post-42-800.webp 800w,
/img/post-42-1280.webp 1280w"
sizes="(max-width: 600px) 100vw, 800px">
<img src="/img/post-42-800.jpg"
srcset="/img/post-42-320.jpg 320w,
/img/post-42-800.jpg 800w,
/img/post-42-1280.jpg 1280w"
sizes="(max-width: 600px) 100vw, 800px"
width="1280" height="720" alt="示意图" loading="lazy" decoding="async">
</picture>sizes 必须写,而且要和 CSS 里图片的实际宽度对得上。写错 sizes 会让浏览器选错档位,常见错误是 CSS 里把图限制在 800 像素而 sizes 写 100vw,结果手机上下载了 1280 档。
width 与 height 也别省。它们让浏览器提前算出占位高度,避免图片加载完成后页面往下跳,这对累积布局偏移指标影响极大。哪怕响应式布局里最终宽度会变,只要写的是原始宽高比,浏览器就能算出正确比例。
第二种是 Nginx 层按 Accept 头动态分发,适合图片由 CMS 统一输出、改模板成本高的站点:
map $http_accept $img_ext {
default jpg;
"~*image/webp" webp;
"~*image/avif" avif;
}
location ~ ^/img/(.+)\.(jpg|jpeg|png)$ {
add_header Vary Accept;
try_files /img/$1.$img_ext /img/$1.jpg =404;
}这里 add_header Vary Accept 是必须的。没有它,中间的 CDN 或反向代理会把 WebP 版本的响应缓存起来,然后发给不支持 WebP 的客户端,造成图片在某些设备上全部裂开。这类事故很难复现,因为本地测试时缓存是空的。如果你的站点前面挂着 CDN,一定要确认 CDN 也尊重 Vary 头,或者在缓存键里显式加入格式维度。
五、缓存头与回源成本
图片是静态资源里最适合长缓存的东西,因为文件名里可以带内容指纹,内容变了文件名就变。推荐的策略:
location ~* \.(webp|jpg|jpeg|png|avif)$ {
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}immutable 让浏览器在刷新时也不去校验,省掉一次条件请求。access_log off 对图片目录可以显著减小日志体积——一天十万次图片请求的日志能占几百兆,而它们对排查问题的价值极低。
但要注意:如果文件名不带指纹(直接覆盖同名文件),那么 immutable 会让你改了图之后用户一年内看不到新图。这种情况下必须改用带指纹的命名,比如在文件名里拼上内容的短哈希,这也是上面管线建议固定后缀的原因——后缀之外再加哈希即可。
回源成本方面,缩略图目录一旦被 CDN 缓存,回源压力主要集中在长尾图片。建议对图片目录开 open_file_cache,并在磁盘上使用单独的分区或对象存储,避免图片写入把数据库的 IO 抢走。
六、清理与一致性检查
管线跑久了会产生两类垃圾:原图上传后被替换,旧尺寸文件留在磁盘上成了孤儿;数据库里记录了一张图,但磁盘上文件已经丢了。两类都要定期扫。
# 找出超过 90 天没被访问的生成文件(需 access_log 或 atime)
find /data/img -type f -name '*-*.webp' -atime +90 -print | head -50
# 找出库里没有引用的孤儿图(对比文章表里出现的文件名)
grep -rhoE '/img/[A-Za-z0-9_-]+\.(webp|jpg)' /var/www/site/posts \
| sort -u > /tmp/used.txt
find /data/img -type f -printf '/img/%f\n' | sort -u > /tmp/all.txt
comm -13 /tmp/used.txt /tmp/all.txt | wc -l注意 comm 要求两个文件都排好序,这是它最常见的误用点——不排序会得到一堆错误的差集。清理动作一定要先输出清单人工抽查,再删除。我见过一次误删,原因是扫描脚本匹配路径写错,把所有正文图都判成了孤儿。
一致性检查的正确姿势是"两边都数、随机抽十张打开"。数量差可以容忍少量孤儿,因为生成冗余不影响线上;但库里引用而磁盘缺失的情况必须在发布前拦住,否则用户看到的是裂图。
七、七个真实踩过的坑
坑一:只转格式不缩尺寸。把 3000 像素的 JPEG 转成 WebP,体积只降三成,而缩到实际显示尺寸能降九成以上。尺寸永远优先于格式。
坑二:忽略 EXIF 方向。竖拍手机照片在网页上躺着。修法是在管线里强制旋转并 strip EXIF。
坑三:把 GIF 原样转换。动图转 WebP 会变成静态第一帧。动图要么保留 GIF,要么转成视频元素,不要在管线里盲转。
坑四:透明 PNG 转 JPEG。透明区域会变成黑色或白色。带透明的图只能转 WebP 或 AVIF。
坑五:懒加载放在首屏图上。首屏大图加 loading="lazy" 会推迟关键渲染,反而拉高最大内容绘制时间。首屏图应该用 fetchpriority="high" 并去掉 lazy。
坑六:忘了 Vary 头。CDN 缓存串格式,部分浏览器裂图。见上一节。
坑七:在请求时实时缩放。用 PHP 或 Nginx 模块在用户请求时生成缩略图,第一次访问极慢,并发一高就把 CPU 打满。缩放必须离线做,请求路径上只做文件查找。
八、小结:把规范写进管线
图片优化最容易犯的错是把它当成一次性任务:某天花两小时把历史图压缩一遍,然后新图继续按老方式上传,三个月后体积又回来了。真正有效的做法是把规范固化成管线——编辑只管上传原图,尺寸、格式、命名、清理全部由脚本完成,人不需要记住任何参数。
优先级很清楚:先缩尺寸,再转格式,然后配长缓存,最后加按能力分发。前两步做完通常就能拿到八成收益,后两步是锦上添花且有一定风险(Vary 与缓存键),赶时间可以先只做前两步,但分发与缓存一旦要做就必须做对。