上一篇写 CDN 的时候,压图这件事只占了一小节。我当时把整站 5924 张图批量转成 WebP,指标很好看,然后被人一眼看出部分文章出现色彩断层,方案整个作废,连夜回滚。那篇的重点在 CDN,这件事就一句带过了。

但线没结束。回滚之后我又把它重做了一遍,这次做成了后台里一个可以逐张操作的小工具。结论是:全站批量转格式这条路,对我这种内容混杂的博客就是走不通的;可同样是压缩,拆成“我自己一张一张决定”之后,它又变得可行了。

先说结论

我做的东西很简单:后台「媒体 → 图片工具」,选一张图,选方式(同格式压缩 / 转换格式)、选质量(无损 / 有损加数值),点「生成预览对比」,页面并排显示原图和转完之后的样子、体积变化、以及像素差异指标。看过觉得没问题,再点「确认替换」,原图自动备份。

WordPress 后台图片工具的处理流程:选图、选口径、生成预览、人工放大确认、确认替换共五步,其中人工确认是刻意设置的卡点,未生成预览就不显示替换按钮
一张图要过五道手,第 4 步是刻意加的卡点

它救我的地方不在于省了多少兆,而在于它逼我看一眼。上一轮翻车的原因说到底是“我测的样本不是用户看到的全部”,而这次我把“看一眼”做成了流程里绕不过去的一步——不点预览就出不来替换按钮。

顺手说,这篇文章会更偏“做工具时踩的坑”,而不是“怎么省体积”。省体积的账上一篇已经算过了,这里不重复。想先看 CDN 那条线的话,在博客接 CDN:独立静态子域接 EdgeOne里。

现成的插件我试了,最后没敢用

按我的习惯,这种事情先找现成的。翻了一圈,本地压缩做得最成熟的是 EWWW Image Optimizer——免费、装机量过百万、从 2012 年维护到现在,功能上几乎正好对上我的需求。

它确实支持我要的东西:媒体库列表里每张图旁边有独立的优化按钮,不点批量就完全是手动;同格式的无损压缩用 jpegtran、optipng 这些工具做;格式转换支持 PNG、JPG、WebP 互转;而且全程在服务器本地跑,图片不出机器。看着几乎就是我要的,但我把它下载下来之后没急着激活,先量了一下体积,然后决定不装。

实测项结果
插件体积24 MB
其中多平台二进制20 MB(macOS、FreeBSD、Windows 全都打包进来了)
真正的 PHP 代码2.4 MB
推广 / 提示钩子admin_notices 16 处、network_admin_notices 11 处
官方“静音”开关没有,只能逐条 dismiss,版本更新还会再冒出来

这里面 20 MB 是它自带的多平台二进制。我用的是 Linux 服务器,另外那三个平台的二进制是纯陪跑,删掉能压到 4 MB 左右——但每次插件更新都会回来。至于那 27 处通知钩子,技术上也拦得住,挂个 mu-plugin 把它的通知全卸掉就行。

问题是我要的是一条能长期不添麻烦的路。一个每次更新都要重新“瘦身 + 静音”的插件,维护成本比我自己写一个还高。功能它没问题,但它跟“轻量、干净”是两回事,这是两笔账。

所以最后还是自己写。这里有个前提值得单独说:服务器上本来就装了 ImageMagick(PHP 的 Imagick 扩展),转 WebP、转 AVIF 都能做。也就是说我要的能力其实已经在机器上了,缺的只是一个人机界面,不需要引入任何外部服务。

我需要的不是一个优化器,是一个决策工具

把上一轮的失败拆开看,会发现一个挺尴尬的事实:脚本本身没写错,转换也确实成功了,5924 张全过。真正坏掉的是决策环节——我替几千张性质完全不同的图,统一选了一个质量参数。

照片、渐变背景、带细密文字的终端截图,这三类内容对“有损”的容忍度差着量级。照片扔到 q82 肉眼看不出区别,渐变区的色带却能被人一眼点出来。我用一个数字覆盖了全部,还只用照片做了抽样。所以工具的设计目标不是“压得更狠”,而是“把决定权一张一张交回来,并且把判断依据摆在眼前”,具体落到三条。

  • 必须先预览、后替换。没生成预览就不给替换按钮,从流程上堵死“闭着眼睛批量”这条路。
  • 预览里要有像素级别的差异指标,不能只显示“省了百分之多少”。
  • 替换前自动备份,替换后自动把版权元数据补回去。

