问题的由来:图片一多,站长就失眠
个人站最难伺候的往往是图片。文字占不了多少带宽,一百篇文章的 HTML 全部加起来可能还不如十几张手机照片大。而当站点开始有配图、有封面、有截图教程之后,情况立刻变了:用户在列表页滚到底,浏览器要同时并发请求几十张缩略图;一张没压缩的原图往往三五兆,移动端弱网下要加载好几秒;图片是最容易被别人盗链的静态资源,访客还没来,你的带宽先被别人的论坛吃光。
很多站长第一反应是「上个 CDN 就好了」。CDN 确实能解决回源带宽的问题,但解决不了「一张图三兆」的问题——CDN 只是把这三兆从你的服务器搬到离用户更近的地方,用户依然要花几秒钟下载。真正的顺序应该是:先把图变小,再让浏览器少下载,最后才谈缓存和分发。
这篇文章讲一套完全在站内就能落地的图片优化方案,不需要买任何服务:统一图片的存储与命名、用 srcset 让不同屏幕拿到不同尺寸、用 Nginx 判断 Accept 头动态分发 WebP、再配一套自助的缩略图生成流程。我用这套方案把站点首屏的图片流量从 2.8MB 压到 400KB 左右,LCP 从 4.2 秒降到 1.6 秒。
第一步:先量一下现状,别凭感觉优化
动手之前先搞清楚图片到底占了多少。如果站点有 Nginx 访问日志,直接按扩展名聚合一下:
awk '{print $7}' /var/log/nginx/access.log \
| grep -iE '\.(jpg|jpeg|png|gif|webp|avif|svg)$' \
| sed -E 's/.*\.//' | tr 'A-Z' 'a-z' | sort | uniq -c | sort -rn看文件体积的话,直接在服务器上扫一遍站点目录:
# 找出最大的 20 张图
find /www/wwwroot -type f \( -iname '*.jpg' -o -iname '*.png' \) \
-printf '%s\t%p\n' | sort -rn | head -20 | awk '{printf "%.1fMB\t%s\n", $1/1048576, $2}'
# 按目录统计图片总量
du -sh /www/wwwroot/example.com/usr/uploads/*我第一次统计的时候发现一个很有意思的现象:体积最大的那批图,访问量几乎为零——它们是几年前写过、早就不再有流量的旧文章的配图,只是坏在服务器上占空间,并不影响现在的用户体验。真正影响性能的是「列表页缩略图」和「文章首图」这两类,数量少但每次访问都在加载。所以优化的优先级要按「访问量 × 单张体积」排,而不是按文件大小排。
第二步:把图片尺寸和格式的基础打对
有三件事是必须做的,而且和框架无关。
上传时就限制尺寸。大部分 CMS 都允许在上传时做压缩,但默认值往往很宽松。Typecho 有插件可以做,自己写的站就在上传接口里加一步。原则是:正文内插图最宽不超过容器宽度的两倍(Retina 用),一般 1600px 足够;封面图 1200px;列表缩略图 400px。超过这个尺寸的像素,在屏幕上根本显示不出来,纯属浪费。
统一用无损或有损但高压缩率的编码。照片类图片用 JPEG 质量 82 已经看不出差别,体积能降一半以上;截图和 UI 图用 PNG 但先过一遍 pngquant 或者 oxipng;线稿、图标、logo 一律用 SVG 或 WebP。
# 批量把 jpg 压到质量 82,顺带剥掉 EXIF
mogrify -quality 82 -strip -resize '1600x1600>' /path/to/*.jpg
# png 有损量化,视觉损失极小,体积能降 60%~70%
pngquant --quality=65-80 --ext .png --force /path/to/*.png
# 生成 webp 版本(cwebp 单张,或用 vips 批量)
for f in /path/to/*.jpg; do
cwebp -q 80 "$f" -o "${f%.jpg}.webp"
done文件名要有意义。这一点经常被忽略,但它同时影响两件事:一是 SEO,图片的 alt 和文件名是搜索引擎理解图片内容的唯一线索;二是运维,出问题时你能通过文件名一眼看出这是哪篇文章的图。IMG_20230912_113355.jpg 这种名字在硬盘上看到会让人崩溃。我给自己的规范是 文章slug-序号-描述.jpg,比如 nginx-realip-01-config.jpg。
第三步:用 srcset 让浏览器自己挑尺寸
这是性价比最高的一步,因为不需要任何服务器端逻辑,只在 HTML 上下工夫。浏览器拿到 srcset 之后,会根据自己的屏幕宽度、DPR(像素比)和网络状况,选一个最合适的候选图去下载,其他的一概不请求。
<img
src="/uploads/nginx-realip-01-config-800.jpg"
srcset="/uploads/nginx-realip-01-config-400.jpg 400w,
/uploads/nginx-realip-01-config-800.jpg 800w,
/uploads/nginx-realip-01-config-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 720px, 760px"
width="1600" height="900"
loading="lazy" decoding="async"
alt="Nginx real_ip 配置示例">这里有三个必须理解的细节。
sizes 的写法直接决定浏览器算出来的「需要多少像素」。写错的话,浏览器可能以为需要 1600px 从而下载大图,整件事就白做了。最常见的错误是把 sizes 写成固定像素而忽略了手机的 DPR:手机宽 390px、DPR 3,如果 sizes="390px",浏览器算出来需要 1170px,还是会挑 1600w 那张。用 100vw 配合媒体查询才对。
width 和 height 属性一定要写,这是防布局抖动的关键。浏览器在图片下载完成之前就知道要预留多大空间,CLS 才能是 0。如果图片是响应式的,在 CSS 里写 max-width:100%; height:auto; 即可,属性值只作为比例占位。
loading="lazy" 不要加在首屏图片上。首屏大图(通常是 LCP 元素)如果 lazy,浏览器会等到布局完成才开始加载,LCP 直接推迟几百毫秒。正确做法是首屏图用 loading="eager" fetchpriority="high",并且可以考虑 preload;首屏以下的图全部 lazy。
<link rel="preload" as="image"
href="/uploads/cover-1600.webp"
imagesrcset="/uploads/cover-800.webp 800w, /uploads/cover-1600.webp 1600w"
imagesizes="100vw" fetchpriority="high">第四步:Nginx 按 Accept 头动态分发 WebP
WebP 相比 JPEG 通常能省 25%~35%,而且主流浏览器全都支持了。但历史文章里的图片引用的是 .jpg 路径,一篇篇回去改链接不现实。最优雅的做法是:磁盘上同时放 foo.jpg 和 foo.jpg.webp,Nginx 在收到请求时检查浏览器的 Accept 头,支持 WebP 就悄悄返回 WebP 文件,URL 完全不变。
这个功能需要 ngx_http_image_filter_module 吗?不需要。只需要 map 加上 try_files,标准模块就够:
# http 段
map $http_accept $webp_suffix {
default "";
"~*image/webp" ".webp";
}
server {
location ~* \.(jpe?g|png)$ {
add_header Vary Accept;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
# 先试 webp,再试原文件
try_files $uri$webp_suffix $uri =404;
types { }
default_type image/webp;
}
}这里有两个非常容易踩的坑。第一个是 types { } 加 default_type image/webp:如果只写 try_files 不写这两行,Nginx 会根据最终文件的扩展名判断 MIME,foo.jpg.webp 的后缀其实还是 .webp,通常没问题;但如果你把文件命名成 foo.webp.jpg 之类,就会返回 image/jpeg 而内容是 WebP,浏览器可能直接渲染失败。显式写死类型最保险。
第二个是 add_header Vary Accept。这一行不能省。同一个 URL 对不同浏览器返回不同格式,如果 CDN 或者浏览器缓存不区分 Accept 头,就会出现「Chrome 缓存了 WebP,Safari 拿到 WebP 显示不了」的经典事故。加了 Vary 之后,缓存会按 Accept 分别存副本。副作用是缓存命中率下降,这是必要的代价。
怎么验证是否真的生效?两条 curl 就够了:
curl -sI -H 'Accept: image/webp' https://example.com/uploads/a.jpg | grep -i content-type
curl -sI https://example.com/uploads/a.jpg | grep -i content-type第一条应该返回 image/webp,第二条返回 image/jpeg。如果两条一样,检查三件事:webp 文件是不是真的存在于 a.jpg.webp 这个路径(注意是双后缀)、map 是不是写在 http 段而不是 server 段里、以及 location 的正则有没有被上面的正则先匹配走(Nginx 正则 location 是按文件出现顺序匹配的,先命中先赢)。
第五步:防盗链与图片安全
图片被别的站直接引用,是个人站最常遇到的带宽消耗。Nginx 的 valid_referers 可以低成本解决:
location ~* \.(jpe?g|png|gif|webp)$ {
valid_referers none blocked server_names *.example.com example.com;
if ($invalid_referer) {
return 403;
# 或者返回一张占位图:
# rewrite ^ /static/hotlink.png break;
}
}none 允许没有 Referer 的请求(用户直接输入 URL、从邮件客户端打开),blocked 允许 Referer 被防火墙剥离的请求。这两个都不加的话,会误伤一批正常用户。另外注意:Referer 是可以被伪造的,防盗链只能挡掉「随手转载」这一档,挡不住有意盗用,所以不要指望它能省下多少带宽——真正的止损手段仍然是压缩图片体积。
还有一件必须做的事:禁止在上传目录执行任何脚本。很多 CMS 被挂马都是因为有人传了一个伪装成图片的 PHP 文件,然后访问它。加一段 location 把上传目录下的脚本请求全部拒掉:
location ~* /uploads/.*\.(php|phtml|php[0-9]|jsp|asp)$ {
deny all;
}第六步:把缩略图生成做成可重复的流程
上面所有方案都依赖一件事:每张原图旁边都有对应尺寸和格式的衍生文件。手动做必然漏,正确的做法是写一个脚本,扫描上传目录,缺什么补什么,放进 cron 每十分钟跑一次。
#!/bin/bash
# /usr/local/bin/gen-derivatives.sh
SRC=/www/wwwroot/example.com/usr/uploads
cd "$SRC" || exit 1
find . -type f \( -iname '*.jpg' -o -iname '*.png' \) -mmin -15 | while read -r f; do
base="${f%.*}"
# 生成 400 / 800 / 1600 三个宽度
for w in 400 800 1600; do
out="${base}-${w}.jpg"
[ -f "$out" ] && continue
convert "$f" -resize "${w}x>" -quality 82 -strip "$out" 2>/dev/null
done
# 为每个尺寸生成 webp
for w in 400 800 1600; do
jpg="${base}-${w}.jpg"
[ -f "${jpg}.webp" ] && continue
cwebp -q 80 "$jpg" -o "${jpg}.webp" >/dev/null 2>&1
done
done用 -mmin -15 只处理最近改动过的文件,避免每次都全量扫描几十万文件。第一次上线时可以去掉这个条件跑一遍全量,之后交给增量。加上 flock 防止两次 cron 重叠:
*/10 * * * * flock -n /tmp/gen-derivatives.lock /usr/local/bin/gen-derivatives.sh效果怎么衡量
改完之后不要只看「感觉快了很多」,要有数据。我会看三个数:
一是文章页的图片总字节数。打开 Chrome DevTools 的 Network 面板,按 Img 过滤,看底部的 transferred。改造前典型值是 2~3MB,改造后应该在 300~600KB 之间,具体取决于文章里的图数。
二是 LCP。用 PageSpeed Insights 的移动端报告,或者本地用 lighthouse --preset=desktop 跑一遍。我这边从 4.2s 降到 1.6s,主要贡献来自首屏大图换成 WebP 并预加载。
三是 CDN 或者服务器上的图片带宽占比。Nginx 日志里按字节求和,图片类的占比应该明显下降。这个数值对成本影响最直接。
还有一个小提醒:改完图片格式后,记得去 Search Console 看一下图片搜索的展示量有没有异常波动。极少数情况下,把 .jpg 返回成 WebP 会让图片搜索的抓取行为短期变化,一两天内会稳定下来,不需要紧张。
小结
图片优化的顺序不能乱:先压体积(尺寸 + 编码),再让浏览器少下载(srcset + lazy + 正确的优先级),再谈分发(WebP 协商 + 长缓存 + 防盗链),最后用脚本把这一切固定成流程。跳过前三步直接上 CDN,等于把一个 3MB 的问题搬得更远一点而已。这套方案全部基于 Nginx 标准模块和几个开源命令行工具,不需要改动程序代码,也不依赖任何外部服务,对个人站来说是最划算的一条路。