摘要:「上传图片自动转 WebP」是同一类需求里实现最短、坑却最集中的模块:真正转格式那几行 GD 代码谁都写得出,决定成败的是它周围的三件事 —— 路径怎么算、怎么判断「能转」、失败时有没有人知道。这三件事的共同点是:出错时完全没有异常。页面正常、上传正常、「上传成功」的提示照常显示,唯一的现象是图片体积没变;而你登录后台在自己电脑上看,又一切看起来都是对的(原因见坑②)。本文拆五个坑:最经典的一个是 str_replace( baseurl, basedir ) 路径映射 —— 它在只有一种域名、协议一致的站上永远正确,所以在开发机测不出来,却会在上了 CDN / 多域名 / http-https 混用的真实站点上静默失效。附零依赖交付代码与 185 项离线单测。
一、为什么这个功能”特别难发现自己坏了”
先看它的验收条件,几乎只有一句话:「上传一张 JPG,磁盘上多一个 .webp,前台 src 指向它」。这句话本身没错,但它有一个致命的性质:它的三个环节全都可以”看似成功”。
| 环节 | 失败了会怎样 | 你能看到吗 |
|---|---|---|
| 上传时转换 | 上传照样成功($file 原样返回) | 看不到 —— 这正是设计目标:转换不该阻断上传 |
| 展示层换 src | URL 不动,继续用原图 | 看不到 —— 页面完全正常,只是文件大一点 |
| 浏览器是否支持 | 继续用原图 | 看不到 —— 而且你自己大概率用的是 Chrome |
更麻烦的是第二行和第三行的叠加效应:如果你用 Chrome(支持 WebP)在开发机上测,两条路都通;一旦站点上了 CDN,第二条路断掉,而第三条路仍然是通的 —— 于是你会看到「前台图片正常显示」,得出”功能正常”的结论,而实际上一个字都没生效。
这类模块的正确验收方式不是”页面看起来对不对”,而是「磁盘上有没有那个文件」+「
src到底指向谁」 —— 两个都能在命令行里断言,不该靠肉眼。
下面五个坑,前两个是”根本没生效”,第三个是”生效了但把上传搞挂”,第四个是”坏了没人知道”,第五个是”为了省空间把原图删了”。
二、坑①:str_replace( baseurl, basedir ) —— 最经典的静默失效
转换本身很简单,真正需要动脑的是展示层:WordPress 的 wp_get_attachment_image_src 过滤器拿到的是一个 URL,而你要判断的却是「同名 .webp 文件存不存在」—— 必须是磁盘路径。
于是就有了这个几乎人人写过的映射:
<?php
add_filter( 'wp_get_attachment_image_src', function ( $image ) {
if ( ! $image || empty( $image[0] ) ) {
return $image;
}
$url = $image[0];
if ( ! preg_match( '/.(jpg|jpeg|png)$/i', $url ) ) {
return $image;
}
$upload_dir = wp_upload_dir();
$webp_url = preg_replace( '/.(jpg|jpeg|png)$/i', '.webp', $url );
// ★ 把 URL 换回磁盘路径:baseurl -> basedir
$webp_path = str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $webp_url );
if ( file_exists( $webp_path ) ) {
$image[0] = $webp_url;
}
return $image;
}, 20 );
这段代码在开发机上永远是对的:单一域名、同一个协议,baseurl 是图片 URL 的前缀,str_replace 精准命中。问题在于它依赖了一个没写在代码里的前提:「图片 URL 一定以 baseurl 开头」。
两类真实环境会让它静默失效
- CDN / 多域名:站点启用 CDN 后
$image[0]是https://cdn.example.net/wp-content/uploads/…,而baseurl是https://example.com/wp-content/uploads→str_replace找不到目标字符串,$webp_path原封不动等于那个 URL。 - 协议不一致:站点
baseurl存的是http://,而页面在 HTTPS 下输出https://的图片 URL(或反之)→ 同样匹配不上。
两种情况下 file_exists() 都只是安静地返回 false:没有 warning、没有日志、没有异常。表现只有一个 —— 「WebP 没生效」。而你去看代码,它”看起来完全正确”。
这就是”静默失效”的定义:代码在错误的前提下运行,而且没人告诉它前提不成立。
str_replace找不到匹配时不会报错 —— 它只是把原字符串还给你。
正解:用附件的真实路径推导,完全不碰 URL 前缀
关键在于这个过滤器本来就给了你更好的信息:第 2 个参数就是 $attachment_id。而 WordPress 记附件路径用的是 _wp_attached_file 元数据 —— 它和”URL 怎么生成”完全无关,存在数据库里,不受 CDN 与协议影响。
<?php
function zdw_webp_local_path( $url, $attachment_id = 0 ) {
$clean = preg_replace( '/[?#].*$/', '', (string) $url ); // 先剥查询串/片段
$base = wp_basename( $clean );
$webp = preg_replace( '/.(jpg|jpeg|png)$/i', '.webp', $base );
if ( ! $webp || $webp === $base ) {
return ''; // 扩展名不是 jpg/jpeg/png
}
// ① 优先:附件真实路径所在目录(与协议 / CDN 无关)
if ( $attachment_id ) {
$attached = get_attached_file( (int) $attachment_id );
if ( $attached ) {
$cand = dirname( (string) $attached ) . '/' . $webp;
if ( file_exists( $cand ) ) {
return $cand;
}
}
}
// ② 回落:老办法(CDN / 协议不一致时会失效,仅作兜底)
$dirs = zdw_webp_upload_dirs();
if ( '' !== $dirs['baseurl'] && 0 === strpos( $clean, $dirs['baseurl'] ) ) {
$cand = str_replace( $dirs['baseurl'], $dirs['basedir'],
preg_replace( '/.(jpg|jpeg|png)$/i', '.webp', $clean ) );
if ( file_exists( $cand ) ) {
return $cand;
}
}
return '';
}
思路的转变是:URL 里唯一真正需要的信息是「文件名」,目录不要从 URL 推。目录直接问 WordPress 要,它一定知道得比 str_replace 准。只要中间尺寸和原图在同一个目录(WordPress 默认如此),这个推导对任意尺寸都成立。
这里有一处细节值得注意:get_attached_file() 返回的是「原图」路径,而我们过滤的可能是 foo-300x200.jpg。dirname() 取目录后拼上当前 URL 的 basename,两者自然对齐 —— 这比”从原图路径推中间尺寸”更稳,因为中间尺寸的命名规则未必是 -300x200。
三、坑②:能力判定只看 function_exists( imagewebp )
这是本文的第二个”看起来对”。绝大多数实现都是这么写的:
<?php
function my_webp_supported() {
return function_exists( 'imagewebp' ) && extension_loaded( 'gd' );
}
imagewebp() 是 PHP 编译进 GD 的函数。问题在于:某些发行版的 GD 构建里函数符号存在,但底层没有编译 WebP 编码器 —— 调用时直接返回 false。这类环境上,上面的判定会给出 true,于是:
- 每次上传都走完「读文件 → 解码 → 调用 → 失败」的全流程(白烧 CPU 与内存);
- 每次失败都记一条日志(见坑④)→ 真实故障被淹没在”环境不支持”的洪流里;
- 而站长在后台勾着开关,看着一切正常。
修正只需要一层 gd_info(),成本为零:
<?php
function zdw_webp_supported() {
static $ok = null;
if ( null !== $ok ) {
return $ok;
}
$ok = false;
if ( extension_loaded( 'gd' ) && function_exists( 'imagewebp' ) ) {
$info = function_exists( 'gd_info' ) ? gd_info() : array();
// ⚠️ 老版本 GD 可能没有这个 key(是 key 不存在,不是值为 false)
// —— 此时不能因此判死,按「函数存在即支持」处理
$ok = ! array_key_exists( 'WebP Support', $info ) || ! empty( $info['WebP Support'] );
}
return $ok;
}
那句 array_key_exists 是这里最容易被写错的地方。用 ! empty( $info['WebP Support'] ) 单独判,在老版本 GD(key 根本不存在)上会得到 false → 把一台其实能转的服务器判成不能转,而且同样静默。array_key_exists 才能区分「不支持」与「无法判断」。
凡是”判定环境能力”的代码,都要问一句:我能区分「确定不支持」和「查不出来」吗? 把两者都当成”不支持”,就会在一部分合法环境上永久失效 —— 而没人会来报告这个 bug,因为现象只是”这个功能没起作用”。
四、坑③:大图不设上限 —— 上传直接 500
这个坑的性质和前两个不同:它会真的把上传搞挂。
imagecreatefromjpeg() 会把整张图解码进内存,真彩色位图的内存占用约 宽 × 高 × 4 字节。一张 8000×6000 的照片:
8000 × 6000 × 4 = 192,000,000 字节 ≈ 183 MB
而 GD 默认内存限制下这是必然 OOM。关键在于 OOM 发生在「上传请求」里 —— 用户看到的不是「图片太大」,而是「上传失败」或空白页 / 500。他只会反复重试,每次都失败。
修正:读出像素后先判上限,超限直接跳过转换,让上传本身照常成功(返回原 $file)。
<?php
$info = @getimagesize( $file_path );
if ( ! $info || empty( $info['mime'] ) ) {
return false;
}
// 像素超限直接跳过:注意「跳过」与「失败」对调用方是两件事
$pixels = (int) $info[0] * (int) $info[1];
if ( $pixels > zdw_webp_pixel_limit() ) {
return false;
}
function zdw_webp_pixel_limit() {
$limit = apply_filters( 'zdw_webp_pixel_limit', ZDW_WP_PIXEL_LIMIT );
return max( 1, (int) $limit ); // clamp:防被 filter 设成 0 导致「永远跳过」
}
默认上限 3600 万像素(≈ 6000×6000,约 144MB 峰值),既覆盖了正常摄影图,又留出安全余量。那个 max( 1, … ) 看着多余,其实是在防一类集成事故:下游有人写 add_filter( 'zdw_webp_pixel_limit', fn() => 0 )(比如想”临时禁用转换”),结果变成所有图都跳过,而没有任何提示。clamp 让”关掉”这个动作必须显式关开关,而不是靠改数字。
「因为图片太大而跳过」和「转换失败」必须是两个可区分的结果 —— 前者是预期行为,不该报警,也不该让人去重试;后者是需要处理的故障。把两者都
return false之后,调用方就没法区分了。
五、坑④:失败没有任何痕迹 —— 运营只能靠”图怎么没变”发现
前面三个坑为什么难查?因为失败了也没有记录。最朴素的实现里,转换失败就是一句 return false;,然后调用方拿到 false 也不知道该做什么。
结果是:一个站点可能几个月都没在转换,直到某天有人注意到「图片怎么都是 jpg」。那时候你既不知道从什么时候开始的,也不知道是哪一类图失败(全部?还是只有 PNG?)。
修正分两步。第一步:把”为什么失败”变成可判定的原因:
<?php
function zdw_webp_convert_reason( $file_path, $result ) {
if ( $result ) {
return 'ok';
}
if ( ! zdw_webp_supported() ) {
return 'unsupported'; // 部署问题:要去装 GD WebP
}
if ( '' === (string) $file_path || ! is_readable( (string) $file_path ) ) {
return 'unreadable'; // 权限 / 路径问题
}
$info = @getimagesize( $file_path );
if ( $info ) {
$pixels = (int) $info[0] * (int) $info[1];
if ( $pixels > zdw_webp_pixel_limit() ) {
return 'too_large'; // 预期行为:不报警
}
}
return 'failed'; // 真故障
}
第二步:只记需要人管的那些。too_large 是预期行为,不该进日志 —— 否则一次批量上传大图就会刷屏,把真正的故障淹掉。
<?php
$reason = zdw_webp_convert_reason( $file_path, false );
if ( 'too_large' !== $reason ) {
zdw_webp_log( $reason, array(
'file' => wp_basename( $file_path ),
'pixels' => $info ? ( (int) $info[0] * (int) $info[1] ) : 0,
'quality' => $quality,
'limit' => zdw_webp_pixel_limit(),
) );
}
再加一层同请求去重:一次请求内相同原因 + 相同数据只记一条。WordPress 的媒体批量上传会在一个请求里处理多张图,如果每张失败都写一条,”环境不支持”这种全局问题会在日志里出现几十次 —— 严重程度被数量稀释,反而看不清。
日志的价值不在于「记得多」,而在于「一眼能看出是哪一类问题」。把”预期内的跳过”和”需要修的故障”混在一起写,等于没写。
六、坑⑤:「保留原图」做成开关 —— 关掉就是不可逆的数据损失
这个坑和前面四个不同:它不是 bug,是一个设计决策的后果。很多类似实现里会有一个「转换后删除原图」的选项,理由是”省磁盘空间”。看它的实现:
<?php
// ❌ 不要这样做
function my_webp_convert( $file_path, $quality = 80, $keep_original = true ) {
// ... 转换 ...
if ( ! $keep_original && file_exists( $file_path ) ) {
wp_delete_file( $file_path ); // ← 永久删除原始 JPG/PNG
}
}
问题在于:这是一个不可逆动作,却被做成一个可以随手勾选的开关。而且它的”好处”(省一点空间)和”代价”(原图永远没了)完全不对等 —— 原图是唯一的高质量副本,WebP 是有损的,删掉就再也回不去了。
本交付的处理方式是把这个能力整个移除,并且把口子焊死:不只是”不提供一个开关”,而是把 $keep_original 形参从函数签名上摘掉。
<?php
// ✅ 形参里没有 $keep_original —— 恒保留原图
function zdw_webp_convert( $file_path, $quality = ZDW_WP_DEFAULT_QUALITY ) {
// ... 转换 ...
// ★ 原图永不删除
}
为什么要连形参一起摘掉?因为留着形参就等于给未来”顺手开个开关”留好了位置 —— 只要改一行 true 变 false 就能炸。摘掉之后,重新引入这个能力必须改函数签名,调用点会全部报参数错误 —— 改动显眼到无法”顺手”完成。
副作用(用户最常问的那个问题)
因为原图保留,转换后磁盘上会同时存在 foo.jpg 与 foo.webp。而媒体库里的附件(_wp_attached_file)指向的是 .webp —— 于是原图变成了一个“未入库的孤儿文件”。
这是有意保留的数据,不是 bug。它的意义是:如果你哪天要换 CDN、要重新生成尺寸、要回滚,原图还在原地。真要省空间,正确做法是压缩原图,而不是删掉原图。
判断一个选项该不该存在,可以问三个问题:① 关掉它是否只失去能力/损伤数据安全?② 关掉它是否表达某种业务策略?③ 关掉它是否省资源/避干扰/有合规收益? 三个都满足才该做成开关。删原图只满足第 ③ 条的一半(省一点空间),却带来不可逆的数据损失 —— 它不该是可配置的。
七、交付代码(公开引导)
下面这份是零依赖单文件:不引用任何插件、不依赖父主题,放进 mu-plugins/ 或主题目录 require_once 即可。它把上面五个坑全部修掉,另外多了两处为了”可诊断”而加的东西:
- 自检端点
?zdw_webp_check=1(需管理员)—— 一次性打出 GD 能力、gd_info、像素上限、memory_limit、上传目录是否可写,并取最近一个附件演示路径推导走了哪条分支。这个端点是只读的:不写文件、不改选项。 - 上传目录请求内缓存 ——
wp_upload_dir()会被过滤器和父主题影响,每张图都调一次是浪费。缓存成static后每请求只解析一次。
为什么”上传目录可写”值得单独报出来:它是「上传成功但没生成 .webp」的第一嫌疑 —— 上传目录由 web 用户写入,如果权限不对,imagewebp() 会失败,而 WordPres 上传流程本身的报错点不在这里,所以你只会看到”上传成功了但没转换”。而且这个检查必须以 web 用户身份做:用 root 或其它用户跑会得到相反的结论(在测试环境里我实测过这个陷阱)。
九、验证:185 项离线单测(公开)
这份代码配了 185 项断言的无 WordPress 单测,可以在任何装了 PHP + GD 的机器上跑(不需要 WordPress、不需要数据库)。它能离线测这件事本身很关键 —— 因为这个模块的失败态在真实站点里几乎观察不到。
覆盖范围
| 分组 | 断言什么 |
|---|---|
| 函数齐全 / 焊死口子 | 18 个函数都存在;函数签名里**没有** $keep_original;代码里没有 wp_delete_file / unlink |
| 能力检测 | 判定 = gd && imagewebp && gd_info[「WebP Support」],逐项对齐;key 不存在时不判死 |
| 浏览器 Accept | 分进程测 true / false / 缺失 / 大小写(因为判定函数有 static 缓存) |
| 像素上限 | 默认 3600 万;filter 覆盖生效;clamp 到 ≥1;返回 0 不会造成”永远跳过” |
| 真实转换(JPG/PNG) | 产物扩展名 / MIME / bytes;原图仍在且 md5 未变;PNG 调色板图的 alpha 被保留 |
| 路径推导 | 同域 / 中间尺寸 / CDN 前缀 / 协议不一致 / 带查询串 / 无附件 ID 回落 / 目录穿越 |
| 展示层替换 | 有 webp 才换 URL;webp 不存在时 URL 保持原样(不产生 404 链接);宽高元素原样保留 |
| 上传钩子 | file/url/type 三键同步;超像素时返回原数组(上传照常成功) |
| 失败留痕 | 原因分类;too_large 不写日志;同请求去重 |
| 自检端点 | 只读(不含任何写操作);无权限 403;exit 生效 |
| 源码级 | 零依赖(无 zhiji_/zib_ 前缀);命名前缀一致;常量都有 defined 守卫 |
两个我自己先写错的断言(值得记下来)
写测试时最危险的不是漏测,而是写出「永远为真」的断言 —— 它会给你绿灯,但什么也没验证。这一轮我踩了两个:
- 断言范围大于事实范围:我写了「源码里不含
wp_delete_file」,结果 FAIL。查下去发现:注释和后台说明文字里**故意**提到了它(那是在解释”为什么移除这个开关”)。→ 修正:先剥掉注释,并且把断言收敛到”行为函数”范围内,同时把”唯一允许出现的地方”明确登记成一条正向断言。 - 忘了
static缓存:路径推导的断言在第五节之后一直失败,因为zdw_webp_upload_dirs()在第五节就已经缓存了当时的目录。这不是代码 bug,是我在同一个进程里改了它不该改的东西。→ 修正:把依赖它的断言全部挪进独立子进程,并且专门加一条断言把这个缓存行为记录下来(下游集成者必读)。
判断一条断言有没有价值,只需要问一句:「它在什么情况下会失败?」 如果答不上来,它就是在占位置。所以这轮测试里我给关键的 clamp / 清洗断言都配了对照组 —— 比如断言
darken(…, 2.5) === darken(…, 1.0)(clamp 生效)时,必须同时断言!== darken(…, 0.5),否则两个值都被 clamp 到同一个结果,断言恒真。
十、速查表
| 坑 | 错误写法 | 现象 | 正解 |
|---|---|---|---|
| ① 路径映射 | str_replace( baseurl, basedir ) | CDN / 协议不一致时静默失效,永不替换 | 用 get_attached_file() 推目录,URL 只取 basename |
| ② 能力判定 | 只判 function_exists( imagewebp ) | GD 无编码器时仍判「支持」,每次上传白跑一遍 | 加 gd_info()[「WebP Support」];key 不存在时不判死 |
| ③ 大图 | 直接 imagecreatefromjpeg() | 大图 OOM → 上传 500 / 白屏 | 先判像素上限,超限跳过,上传照常成功 |
| ④ 失败无痕 | return false; 结束 | 几个月没转换,无人知道 | 原因分类 + 只记需人管的 + 同请求去重 |
| ⑤ 删原图开关 | $keep_original = false → wp_delete_file() | 原图永久消失,不可恢复 | 移除该能力,并摘掉形参把口子焊死 |
附:三个可迁移的结论
这份代码里真正有价值的部分,是可以搬到别的模块上的三条:
- 凡是”从 A 形式推出 B 形式”的转换,都要问:A 的前提是什么,前提不成立时会怎样?
str_replace( baseurl, basedir )的前提是”URL 以 baseurl 开头”。前提不成立时它不报错,只是原样返回。更稳的做法是直接问权威来源(这里是附件元数据),而不是从代理信息反推。 - 「跳过」和「失败」必须是两个可区分的结果。像素超限是预期行为,转换报错是故障,两者的处置(报警 / 重试 / 日志)完全不同。把它们合并成一个
false,调用方就永远分不清 —— 这是”日志里全是同一种噪音”的根源。 - 不可逆的动作不应该做成开关。删原图、删附件、清空缓存、批量替换 —— 这些一旦误触就无法回退。做成开关意味着”任何人都能在不知情的情况下触发”,而它的收益(省一点空间)和代价(数据永久消失)完全不对等。移除能力时,连形参一起摘掉,让重新引入必须改签名。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效 (本文)
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容