第三条不是洁癖。我的图上有另一套自研的取证插件在写版权信息——JPEG 写进 IPTC、PNG 写进 iTXt、WebP 写进 XMP。任何一次重编码都可能把这些信息冲掉,压缩完不补回去,等于悄悄毁掉自己的维权凭据。

还有一点是故意不做的:我没给它加批量入口。倒不是技术上难,是我发现自己一旦有了那个按钮就会去按;工具的形状会反过来塑造人的习惯,这一轮我宁愿它难用一点。说到底,这个工具想让“压缩”从一次性的大动作,变回一件随时能做的日常小事——真正省带宽的大头有 CDN 兜着,图片这边我宁愿慢一点、稳一点。

什么样的图该压,我后来分了四类

工具只是给了我一双手,真正难的是判断。上一轮翻车的根子就在这儿:我拿一个质量参数,覆盖了几千张性质完全不同的图。后来我逼着自己把它们分了类,每类定一个默认打法,遇上不一样的再单独调。这张表是我现在的默认值。

图的性质怎么处理原因
照片、实拍图有损压缩,质量 82–85细节密集,人眼对这类误差不敏感
渐变背景、纯色过渡无损,或者干脆别压平滑区域最容易出现色带,一眼就能看出来
带细密文字的截图无损文字边缘的锯齿和色晕同样一眼可见
真透明的图(logo、图标)只能留 PNG 或转 WebPJPEG 没有透明通道,透明区会变成黑块

这张表里最反直觉的是第二行。渐变背景、纯色过渡这种看上去“很简单、很好压”的图,恰恰是 JPEG 那一系有损编码的软肋——平滑区域里一点点量化误差,人眼会主动把它连成一条条的色带。上一轮被人一眼点出来的,就是这类图。

第四行的坑更硬:JPEG 压根就没有透明通道。把一张带真透明的 logo 转过去,透明区域不会变成白底,而是会变成黑块。这不是质量问题,是格式能力问题,怎么调质量参数都救不回来。

顺带说一个我踩过的边界:PNG 大于 100 KB 的那批里,有 96% 明明标着“带透明通道”,实际却一个透明像素都没用到——那是截图工具顺手写进去的空通道。判断能不能转 JPEG,看的是有没有真的用到透明,不是文件头里有没有那条通道。

坑一:无损压缩的“无损”,得自己证明

工具做完第一版,我先在两篇自己的文章上实测。无损档跑完,我写了段代码做像素比对,屏幕上打出来一行:PSNR = 0。

那一瞬间我以为画质彻底崩了。无损模式把图毁了,这事要是发生在我刚被色带教训过之后,就太荒唐了。但我留了个心眼,又跑了一次绝对误差计数,结果是 AE = 0——逐像素完全相同。两个指标一个说“完全一样”,一个说“差到没边”。

查下去才知道,这不是我的问题:ImageMagick 对两张完全相同的图,compare -metric PSNR 返回的是 0 (0),而不是数学上应该出现的 inf。如果直接把它显示到界面上,用户看到的会是“PSNR 0”,明明是无损,看着像画质崩了。

所以判定顺序必须反过来:先用 AE(绝对误差计数)判,AE 等于 0 就认定像素完全一致,显示“与原图完全一致”;只有 AE 不为 0,才去算 PSNR 给个参考值。

顺带记一个细节:PSNR 只在同格式之间比才有意义。转格式的那类图我也会算一个值显示出来,但从不拿它当“无损还是有损”的判定依据——判定的活儿只交给 AE。

为了把这件事钉死,我又加了一重验证:把原图和产物都解码成原始 RGBA 位流,用 cmp 逐字节比,结果 IDENTICAL。到这一步我才敢在界面上写“无损”两个字。

这事的教训跟上一轮是同一个:一个指标好看,不代表结论成立。上一轮是“PSNR 四十多分贝看着很安全”,这一轮是“PSNR 零看着很吓人”,两次都是指标在骗人。指标得交叉着看,而且得知道它什么时候会失效。

坑二:元数据写不进去,还不报错

前面说过,图上有版权元数据,压缩完必须补回去。我调用取证插件里现成的写入函数,参数按直觉传了一个字典,键名写作 rights、creator、credit、source。跑完,日志里写着“元数据未处理”——没报错,没异常,就是没写进去。

翻源码才明白:JPEG 那条支路收的不是语义键名,而是 IPTC 的标签号——版权要写成 2#116,作者写成 2#080。函数遍历字典时会把所有不匹配这个格式的键直接跳过,于是我传的四个键全被丢掉,拼出来的 IPTC 数据是空的,函数发现是空的就直接返回 false,而且这条路径不打日志。

