摘要:给站点做两个小功能:① 消息中心里的优惠码,点一下就能复制;② 站内消息产生后,顶部铃铛角标自动更新。两个功能都坚持同一件事——复用父主题已经有的能力,而不是在子主题里再写一套。上线后收到三条「不生效」:优惠码点了没反应、收到消息后角标纹丝不动、在控制台手动调刷新函数也「没反应」。三条同一种性格:不报错、不留日志、页面看起来一切正常。本文逐一还原根因:一次 requirejs 的兜底 define、一次「只搬了一半」的渠道移植、一次把派生值当成前端状态的误判,附真机取证方法与两段零依赖交付代码。
适合阅读:在 WordPress 子主题里复用父主题能力、或写过「动态插入 DOM + 复制 / AJAX 刷新」的开发者。
一、共同前提:子主题该「借」父主题,而不是重写
这两个功能在父主题(zibll)里都已经有现成实现,子主题要做的不是重写,而是借用 + 兜底:
| 能力 | 父主题提供的东西 | 子主题该做的 |
|---|---|---|
| 复制 | 一处集中实现:data-clipboard-text 属性 + body 级事件委托 + 统一提示 | 写对属性,让父主题处理点击 |
| 未读数角标 | 服务端渲染 <badge msg-cat="分类"> + 现成的计数函数 | 只负责「刷新时机」,数字问父主题要 |
为什么要坚持「借」?因为一旦重写,用户立刻会遇到:复制弹窗有两种风格、角标数字有两个来源。更糟的是两个来源会打架——这在本文第三章会具体讲到。
但「借」的代价是:你必须知道父主题的实现前提。下面三条失效,全是「父主题的机制没问题,只是它有一个你没满足的前提」。
二、失效一:异步插入的 DOM,父主题「看不见」
现象
消息中心里的优惠码,是前端脚本在消息正文插入之后装饰出来的(MutationObserver 监听异步内容)。装饰逻辑本身工作正常——DOM 里能看到它生成了带 data-clipboard-text 的 span,样式也对,但点下去毫无反应。更早的一版还出现过「复制成功了,但一次点击弹出两个提示」。
根因:一次「只检查初始 DOM」的自动加载
父主题的复制是一个模块(js/libs/clipboard.min.js),它在页面初始化时按需加载。触发条件是这一行:
// 父主题 js/main.js(页面初始化时执行一次)
$('[data-clipboard-text]').length && tbquire(['clipboard']);
注意 $(...).length && ——它只在「当前 DOM 里已经存在该属性元素」时才加载模块。页面初始化那一刻,消息还没打开、优惠码还没装饰出来,条件为假 → 模块没被加载 → 后来插入的元素虽然有属性,却没有人监听点击。整个过程不报错、不留日志。
所以解法看起来很简单:装饰命中之后,子主题自己把那句 tbquire 补上。但这里有一个反直觉的点,不搞清它就不敢这么写。
🧨 反直觉点:没有 define() 的脚本,回调会被执行吗?
clipboard.min.js 里没有一句 define(),它是普通脚本。按 requirejs 的常规理解,「没有 define 的模块」应该报 No define call for clipboard,那回调自然也不会执行——如果真是这样,我这条路就走不通了。
去看父主题加载器(js/loader.js)的关键分支,答案是否定的:
// loader.js 的 completeLoad 内(压缩后仍可辨认)
if (j.enforceDefine && (!g || !da(g)))
return e(a) ? void 0 : w(C("nodefine", "No define call for " + a, null, [a]));
E([a, d.deps || [], d.exportsFn]); // ← 走这里:把它当 shim 模块“定义”掉
关键在第一个条件 enforceDefine:父主题的配置里没有开启它,所以不会进「报错返回」分支,而是走下面的 shim 兜底 —— 模块被「定义」成空导出,回调照常执行。
旁证(比读压缩代码更可信):父主题自己就这么用——
// js/editextend.js:1580
tbquire(['main'], function () {
$('.pay-dawnload-edit').PayDawnloadEdit();
});
main 映射到 main.min.js,而 main.min.js 里 define( 出现次数是 0。父主题的核心脚本本身就是靠「无 define + 有回调」的方式加载的,站点能正常跑 → 回调确实会触发。
有了这个结论,修法就可以放心写了。
修法:装饰命中后主动加载 + 就绪标志 + 让位
/* ① 装饰命中后,主动把父主题复制模块加载起来 */
function ensureParentCopy() {
if (ensureParentCopy.done) { return; } // 幂等:只喊一次
if (typeof tbquire === 'function') {
ensureParentCopy.done = true;
tbquire(['clipboard'], function () {
window.ZHIJI_PARENT_COPY_OK = true; // ② 回调触发后才置位,避免竞态
});
}
}
// ...装饰循环里:
if (hit) { ensureParentCopy(); }
/* ③ 自己的委托在「父模块已就绪」时让位,否则一次点击会复制两遍、弹两个提示 */
document.addEventListener('click', function (e) {
var btn = e.target.closest && e.target.closest('.zhiji-copy');
if (!btn) { return; }
if (btn.hasAttribute('data-clipboard-text') && window.ZHIJI_PARENT_COPY_OK) {
return; // 交给父主题(它会弹统一风格的提示)
}
e.preventDefault();
handle(btn); // 父模块不可用时的降级链
});
三者的分工一定要清楚,否则不是「没反应」就是「双弹窗」:
| 父模块状态 | 谁负责复制 | 提示来源 |
|---|---|---|
已就绪(ZHIJI_PARENT_COPY_OK = true) | 父主题的 body 级委托 | 父主题统一提示(data-clipboard-tag 定制文案) |
| 未就绪(还在加载 / 加载失败) | 子主题自己的委托 | 子主题的降级链(Clipboard API → execCommand → 手动选中) |
三、失效二:只搬了 send(),没搬 flush()
第二个功能是「站内消息产生后,角标自动更新」。父主题没有提供全局刷新接口,所以子主题自己造了一套。先说设计,因为这个设计本身就是为了躲开一个老坑。
设计:标记 ≠ 数字
未读数是派生值。如果通知渠道在产生消息时自己去把角标数字 +1,就会出现「角标显示 3,但实际未读是 0」这类不一致——这是「两个数据源」的经典形态。所以渠道只做一件事:
打一个时间戳标记(表示「这人有新东西,下次打开页面时拉一次」),数字永远由前端向唯一数据源(父主题的未读计数函数)要。
根因:两段式机制,只搬了第一段
老版本(v2)是两段式的,两段都写在一个渠道类里:
// ① 打标记
public static function send($uid, ...) {
set_transient(self::key($uid), time(), DAY_IN_SECONDS);
}
// ② 消费标记(挂在 wp_footer)
add_action('wp_footer', array('Zhiji_Notify_Badge', 'flush'), 98);
public static function flush() {
...
echo "<script>if(typeof window.zhijiRefreshMsgBadge==='function')"
. "{window.zhijiRefreshMsgBadge();}</script>";
}
重构(v3)时,我把这个类整体搬了过来,还顺手把它改造得更”纯”:send() 打标记、pending() 查询、clear() 清除,职责分离得很干净。唯一的问题是:搬到新家之后,没有一个地方调用它们。
结果就是:标记老老实实写进了 transient,没有任何人读它 → 死数据 → 消息产生后角标永远不会自动变,只能在控制台手动敲函数(而手动敲又有第四章的坑)。
| 环节 | v2 | v3(修复前) |
|---|---|---|
send() 打标记 | 有 | 有 |
pending() / clear() | 有,且有调用点 | 有,但零调用点 |
挂在 wp_footer 的 flush(消费端) | 有 | 整个缺失 |
这是「移植」最典型的坑:搬运了定义,漏掉了调用关系。而且静态检查永远查不出来——php -l 通过,各种 lint 通过,两个函数都「存在」,只是没人用。
教训:迁移模块时,除了确认「函数在不在」,还要多问一句「谁调用它」。一次
grep -rn "类名::方法名",成本一秒,能省掉一整轮「功能不生效」的排查。
修法:把消费端补上,并放对层次
add_action('wp_footer', 'zhiji_msg_badge_flush_trigger', 98);
function zhiji_msg_badge_flush_trigger() {
if (!is_user_logged_in() || !class_exists('Zhiji_Notify_Badge')) { return; }
$uid = get_current_user_id();
if (!Zhiji_Notify_Badge::pending($uid)) { return; } // 没标记就什么都不做
Zhiji_Notify_Badge::clear($uid); // 一次性消费
echo "<script id='zhiji-badge-refresh'>(function(){ ... })</script>n";
}
为什么放在业务模块层,而不是放回通知渠道类里?因为项目的分层是单向依赖:… → notify → … → modules。这段逻辑要读「模块开关」,而开关属于 modules 层——放回 notify 层就成了反向依赖。
于是职责这样切:notify 层只管”打标记”和”查询 / 清除标记”(纯数据),「什么时候输出给前端」交给消费方的模块。这样渠道类保持无状态、无前端耦合,任何模块产生消息后都能复用同一条链路。
另外两个细节,都是踩过才加的:
① 函数就绪兜底——输出的脚本可能比前端 JS 先执行(脚本 defer / 异步加载),此时 window.zhijiRefreshMsgBadge 还是 undefined。只判断一次会静默失败,所以加了轮询重试(200ms × 25 次)。
② 一次性消费——输出后立刻 clear()。否则标记一直在,用户每翻一页都会触发一次刷新,白耗请求。
四、失效三:「看起来没反应」
第三条最容易被误判成 bug。现象是:在浏览器控制台敲下刷新函数,返回 undefined,角标一动不动 → 判定”函数坏了”。
真机取证:函数是好的
不猜,直接抓调用链。在页面里把 jQuery 的 $.post 包一层再调函数,记录它到底发出了什么:
// 抓到的调用记录(结构化输出)
[
{ "via": "post",
"args": [".../wp-admin/admin-ajax.php",
{"action":"zhiji_msg-counts","nonce":"596c3ca97d"}] },
{ "via": "post-resp",
"body": "{"ok":true,"data":{"all":"0",...}}" }
]
请求发出去了、响应也正确。再把三种数据状态各跑一遍:
| 数据状态 | 操作 | 角标结果 |
|---|---|---|
| 未读 = 0 | 打开页面 → 无角标 | 调用函数 → 仍无(正确,本来就没有) |
| 未读 = 1 | 打开页面 → 角标 1 | 标为已读 → 调用函数 → 角标消失(remove 分支) |
| 未读 = 0 → 产生消息 | 不刷新页面 | 调用函数 → 角标出现 1(补建分支) |
功能完全正常。那用户为什么会觉得”没反应”?两个原因叠加:
① 数据已经是 0 了。他刚刚点开过那条消息,服务端自然把未读清零,角标本来就该是空的(页面重新加载后也确实没有角标)。此刻调用刷新函数,结果”和调用前一样”——这是正确行为。
② 这套机制不是轮询。它的定位是「业务 AJAX 产生消息之后主动喊一声」,而不是「每 N 秒问一次服务器」。所以页面放着不动,角标永远不会自己变——这是设计,不是缺陷。
教训:判断「功能有没有生效」之前,先确认「数据有没有变」以及「它是不是应该自动变」。另外,返回 undefined 不代表失败——判断标准应该是它有没有产生副作用(这里:有没有发出请求、DOM 有没有变化)。
(顺带:第一条的原因还暴露了一个更本质的问题——如果自动触发链路是好的,用户根本不需要手动调这个函数。手动调用”看起来没反应”,恰恰是第三章那个”消费端缺失”的症状。)
五、怎么验证这类「静默」问题
静默失效的共同点是没有报错,所以「跑一下看有没有报错」这种常规手段完全无效。这次用到的取证手段,按由浅入深排:
| 层次 | 手段 | 能发现什么 |
|---|---|---|
| 代码层 | 读父主题源码 + grep 调用点 | 机制究竟断在哪一环(本文两个根因都在这一层定案) |
| 数据层 | CLI 直接调用函数、比对返回值 | 计数对不对、口径是否一致 |
| 网络层 | 抓 AJAX 请求 | 「请求到底有没有发出去」——最决定性 |
| DOM 层 | 读真实渲染出来的节点 | 选择器命中没有、补建有没有发生 |
决定性证据长什么样
第三章修复后的验证,靠的就是网络层这一条。三个状态各看一眼请求列表:
无标记 → 页面无 flush 脚本;请求列表里【没有】zhiji_msg-counts
有标记 → flush 脚本出现;请求列表里【有】 action=zhiji_msg-counts ← 这一行定案
再次加载 → flush 脚本不再输出;请求列表里也没有(一次性消费生效)
注意「有标记」那一行:它同时证明了 flush 输出 + 脚本执行 + 端点可用 + 一次性消费。光看角标数字是看不出来的(服务端渲染已经给了正确数字,肉眼看不出差别)。
🧨 三个把取证做砸的坑
| 坑 | 现象 | 正解 |
|---|---|---|
| 注入登录 Cookie 的时序 | 自动化浏览器里页面始终是「未登录」,看到的 DOM 全不对 | 先导航到目标域,再注入 Cookie,然后重新导航一次 |
| CLI 读未读数少算 1 | 和页面真实值不一致 | 计数函数内部用了 is_super_admin(),它依赖 current_user;CLI 下必须先 wp_set_current_user($uid) |
| 拿本地父主题副本模拟浏览器 | 结论与真机不符 | 真机 jQuery 是 3.7.1,本地副本是 1.9.1 → 关键链路以真机为准 |
第三个坑值得多说一句:手边有一份父主题源码副本,很自然会想「照它的版本在本地模拟」。但副本不一定等于线上——这次就是 jQuery 差了整整 6 个大版本。能用真机就用真机,本地模拟只适合验证纯逻辑,不适合验证「和父主题的交互」。
六、小结:三条失效,三种断法
| # | 失效 | 本质 | 一句话 |
|---|---|---|---|
| 一 | 异步 DOM 用不了父主题复制 | 触发时机问题 | 能力存在,但它的加载条件不满足你的用法 |
| 二 | 角标永不自动刷新 | 调用关系问题 | 零件都在,连线断了 |
| 三 | 手动刷新「没反应」 | 预期问题 | 功能是好的,但它在等一个不会自动发生的触发 |
一句话总结:
「配置了」到「生效了」之间隔着一条调用链。链上每一环都可能静默断开——而它不会告诉你是哪一环。
所以排查顺序建议固定成:先证明「数据对不对」,再证明「请求发没发」,最后才怀疑「代码写没写」。把这三步固化成可重跑的脚本之后,这类问题基本一轮就能收敛。
附:同系列文章
本文属于「zibll 子主题开发实录」系列,同系列其它篇目见文末导航。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容