前言:上篇解决了"怎么配",这篇解决"配完之后的怪事"
上一篇记录了如何用 Apache 重写规则,让全站图片自动上 WebP,文章正文一个字不改。
规则配完、验证通过后,本来以为收工了。结果过了几天在实际浏览时发现一个说不通的现象:
- 用无痕窗口打开某篇文章,图片链接是
photo.jpg - 按 Ctrl+F5 硬刷新之后,同一张图的链接变成了
photo.jpg.webp
同一个页面、同一张图,两种结果。
更让人困惑的是:后台缓存清了不下十次,现象依旧。当时第一反应是怀疑 .htaccess 规则写错了,或者是缓存插件没清干净。
但这两个猜测都是错的。真正的答案藏在"谁动了我的 HTML"这个问题里。本文完整记录排查过程、根因分析、解决方案,以及一套可以直接复用的验证脚本。
适用环境:WordPress + Apache + cwebp 预转换 + 页面缓存插件(本文以 Cache Enabler 为例,其他带 WebP 选项的缓存插件同理)。
一、先排除一个错误直觉:不是 .htaccess 干的
排查的第一步,是先确认我们自己的规则不可能造成这种现象。
回顾一下 Apache 重写方案的工作原理:
- 浏览器请求
demo.jpg,请求头带Accept: image/webp - Apache 在服务器内部把要读取的文件换成
demo.jpg.webp - 响应头改成
Content-Type: image/webp
这里有个关键点:
内部重写不改变 URL。
浏览器地址栏、HTML 源码、Network 面板里看到的名称,永远都是 demo.jpg。后缀变化只发生在浏览器看不到的服务器内部。
所以——只要你在 HTML 源码里看到了 .jpg.webp 这个后缀,就百分之百可以断定:有人在 HTML 层面把链接字符串改写了,跟重写规则没有任何关系。
这一条判断,是整个排查的突破口。
二、关键实验:让 curl 模拟浏览器
既然现象跟"是否无痕、是否硬刷新"有关,那很可能跟请求头有关。用 curl 直接验证。
第一次测试(不带头,血的教训)
curl -s https://www.example.com/demo-article.html | findstr /i "photo"
结果:
<img src="https://www.example.com/static/photo.jpg" alt="..." />
看到 .jpg,差点就下结论"HTML 是干净的,问题在别处"。
但这个测试是错的。
为什么第一次的结论不成立
curl 默认发送的请求头是:
Accept: */*
它不包含 image/webp。而真实浏览器发的是:
Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8
缓存插件就是靠这个头判断"该给访客哪一份 HTML"。curl 没带这个头,拿到的是非 WebP 版的那份缓存。
第二次测试(带上 Accept,真相浮现)
curl -s -H "Accept: text/html,application/xhtml+xml,image/webp,*/*" ^
https://www.example.com/demo-article.html | findstr /i "photo"
结果:
<img src="https://www.example.com/static/photo.jpg.webp" alt="..." />
同一篇文章,两种不同的 HTML。实锤了。
这里要记住一个通用经验:用 curl 测试任何跟内容协商有关的机制时,必须手工补上 Accept 头,否则你测的根本不是浏览器看到的东西。这个坑在测图片、测压缩、测 WebP 时都会踩到。
三、两套机制并存:它们在做同一件事
现在答案清楚了:站点上其实有两套 WebP 方案在同时运行。
| 对比项 | Apache 内部重写 | 缓存插件改写 URL |
|---|---|---|
| 实现方式 | 服务器内部换文件 | 在 HTML 里改 src 字符串 |
| HTML 里的 URL | 始终是 .jpg,不变 | 变成 .jpg.webp |
| 覆盖范围 | 所有图片请求,含 CSS 背景图、JS 里的图 | 只能改 HTML 里的 <img> 标签 |
| 文件不存在时 | 自动回退原图(有 -f 判断) | 直接请求 .jpg.webp → 404 |
| HTML 缓存份数 | 1 份 | 2 份(普通版 + WebP 版) |
两者最终都让浏览器拿到了 WebP 图,但路径完全不同。
四、为什么清了十几次缓存都没用
这是整个排查中最反直觉的一点。
因为缓存插件本来就该生成两份。它的目录结构大致是这样:
wp-content/cache/cache-enabler/
└── 站点目录/
├── demo-article.html ← 给不支持 WebP 的浏览器
└── demo-article-webp.html ← 给支持 WebP 的浏览器
你清一次缓存,它重新生成两份;再清一次,还是两份。
所以"清缓存没用"根本不是缓存没清干净,而是——这个现象就是它的正常工作方式。你在无痕、硬刷新之间看到的差异,只是不同时机命中了不同副本而已。
经验教训:当你反复执行某个操作却总是得到同样结果时,别急着加大力度,先停下来想一想——是不是这个"异常"本来就是设计如此。
五、真正的风险:无脑追加后缀
两套并存时功能上其实没坏:浏览器请求 photo.jpg.webp,重写规则因为后缀不是 jpg/png 所以不匹配,直接把文件返回了。能正常显示,只是绕了一圈。
但这里埋着一个雷。
缓存插件改写 URL 的方式是追加 .webp 后缀,它不会去检查这个文件是否真的存在。回想一下上一篇的转换过程:
- 全站原图 1500 张
- 实际转换成功 1149 个 WebP
那剩下的三百多张(转换超时、文件损坏、新上传还没转),HTML 里照样会被改成 .jpg.webp。
结果就是:这些图片 404,永久裂图。
而重写规则里有这么一条兜底:
RewriteCond %{REQUEST_FILENAME}.webp -f
含义是"只有当 .webp 文件确实存在时才替换",不存在就自动返回原图。这就是两套方案最本质的差距:一个靠运气,一个有兜底。
六、解决方案:把改写权收回来
思路很简单——关掉缓存插件的 WebP 改写,只保留 Apache 重写这一套。
操作步骤
- WordPress 后台 → 设置 → Cache Enabler
- 在「缓存行为」区块找到这项:
☑ 创建一个缓存版本以支持 WebP。使用 Optimus 将您的图像转换为 WebP。 - 取消勾选
- 点击底部的「保存更改并清除站点缓存」(用这个按钮,一次搞定保存和清缓存)
关于选项文案的坑
这个选项是德文插件汉化过来的,措辞相当绕——"创建一个缓存版本以支持 WebP",完全不像个开关名。很多人翻遍设置页都找不到,就是因为它在描述"做什么"而不是"开关叫什么"。
另外注意后半句提到的 Optimus:这说明插件自己不转换图片,它是配合 Optimus 插件使用的。如果你像本文一样用 cwebp 自己转换,那这个选项对你只有 URL 改写这一个作用,而这件事重写规则做得更好。关掉它没有任何损失。
如果插件版本较老,没有这个选项
那就退而求其次——把缺失的 WebP 补齐,消除裂图隐患。先查出哪些图没有对应 WebP:
cd /d D:\webroot for /r %i in (*.jpg) do @if not exist "%~i.webp" echo %i
把列出的文件补转一遍即可。两套机制并存的"绕一圈"问题依然存在,但至少不会裂图。
七、验证:七项全绿才算收工
把下面的内容保存成 webp-check.bat,双击运行即可。注意里面用的是 curl.exe,原因见本节末尾。
@echo off chcp 65001 >nul set REF=Referer: https://www.example.com/ set WEBP=Accept: image/webp,*/* echo ============ WebP 最终验证 ============ echo. echo [1] 图片目录(应为 image/webp) curl.exe -s -D - -o nul -H "%REF%" -H "%WEBP%" ^ https://www.example.com/static/photo.jpg | findstr /i "content-type" echo. echo [2] 第二个图片目录(应为 image/webp) curl.exe -s -D - -o nul -H "%REF%" -H "%WEBP%" ^ https://www.example.com/img/demo.jpg | findstr /i "content-type" echo. echo [3] uploads 目录(应为 image/webp) curl.exe -s -D - -o nul -H "%REF%" -H "%WEBP%" ^ https://www.example.com/wp-content/uploads/2017/09/logo.png | findstr /i "content-type" echo. echo [4] 降级测试(应为 image/jpeg) curl.exe -s -D - -o nul -H "%REF%" -H "Accept: image/jpeg,*/*" ^ https://www.example.com/img/demo.jpg | findstr /i "content-type" echo. echo [5] HTML 里的 URL(应为 .jpg,不带 .webp) curl.exe -s -H "Accept: text/html,image/webp,*/*" ^ https://www.example.com/demo-article.html | findstr /i "photo" echo. echo [6] 缓存目录里的 webp 副本(应为空白) dir /s /b D:\webroot\wp-content\cache\cache-enabler\*-webp.html 2>nul echo. echo ============ 验证结束 ============ echo 1-3 应为 image/webp,4 为 image/jpeg,5 无 .webp,6 无输出 pause
判定标准
| 项 | 期望结果 | 说明 |
|---|---|---|
| 1~3 | Content-Type: image/webp |
三个目录的重写都生效 |
| 4 | image/jpeg |
不支持 WebP 的浏览器正常拿原图 |
| 5 | URL 是 .jpg,不带 .webp |
插件改写已关闭 |
| 6 | 无任何输出 | WebP 副本已清空,缓存体积减半 |
为什么脚本里写 curl.exe 而不是 curl
这是 Windows 上最常见的翻车点。在 PowerShell 里,curl 是 Invoke-WebRequest 的别名,不是真正的 curl。直接敲:
curl -s -D - -o nul ...
会报一堆莫名其妙的错,比如"缺少参数 SessionVariable"——因为它把 -s 当成了 PowerShell 的参数。
解决办法就是写全 curl.exe,加后缀后绕过别名,CMD 和 PowerShell 里都能正常执行。
另一个常见错误:把上一条命令的输出一起粘进了命令行,导致报"此时不应有 <"。批处理脚本能彻底避免这类问题,这也是建议写成 .bat 的原因。
八、WebP 到底有什么好处(诚实版)
折腾了这么久,值得吗?分三个层面说,不夸大。
① 省带宽——收益最大的一项
如果你的站点没有用 CDN,所有图片流量都走自家服务器,那么这一项收益是直接折算成钱的。
一个访客打开一篇带 15 张配图的文章:
| 原图 | WebP | 传输量 | 约 3.5 MB | 约 2 MB | 节省 | 约 43% |
|---|---|
传输量减半,意味着同样带宽能服务的访客数翻倍,服务器并发压力也同步下降。对一个有上千篇文章的站点,一个月省下的流量相当可观。
② 打开更快
图片通常占页面总重量的 60%~75%。图片小一半,页面加载时间大致也砍半。移动端走 4G/5G、信号不稳时,这个差异尤其明显。
③ 对 SEO:有用,但别指望它翻盘
这里必须说清楚,避免误导:
- Google:页面速度是明确的排名因素,体现在 Core Web Vitals 的 LCP(最大内容绘制)指标上。图片往往是 LCP 元素,图片小了 LCP 自然改善。
- 百度:有闪电算法,移动端首屏加载速度影响排名权重。
但它的权重很低。搜索排名的决定性因素排序大致是:
内容质量 + 外链权威 >>> 页面速度 >>> 是否用 WebP
内容不行、外链没有,用不用 WebP 都排不上去;内容好的前提下,WebP 能带来的改善大概在百分之几的边际量级。
所以正确的期待是:主要收益在用户体验和带宽成本,排名提升是附带的。
什么情况下不划算
| 情况 | 说明 | 已用 CDN | 多数 CDN 自带 WebP 转换,自己再做一遍是重复劳动 | 站点图片很少 | 收益不明显,折腾成本大于收益 | 服务器 CPU 很弱 | 如果是实时转换(每次请求现转)会拖慢服务器——但本文方案是预先转换,零实时开销 |
|---|
九、自己量出节省比例
别信估的百分比,实测最准。在 PowerShell 里跑:
$p = "D:\webroot\wp-content\uploads"
$jpg = (Get-ChildItem $p -Recurse -Include *.jpg,*.png | Measure-Object -Sum Length).Sum
$webp = (Get-ChildItem $p -Recurse -Include *.webp | Measure-Object -Sum Length).Sum
"原图总计: {0:N1} MB" -f ($jpg/1MB)
"WebP总计: {0:N1} MB" -f ($webp/1MB)
"节省比例: {0:P1}" -f (1 - $webp/$jpg)
把 $p 换成其他图片目录再跑一次。PNG 多的目录节省比例通常明显高于 JPG 目录,因为 PNG 转 WebP 的压缩空间更大(PNG 一般能小 50%~70%,JPG 只有 25%~35%)。
用 DevTools 亲眼确认
F12 打开开发者工具 → Network 面板 → 右键表头勾选「类型」列 → 刷新页面,看图片请求:
| 名称 | 状态 | 类型 | photo.jpg | 200 | webp |
|---|
名称写着 .jpg,类型却是 webp——这就是内部重写最直观的证明。点开该请求看 Response Headers,能看到 content-type: image/webp。
十、最终形态
调整完成后,整个机制变成干净的单线条:
文章 HTML:<img src="xxx.jpg"> ← 上千篇文章一个字没改
↓
浏览器请求 xxx.jpg,带 Accept: image/webp
↓
Apache 检查 xxx.jpg.webp 是否存在
存在 → 返回 WebP,响应头标 image/webp
不存在 → 返回原图 ← 自动降级,永不裂图
↓
同一 URL 按 Accept 分别缓存(Vary: Accept)
同时拿到四个好处
| 项 | 调整前 | 调整后 |
|---|---|---|
| 裂图风险 | 有(无脑追加 .webp) | 无(-f 判断兜底) |
| HTML 缓存体积 | 两份并存 | 减半 |
| 覆盖范围 | 只改 HTML 的 <img> | 所有图片,含 CSS 背景图 |
| 正文链接 | 被插件改写 | 原样保留 |
写在最后:两个通用经验
这次排查真正的价值不在"关掉一个选项",而在两条可以复用的方法论:
第一,先划清责任边界,再动手找原因。一开始怀疑 .htaccess 写错了,但其实只要记住"内部重写不改 URL"这一条铁律,就能在一分钟内排除这个可能,直接锁定真凶。很多时候排查慢,不是因为技术难,而是因为没有先把"谁不可能"排除掉。
第二,测试工具本身可能是最大的干扰源。第一次 curl 测试给出了完全错误的结论,原因仅仅是它默认不带 Accept 头。用工具验证浏览器行为时,先问一句:这个工具的默认行为,跟浏览器一样吗?
这两条经验,在排查压缩、缓存、内容协商、防盗链等所有跟"请求头"相关的问题时,都同样适用。