更麻烦的是键名规则还不统一:JPEG 用标签号,PNG 用 Copyright 这种关键字,只有 WebP 才吃我最初传的那套语义名。三个格式三套规矩,谁记得住。

我的处理方式是不再自己拼字段,直接把整个附件交给取证插件的官方处理入口——它会读文件的实际格式,自己分派对应的键名,顺带把感知哈希台账也刷新了。比我手工拼一套可维护得多。

这里面让我最不舒服的不是键名写错,是“静默失败”。它明明知道自己什么都没做,却选择了安静地返回假值。这种设计在调试阶段能吃掉好几个小时——因为你会先去怀疑那些会叫的地方,而凶手从头到尾没出过声。

坑三:数据库改了,前台还是旧的

格式转换这条路上,除了换文件,还得顺手改一批引用。文件名从 .png 变成 .webp,正文里的图片地址、附件的元数据、附件记录本身,都要跟着变。我图快,直接用 SQL 把这些字段更新了一遍,没走 WordPress 的 API。改完立刻回查数据库,内容是对的,一切正常。然后我去看前台,页面上的图片地址还是旧的。

原因不复杂:WordPress 会把文章对象缓存在内存里,我用 SQL 改的是数据库那一层,缓存里那份还是改之前的内容。读数据库能看到新值,读页面拿到的是旧值,两边的“真相”不一致。清一下对象缓存就好了。

顺带还有第二层缓存:站上开着 WP Super Cache,它会直接把渲染好的 HTML 存成静态文件。即使对象缓存清干净了,缓存文件里存的还是旧地址。所以格式转换之后要清两次,一次内存的,一次磁盘的。

这个坑本身不难修,但它给我留了个后遗症:那轮自测里我一度以为“引用同步失败了”,其实是测试代码自己读到了旧缓存。我花了十几分钟去查一个根本不存在的 bug,最后发现是证据本身过期了。所以后来测这类改动,我一律绕开 WordPress 的 API,直接查数据库——要看真相就看最底层那份,别经过任何一层可能带缓存的包装。

最大的坑:命令行里全绿,后台一打开就白屏

前面那些坑都还算常规。真正让我重新想了一遍验证方法论的,是接下来这个。

工具写完之后,我在服务器的命令行里跑了一遍完整的流程:建一张测试图、走一遍无损压缩、走一遍格式转换、校验文件、校验数据库、校验元数据、最后清理掉。全部通过,一个警告都没有。我还专门造了张渐变加半透明的图——就是最容易出色带的那类——AE 是 0,完美。

然后我把部署好的插件交给实际使用的人。对方打开后台,点进「图片工具」,页面白屏,一行红字:

Fatal error: Uncaught Error: Call to undefined function shell_exec()
in /usr/local/lighthouse/softwares/wordpress/wp-content/mu-plugins/xz-img-tool.php:64

我的插件是用 shell_exec 去调用服务器上的 magick 命令来做转换的。在命令行里它能用,在后台里它根本不存在。

查下来是这么回事:它所在的 PHP 进程(FPM)有一份 disable_functions 配置,里面明确禁用了 shell_exec、exec、proc_open、popen、system、passthru——一共六个能执行外部程序的函数,全禁。被禁掉的函数在 PHP 里连“存在”都不存在,所以是致命错误,不是警告。

而我之前所有验证,包括那句“一个警告都没有”,跑的都是命令行版的 PHP。命令行那份配置文件里,disable_functions 是空的。也就是说,我测的环境和用户运行的环境,能力根本不一样——我在一个什么都能干的环境里,证明了一个什么都不让干的环境里跑不起来的东西“完全正常”。

这是今天最该记住的一条教训,而且它跟上一轮的翻车是同一类错误:上一轮是“我测的样本不是用户看到的全部页面”,这一轮是“我测的环境不是用户运行时的那一个”。两次都是我拿一个替代品当真相用。

修法是把整条路换掉:不再调用任何外部命令,改成直接用 PHP 的 Imagick 扩展做编码和像素比对。服务器上本来就装着 ImageMagick 7.1.1-26,WebP 和 AVIF 都支持,能力上完全够用。顺带还去掉了两个同样被禁的函数(改文件属主用的),因为进程本来就是这个属主,根本不需要。

但改完还有第二个坑等着:备份目录写不进去。插件要把原图备份到腾讯云 COS 的挂载点,图省服务器磁盘,结果这一条也被拦住了——Web 进程被一个叫 open_basedir 的设置关在网站目录里,只允许访问网站目录和临时目录,COS 的挂载点落在允许范围之外。

