Skip to content

WordPress AI 图片替换插件开发记:七个坑,和一个"元凶在别处"的真相 ​

起因很朴素:我的一个面向海外市场的行业站点上,有不少图是从网络各处找来的,版权风险一直悬着。

最直觉的做法是"换张图"——下载一张新的、重新上传、把引用改过去。但真动手就知道这条路走不通:

这个站有几十张图,引用分散在文章正文、Elementor 的 _elementor_data、以及 Elementor 编译出的外部 CSS 文件里。重新上传意味着新的 URL,连锁反应是:

  • 全站引用全部失效,得挨个改;
  • 旧 URL 变成 404,已经被搜索引擎收录的图片索引全部作废;
  • 手工改几十处,改漏一处就有一张图挂掉。

所以我需要一个**「原地替换」**方案:文件内容换掉,但附件 ID、文件名、所在目录全都保持不变。这样全站引用一行都不用改。

于是有了这个插件,我给它起名 AIRS(AI Image Replacer Suite),一路从 v1.6.0 迭代到 v1.6.41。下面是我踩过的七个坑,和几个我自己比较满意的小设计。

先看看它长什么样 ​

插件跑在 WordPress 里,生产环境接的是火山引擎 Ark 上的豆包 Seedream 5.0 图像模型。

日常使用流程是这样的:

  1. 用管理员账号在前台浏览网站,鼠标移到任意一张图上 → 图上方浮现一个「✨ AI 重绘」按钮;
  2. 点它,右侧滑出面板,显示这张图的附件信息与提示词;
  3. 点「生成候选」,拿到 N 张风格相近但细节不同的新图(默认提示词会刻意要求"换机位、换时间、换材质,绝不复制原图");
  4. 挑一张点「替换原图」——覆盖文件、重建所有尺寸、改写全站引用、清缓存,一气呵成。

它要处理的目标有三类,而第一类就是最大的坑。

坑一:Elementor 的背景图,压根不是 <img> ​

现象:我在一个默认主题的新站测试,一切正常;换到 Elementor 建的老站,鼠标移到任何图片上都没有任何反应。

我把那个卡片的 DOM 贴出来才看明白:

html
<div class="elementor-element elementor-element-d6c86e9 e-con-full e-flex e-con e-child
            elementor-motion-effects-element elementor-motion-effects-element-type-background"
     data-settings="{...&quot;background_background&quot;:&quot;classic&quot;...}">

这不是 <img>,是 CSS 背景图(background-image)——页面上根本没有图片标签,我原来那套"遍历 <img>"的逻辑自然一个都抓不到。

更麻烦的是,Elementor 的背景图有三个不同位置:

场景背景图实际在哪
普通容器/区段背景元素自身的 CSS
开了 motion effects(滚动缩放)被搬到内层 .elementor-motion-effects-layer 上
背景轮播(background-slideshow)藏在更深的 .elementor-background-slideshow__slide__image

我最初写的是"从鼠标位置向上找祖先"。这对第一种有效,后两种全废——因为背景图在下面/内层,向上找永远够不到。

修复:改用 document.elementsFromPoint(x, y),它返回鼠标位置所有层叠元素(包含被遮住的、内层的),再从中挑出「有 background-image 且尺寸达标」的面积最小者——最贴近图片本身的那个元素。

javascript
function bgAtPoint(x, y) {
  var stack = document.elementsFromPoint(x, y) || [];
  var best = null, bestArea = Infinity;
  for (var i = 0; i < stack.length; i++) {
    var el = stack[i];
    if (el === document.body || el === document.documentElement) break;
    if (!bgSrc(el)) continue;                       // 有背景图
    var r = el.getBoundingClientRect();
    if (r.width < 80 || r.height < 60) continue;    // 忽略图标级小图
    var area = r.width * r.height;
    if (area < bestArea) { bestArea = area; best = el; }
  }
  return best;
}

坑二:懒加载把真实地址藏起来了 ​

修好背景图后,普通 <img> 又出问题:图明明在页面上,插件却说"抓不到"。

根因:LiteSpeed Cache、Elementor 这类插件都会做图片懒加载——把真实地址挪到 data-src / data-lazy-src,而 src 只留一个 data: 开头的占位符。我原来的代码读到 data: 就当无效跳过了。

修复:写一个 realSrc(),依次尝试所有可能的位置,并跳过占位符:

javascript
function realSrc(img) {
  var cands = [
    img.currentSrc || '', img.getAttribute('src') || '',
    img.getAttribute('data-src') || '', img.getAttribute('data-lazy-src') || '',
    img.getAttribute('data-original') || '', img.getAttribute('data-lazyload') || '',
    img.getAttribute('data-srcset') || '', img.getAttribute('srcset') || ''
  ];
  for (var i = 0; i < cands.length; i++) {
    var s = String(cands[i] || '').trim();
    if (!s) continue;
    if (s.indexOf('data:') === 0) continue;   // 懒加载占位符
    if (/\.svg(\?|$)/i.test(s)) continue;     // 矢量图不适用于位图替换
    return s;
  }
  return '';
}

同时把绑定方式从"页面加载时一次性 querySelectorAll('img')"改成 document 级事件委托——否则懒加载之后才出现的图永远绑定不上。

坑三:主文件是新图,界面却还是旧图 ​

有个现象让我困惑了很久:

  • 直接下载主文件 → 新图 ✅
  • 电脑里的文件也确实是新图 ✅
  • 但媒体库列表、Elementor 的预览、前台页面 → 旧图 ❌

后来想通了:WordPress 的界面几乎从不直接用主文件,用的都是「尺寸变体」——-150x150.webp、-300x300.webp、-768x597.webp。主文件被换了,但那些缩略图还是旧内容,界面自然显示旧图。

修复:替换后主动重建全部缩略图——删掉所有旧尺寸文件,再以当前主文件重新生成:

php
// 1) 清掉同前缀的所有旧尺寸
foreach ( glob( $dir . $core . '*.*' ) as $f ) {
    if ( $f !== $file ) { @unlink( $f ); }
}
// 2) 以主文件重新生成 metadata(含全部尺寸)
wp_update_attachment_metadata( $id, self::generate_meta( $id, $file ) );

这条经验很通用:任何"换了主文件但界面不变"的问题,先想想界面到底在渲染哪一份文件。

坑四:缓存有四层,改参数是没用的 ​

Elementor 站的缓存链比想象的深:

层缓存键
1. 图片本体URL
2. Elementor 编译出的 CSS?ver= 参数
3. CSS 文件名里的内容 hashpost-4387-css.789718749.css ← 最阴的一层
4. 页面 HTMLLiteSpeed / WP Rocket 等

我一开始的做法是给引用 URL 加 ?airs=时间戳、再更新 CSS 的 ?ver。但线上纹丝不动——因为服务器对静态 CSS 通常配了 Cache-Control: max-age=31536000, immutable,意思是"一年内别再来问我"。只改 query 参数,文件名不变,浏览器压根不重新请求。

修复:不去改参数,而是主动让 Elementor 重新生成 CSS——新生成的文件名 hash 会变,浏览器就不得不重新下载:

php
if ( class_exists( '\Elementor\Core\Files\CSS\Post' ) ) {
    $file = \Elementor\Core\Files\CSS\Post::create( $post_id );
    $file->update();   // 重新生成 → 文件名 hash 变化
}

记住这条:强缓存(immutable)只能靠"换文件名 / 换 hash"破解,改 query 参数无效。

坑五:中文文件名,两边编码不一样 ​

有一次替换完,当页 HTML 里的 srcset 还是旧的:

html
<img src=".../2026/09/logo-副本-2.png"
     srcset="...logo-副本-2.png 400w, ...logo-副本-2-300x300.png 300w">

刷新之后才变成 .webp。

根因在于文件名里的中文:

  • 接口返回的 URL 里,中文是百分号编码的:logo-%E5%89%AF%E6%9C%AC-2
  • 页面 HTML 里的 src 是未编码的:logo-副本-2

我的匹配逻辑是"提取文件名基础名然后按字面比对",两边字面永不相等 → 遍历跑得完美无缺,却一个都没改到。

修复:比对前统一解码。

javascript
var dec = function (s) { try { return decodeURIComponent(s); } catch (e) { return s; } };
var matchName = function (u) {
  var b = urlBase(u);
  return !!b && basesDec.indexOf(dec(b)) > -1;   // basesDec 是解码后的基础名
};

另外加了个兜底:统计实际改到了几处,如果一个都没改到,就 700ms 后自动整页刷新。宁可刷新一次,也不能让用户面对"点了没反应"。

坑六(真凶):折腾十几轮,问题在另一个插件 ​

这是整件事里最值得写的一段。

有一类图,无论我怎么修,都是这个状态:

检查项结果
服务器上的文件29292 字节,新图 ✅
下载该 URL新图 ✅
WordPress「编辑图片」界面新图 ✅
_elementor_data 里的引用完全正确 ✅
缩略图重建已重建 ✅
媒体库列表 / Elementor 预览 / 前台旧图 ❌

我先后怀疑并排除了:LiteSpeed 缓存、CDN、Elementor 引用、postmeta 残留、文件名不一致、服务器时区……全都不成立。期间出了十几个补丁版本,问题依旧。

直到用户自己在另一个插件的设置页里点了一个按钮——问题瞬间消失。

那个插件是 Image Optimizer(Elementor 官方出的图片优化插件),它开着这两个选项:

  • Backup original images(保留原图备份)
  • Convert to a different format: WebP

也就是说,它对每张图的处理是:原图留一份备份,另存一份优化后的 WebP 作为主文件。而在那个状态下,WordPress 与前端实际取用的偏偏是那份"原图备份"。

所以:我们的替换改的是主文件,界面读的是备份——两者永远不一致。

它在设置里点的是「Remove backups permanently → Delete backups」。备份一删,界面立刻对新图。

结论:这场持续十几轮的"服务器是新图、前台是旧图",根因根本不在我自己的代码里,也不在文件名、缓存、或 Elementor 里,而在站上另一个"会写图片、也会提供图片"的插件。

如果重来一次,我会把顺序倒过来:

遇到"服务器文件与页面展现不一致",第一件事是问:"这张图还有谁在提供?" —— 先把站上所有会碰图片的插件(优化、CDN、格式转换、懒加载)列出来,再动自己的代码。

顺带一个建议:这类插件的功能(转 WebP、缩放、缩略图)与自研替换方案高度重叠,最好二选一,否则迟早出现"两个人在改同一张图"的场面。

坑七:悬停交互的"闪烁"和"点不到" ​

功能通了之后是两个体验问题:

① 悬停高亮会闪烁、经常"闪一下就失效"

根因是 mouseout 在目标容器内部的子元素之间移动时也会触发,被我误判成"鼠标离开了图片",150ms 后就把高亮收掉。

修复:先判断 relatedTarget 是否仍在当前目标内,再决定要不要隐藏:

javascript
document.addEventListener('mouseout', function (e) {
  var el = state.hoverEl;
  if (!el) return;
  var to = e.relatedTarget;
  // 还在同一目标内部(子元素之间移动)或已移到角标上 → 保持显示
  if (to && (el.contains(to) || overlay.contains(to))) return;
  scheduleHide(el);
}, true);

② 按钮离图片太远,鼠标移出图片就点不到

角标原本钉在容器左上角,而 Elementor 容器常比图片本身大,于是角标跑到很远的地方。

修复:角标改为跟随光标(position: fixed,出现在光标右下方,超出视口自动翻到另一侧)。从图片移到角标只需很短一段距离,顺手就能点到。

几个我比较满意的小设计 ​

功能能跑之后,我开始琢磨"怎样让它更省心、更干净"。这几个点我认为值得单独说。

1. 替换完,把原图删得干干净净 ​

这是版权合规最关心的一条。一次替换下来,原图的处理是:

原图部分处理
主文件被新图覆盖,原内容消失
全部尺寸变体(-150x150、-300x300…)逐个删除
-scaled 大图副本删除
替换前的备份可设保留 1~90 天 / 永久,也能一键清空

设置里还有一个「立即清空全部原图备份」按钮,一键把服务器上所有原图副本清光。

为什么这么做:既要彻底杜绝版权纠纷(服务器上不能有任何原图残留),也要省磁盘。所以如果想更激进,直接关掉「替换前自动备份原图」,替换后连备份都不会产生——代价是放弃"回滚到原图"的能力。

顺带一提,AI 生成过程中的中间候选图也不留:临时文件用完即删,不会往媒体库里塞垃圾。

2. 「从不改名」:只换格式,不换名字 ​

中途我一度想走"替换后换一个新文件名"这条路,因为同名 URL 会被强缓存挡住。但改名的代价很实在:

  • 旧 URL 失效 → 已收录的图片索引作废
  • 外部引用的链接 404
  • 对 SEO 不友好

所以最终定下的策略是:文件名主体永不变动,只做格式转换——banner.jpg → banner.webp,只有扩展名变化;如果原本就是 WebP,那名字一个字符都不动。

