WebP 自动转换:三个「上传成功、图片没变」的静默失效

WebP 自动转换:三个「上传成功、图片没变」的静默失效-知集
WebP 自动转换:三个「上传成功、图片没变」的静默失效
此内容为付费阅读,请付费后查看
¥1
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
付费阅读
阅读约 98 分钟29,312 字

摘要:「上传图片自动转 WebP」是同一类需求里实现最短、坑却最集中的模块:真正转格式那几行 GD 代码谁都写得出,决定成败的是它周围的三件事 —— 路径怎么算、怎么判断「能转」、失败时有没有人知道。这三件事的共同点是:出错时完全没有异常。页面正常、上传正常、「上传成功」的提示照常显示,唯一的现象是图片体积没变;而你登录后台在自己电脑上看,又一切看起来都是对的(原因见坑②)。本文拆五个坑:最经典的一个是 str_replace( baseurl, basedir ) 路径映射 —— 它在只有一种域名、协议一致的站上永远正确,所以在开发机测不出来,却会在上了 CDN / 多域名 / http-https 混用的真实站点上静默失效。附零依赖交付代码与 185 项离线单测。

一、为什么这个功能”特别难发现自己坏了”

先看它的验收条件,几乎只有一句话:「上传一张 JPG,磁盘上多一个 .webp,前台 src 指向它」。这句话本身没错,但它有一个致命的性质:它的三个环节全都可以”看似成功”。

环节失败了会怎样你能看到吗
上传时转换上传照样成功($file 原样返回)看不到 —— 这正是设计目标:转换不该阻断上传
展示层换 srcURL 不动,继续用原图看不到 —— 页面完全正常,只是文件大一点
浏览器是否支持继续用原图看不到 —— 而且你自己大概率用的是 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 篇) · 系列总目录与阅读路径 →

① 前端性能与加载

② 排版与样式系统

③ 功能实现与边界

④ 系统与运维

⑤ 成就与活动系统

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容