open_basedir restriction in effect. File(/www/OBS/backup/img-originals)
is not within the allowed path(s):
(/usr/local/lighthouse/softwares/wordpress/:/tmp/)

而且它比第一个坑更阴:越界访问不会致命,只会抛警告。在我这种异步请求的界面里,警告会被打印到返回内容里,把本该干净的 JSON 数据污染掉,前端解析直接失败。报错至少告诉你哪里错了,警告只是默默把数据搞坏。

处理方式是把备份目录做成两级:先试 COS,越界就自动退回站内目录。站内目录再补一条 nginx 规则禁止公网访问,避免原图备份被人直接下载。

PHP 命令行与 Web 后台的能力对照表:执行外部命令与修改文件属主在命令行可用、在后台被完全禁用,目录访问范围后台仅限网站目录与临时目录,Imagick 与 GD 两处均有
命令行里全绿的代码,搬到后台就是 Fatal error

这张表的意思很直白:命令行、后台、前台、计划任务,是四个能力边界不同的环境,谁也不能替谁背书。凡是要碰外部命令、系统调用、受限路径的功能,只能用 HTTP 请求真的跑一遍真实入口,才算验证过。

后来我的做法也改了:在网站根目录临时放一个带密钥的探针文件,用请求触发它,把真实环境里的函数可用性、扩展列表、目录限制全部打印出来,看完立刻删掉。三十秒的事,能省掉一整晚的返工。

改完之后,它长这样

换掉底层之后重新验了一遍,这次走的是真实入口。能力探测的结果是:Imagick 可用、WebP 可用、AVIF 可用、外部命令不可用、JPEG 的无损工具不可用,全部符合预期。接着是功能验证,全程用自己造的测试附件,跑完就清理,不碰任何一张生产图片。

验证项结果
同格式无损压缩AE = 0,逐像素完全一致
格式转 WebP体积省 59.9%,PSNR 48.26
附件记录 / 元数据 / 尺寸档 / MIME全部正确更新
正文引用同步、旧文件移除通过
版权元数据读回WebP 的 XMP 可以正常读出
感知哈希台账刷新通过
备份清单生成通过
后台页面渲染不再报错
PHP 警告零

它有一样能力确实是缺的:JPEG 的真正无损压缩用不了。那条路必须借外部命令(jpegtran),而外部命令在 Web 端被禁。所以 JPEG 目前只能走“有损重压 + 可调质量”,PNG 和 WebP 的无损都正常。这一点我在界面上直接标出来了,不装。

还有一处是妥协。备份本来想直接写进对象存储(腾讯云 COS 的挂载点),图省服务器磁盘,结果是 Web 进程的活动范围被限制在网站目录内,够不到那个挂载点,只能先落到站内目录。我给它补了一条拒绝公网访问的规则,目录里的原图不会被直接下载。备份本身是完整可用的,回滚清单也在,缺的只是异地副本这一层。

最后

整个工具不到 40 KB,挂在 mu-plugin 目录下,所以它不会出现在插件的更新列表里,也就不存在“更新之后推广又冒出来”这种事。图省心的话,这个位置挺合适。

用起来就是:媒体库把鼠标移到某张图上,点「优化」,选方式和质量,先看预览,觉得行再替换。原图会备份到指定目录,清单里记着新旧对应关系,后悔了能捞回来。

如果你也想给自己的站做这么个东西,我唯一的建议是别急着写批量。先把“单张、有预览、有备份”这条路跑通,用上一阵子,你自然会知道自己真正需要压的是哪一类图。反过来先做批量,很容易变成我第一轮那样:指标很漂亮,画面被人一眼看穿。

至于压力本身,说实话还是那句话——真正卡住我的从来不是图片体积,是那条 4 Mbps 的带宽。压图能省一点,但把图片挪到 CDN 去,才是把这件事从根上解决。工具负责把该省的省下来,CDN 负责别让它挤在那条细管子里。

下一步我打算挑几张之前就列入待办的图继续压:导航栏那张、头像、还有几张大体积的终端截图。这次不着急,一张一张来,每张都先看预览。

写这些内容需要耗费大量的时间和金钱,觉得有用的话可以点击下面的“赞赏”支持作者,你的支持是我更新的动力。

  • reward_image1
  • reward_image2
欢迎来到XiaoZou123,这里是一个电脑极客、数码爱好者网站。我平时喜欢关注数码新闻,研究计算机技术。如果你看我头像觉得我是二次元,那我其实还算不上!
最后更新于 2026-10-12