好处很直接:URL 路径完全不变 → 旧 URL 永久有效、零 404、SEO 不受影响。至于"同名 URL 的强缓存",交给上面那套"重建缩略图 + 重新生成 Elementor CSS(换 hash)"来解决。

3. 替换完自动"收尾",不用手动刷新 ​

一次替换的完整动作链是这样的——用户只需要点一下按钮:

覆盖主文件
  → 重建全部缩略图
  → 重新生成 Elementor CSS(换 hash → 浏览器强制重新下载)
  → 清 LiteSpeed / WP Rocket / W3TC / 对象缓存
  → 前端全页扫描:把页面上所有引用这张图的位置就地换成新图

最后那步"全页扫描"也是踩坑踩出来的——早期版本只刷新你点开的那一个元素,而同一张图在页面上往往出现在多个位置(多个卡片复用、slideshow 的多个 slide、桌面与移动两套),于是别处还是旧图,看着就像"没生效"。现在改成按文件基础名全页匹配,<img> 与 CSS 背景图一并处理。

4. 给它配了三个"自证清白"的工具 ​

排查那十几轮的经历让我明白一件事:看不见的状态最费时间。所以我把关键中间态都做成了可点击的面板:

  • 🔍 诊断 —— 打印附件的真实状态:文件绝对路径、字节数、修改时间、getimagesize 实际尺寸、全部 postmeta 的内容、同目录下的所有相关文件、以及当前启用了哪些"会接管图片"的插件
  • 🔎 查引用 —— 把数据库里实际存的引用字符串直接打出来(_elementor_data 里到底写的是哪个 URL,一目了然)
  • 🔧 一键修复 —— 对已经替换好的图做一次收尾:重建缩略图 + 归一化引用 + 重新生成 CSS + 清缓存,不用重新调用生图 API

现在回看,"诊断"这个按钮几乎是投入产出比最高的功能——很多问题的定位时间从"来回猜好几轮"缩短到"点一下,看一眼"。

版本迭代表(挑关键节点) ​

版本关键变化
v1.6.0首个可用版本:前台巡检器 + 原地替换
v1.6.14支持懒加载图片;事件委托替代一次性绑定
v1.6.15支持 Elementor 背景图(含 motion effects / slideshow)
v1.6.17改写构建器外部 CSS 文件,解决"刷新后回退旧图"
v1.6.26替换后重建缩略图(解决"界面显示旧图")
v1.6.27~30增加诊断能力:可疑插件、postmeta 内容、上传目录
v1.6.31主动重新生成 Elementor CSS(换 hash 破强缓存)
v1.6.39定稿「从不改名」:只换格式,URL 保持不变
v1.6.41修复中文文件名编码比对问题 + 加自动刷新兜底

小结 ​

#现象层次根因修复
1背景图毫无反应前端识别Elementor 背景图在 CSS 且有三处位置elementsFromPoint + 面积最小
2图片"抓不到"前端识别懒加载把真地址藏在 data-src多属性回退 realSrc()
3主文件新、界面旧WordPress 媒体界面渲染的是尺寸变体替换后重建缩略图
4清了缓存也不变HTTP 缓存静态 CSS 配了 immutable让文件名 hash 变化
5当页 HTML 没更新字符串匹配中文的 URL 编码差异统一 decodeURIComponent
6折腾十几轮的"不一致"第三方插件优化插件保留了原图备份删除备份 / 停用该插件
7高亮闪烁、按钮点不到交互mouseout 误判 + 角标位置过远判定 relatedTarget + 角标跟随光标

几个反复被验证的经验:

  • "服务器是新图、页面是旧图",先问"还有谁在提供这张图"。 站上所有图片优化 / CDN / 格式转换类插件都在图片文件层与你的方案竞争,功能重叠时最好二选一。
  • 缓存要按"键"来破。 浏览器按 URL 缓存图片、按 ?ver 和文件名缓存 CSS。遇到 immutable 只能换文件名或换 hash,改参数是白费力气。
  • 界面渲染的往往不是你以为的那份文件。 主文件 / 缩略图 / 备份副本,三者极易不一致;先确认"界面上这张图具体是哪个文件"。
  • 把不可见的状态做成可见的。 一个"诊断"按钮能省掉好几轮来回猜测——这是这次开发中最大的效率收获。
  • 合规和整洁可以顺手做掉。 替换后删掉原图所有尺寸 + 清理 AI 中间产物,既杜绝版权风险,也让服务器更干净。

基于 VitePress & @sugarat/theme 构建