图片这块最常见的顺序错误是:先装一个自动转 WebP 的东西,再挂一个懒加载,最后发现首屏那张图还是慢。打开 DevTools 的 Network 面板按 Img 过滤,会看到手机实际下载的是两千多像素宽的 jpeg——格式换了,尺寸没换,省下的那点字节还不够抵消多下载的像素。
下面这个顺序是按依赖关系排的:每一步的产出是下一步的输入,倒过来做就要返工。
一、先定尺寸档:页面显示多宽,就准备几档
在压缩之前先回答一个问题:这张图在页面上最宽显示多少像素?
- 正文内容图:内容列宽度(假设 760px)× 你愿意照顾到的 DPR(2 已经够用,3 的收益很小)
- 全宽 banner:视口上限(假设 1600px)× 2
- 列表卡片:按卡片实际宽度算,不要按容器宽度算
WordPress 里用 add_image_size 注册这些档位,只注册真正会用到的:
add_action( 'after_setup_theme', function () {
add_image_size( 'content-1x', 760, 0, false );
add_image_size( 'content-2x', 1520, 0, false );
add_image_size( 'hero-1x', 1600, 0, false );
add_image_size( 'hero-2x', 3200, 0, false );
add_image_size( 'card', 640, 400, true ); // 固定裁切,列表高度才稳定
} );
add_image_size 只对之后上传的图生效,已经存在的图要跑一次重新生成缩略图。上传前把图裁到目标尺寸更省事:一张 4000px 的原图丢进去生成五档,多出来的文件都是废的。
还要顺手看一下超大图的阈值。默认会把这个尺寸以上的上传等比缩小,如果 2x 档需要 3200px,阈值不够高的话那张 2x 图根本生成不出来:
add_filter( 'big_image_size_threshold', function () {
return 3200;
} );
这一步的验收标准很直白:媒体库里随便点开一张图,能看到你注册的那几档尺寸,并且都不是原图等比缩放出来的意外产物。
二、再做格式:WebP 解决的是“同尺寸下更小”
尺寸档定好之后再转格式,收益才算得清:每个档位一份 WebP,而不是十几个尺寸各自两套。
批量转换用 cwebp 走一遍上传目录,转换后的文件放在原图旁边,同名不同后缀:
#!/usr/bin/env bash
set -euo pipefail
root="${1:?用法: ./to-webp.sh wp-content/uploads}"
find "$root" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) -print0 |
while IFS= read -r -d '' src; do
out="${src%.*}.webp"
[ -f "$out" ] && continue
cwebp -quiet -q 82 "$src" -o "$out"
done
- 带透明通道的 PNG 不用特殊处理,cwebp 会保留 alpha。
- 依赖 EXIF 方向信息的照片,转完抽几张用看图工具确认方向是对的。
- 纯色 logo、界面截图这类图,WebP 有损压到 82 反而会出现色块,留在 PNG 里通常更划算。别对上传目录统一处理完就不看了。
接下来让服务端按 Accept 头决定发哪个文件,这比批量改 HTML 里的 <img> 简单得多:
map $http_accept $webp_suffix {
default "";
"~*image/webp" ".webp";
}
server {
# 上传目录里的 jpg/png:有同名 .webp 且客户端支持,就发 webp
location ~* ^/wp-content/uploads/.+\.(?:jpe?g|png)$ {
add_header Vary Accept;
try_files $uri$webp_suffix $uri =404;
expires 30d;
access_log off;
}
}
两个细节别漏:
add_header Vary Accept;不能省。少了它,中间的缓存层可能把 WebP 存下来再发给不支持 WebP 的客户端。- 如果站前面还有一层 CDN,要么在 CDN 侧做同样的规则,要么确认回源请求里
Accept头是透传的。很多“转换没生效”最后查出来是这一层把请求头吃了。
验证一下返回的类型对不对:
curl -sI -H 'Accept: image/webp' https://example.com/wp-content/uploads/2026/11/card-640.jpg | grep -i content-type
curl -sI https://example.com/wp-content/uploads/2026/11/card-640.jpg | grep -i content-type
两次应该分别返回 image/webp 和 image/jpeg。
三、然后是 srcset 和 sizes:让浏览器自己挑
档位和格式都就位了,才轮到浏览器选哪一个。WordPress 默认会输出 srcset,但 sizes 是按内容宽度推的,主题改过布局就对不上,浏览器于是按错误的宽度挑图——常见结果是手机上下了 2x 那张。
按实际布局改写:
add_filter( 'wp_calculate_image_sizes', function ( $sizes, $size, $url, $meta, $attachment_id ) {
if ( ! empty( $size[0] ) && $size[0] >= 1500 ) {
return '(max-width: 1600px) 100vw, 1600px'; // 全宽图
}
return '(max-width: 820px) 100vw, 760px'; // 内容图
}, 10, 5 );
改完在 Console 里跑一段,看浏览器实际挑的是哪一档:
document.querySelectorAll( 'img' ).forEach( ( img ) => {
if ( ! img.currentSrc ) return;
console.log(
img.currentSrc.split( '/' ).pop(),
'naturalWidth', img.naturalWidth,
'clientWidth', img.clientWidth,
'DPR', devicePixelRatio
);
} );
合理的输出是 naturalWidth 接近 clientWidth × DPR,而不是固定等于 2x 档。
手写 <img> 的地方也要把宽高写全,否则图片加载完页面会跳一下:
<img src="/uploads/card-640.jpg"
srcset="/uploads/card-640.jpg 640w, /uploads/card-1280.jpg 1280w"
sizes="(max-width: 720px) 100vw, 360px"
width="640" height="400"
alt="接线端子正面"
loading="lazy" decoding="async">
配套一行 CSS:img { max-width: 100%; height: auto; }。写了宽高属性又不给它 height:auto,图会被拉变形。
四、懒加载放最后,并且必须把首屏摘出来
懒加载改变的是“什么时候下载”,不改变“下载多大”。它放在最后做,是因为要依赖前面已经正确的 srcset:浏览器连该下哪张都算错了,懒加载只会把错的那张推迟到用户快滚到时才下。
WordPress 会把懒加载自动加到内容图里,包括首屏那张。用 class 排除是最可控的方式:
add_filter( 'wp_img_tag_add_loading_attr', function ( $value, $image, $context ) {
if ( str_contains( $image, 'skip-lazy' ) ) {
return false; // 这张图不加 loading 属性
}
return $value;
}, 10, 3 );
然后在首屏那张图上加 class="skip-lazy",并给它该有的优先级:
<img class="skip-lazy"
fetchpriority="high"
decoding="async"
src="/uploads/hero-1600.jpg"
srcset="/uploads/hero-1600.jpg 1600w, /uploads/hero-3200.jpg 3200w"
sizes="100vw"
width="1600" height="900"
alt="设备全景">
另一种写法是按渲染顺序数前 N 张,只适合快速验证:
add_filter( 'wp_img_tag_add_loading_attr', function ( $value, $image, $context ) {
static $n = 0;
$n++;
return $n <= 2 ? false : $value;
}, 10, 3 );
页面上有轮播、有区块动态插入、有多处 the_content() 调用时,这个计数会飘,别留在正式环境。
还有两个容易忽略的点:
- 首屏如果有 CSS 背景图,它既不会被懒加载,也不会被
srcset优化,还会挡住 LCP 的计算优化。想优化就换成<img>加object-fit: cover。 - 用 JS 库实现懒加载时,禁用 JavaScript 刷新一遍,如果有图不显示,说明它在拿可访问性换体积。优先用浏览器原生的
loading="lazy"。
五、最后才动缓存和 CDN
到这一步文件名和尺寸才固定下来,缓存规则才有意义。两个坑:
- 图片内容换了但文件名没换,边缘节点按 URL 缓存就会继续发旧图。改图时换文件名,或者在 URL 里带上内容指纹。
Vary: Accept的规则要和 CDN 的缓存键对齐。缓存键里不含Accept、又开了格式自动转换,就会出现“缓存里存的是 WebP,发给了不支持的客户端”。
验收:用真实宽度走一遍
- Network 面板按 Img 过滤,清空缓存后刷新,看格式、实际像素宽度、单页图片总字节三个数。
- 跑上面那段 Console 脚本,确认每张图挑的档位合理;把 DPR 手动调成 2 再看一遍,应该换成 2x 文件,而不是靠 CSS 放大。
- 首屏那张 LCP 图:没有
loading="lazy",有fetchpriority="high",尺寸对。 - 禁用 JavaScript 刷新:图应该还在。有图不显示,就是某个 JS 懒加载还在管事。
顺序上就这五步:尺寸 → 格式 → srcset/sizes → 懒加载 → 缓存。中间任何一步提前做,后面的验证都会被前面的错误掩盖。
页面本身的图处理完之后,如果还要给搜索引擎和 AI 读的东西(结构化数据、sitemap、llms.txt)还没理,AumViso 是免费的:https://aumcreate.com/plugins/aumviso?utm_source=aliyun&utm_medium=content&utm_campaign=aliyun-image-webp-lazy-load-order