Zsens Admin 插件钩子"幽灵挂载":我的 `add_action` 明明执行了,回调却像丢进黑洞——原来是 `plugins_loaded` 时序与 MU 插件的"抢先注册"在打架
上周被一个很阴间的问题折腾到凌晨:我在插件主文件里写了句最基础的挂载,结果回调死活不执行,连报错都没有。
代码看起来毫无问题:
// zsens-admin.php
add_action('init', 'zsens_bootstrap', 5);
function zsens_bootstrap() {
error_log('ZSENS: bootstrap fired');
// ... 初始化逻辑
}
日志文件安静得像太平间。我先是怀疑优先级被别的插件盖了,把 5 改成 PHP_INT_MIN 都没用。然后怀疑是不是文件名没加载,在文件顶部加了个 die('here')——能输出,文件确实执行了。
最后是靠这个"钩子探针"才定位到:
add_action('all', function($tag) {
static $fired = [];
if (!isset($fired[$tag]) && strpos($tag, 'zsens') !== false) {
$fired[$tag] = true;
error_log("HOOK TRACE: {$tag} | priority check");
}
});
探针显示 init 确实触发了,但我的回调不在挂载队列里。顺着 $wp_filter['init'] 翻,发现队列里有个匿名函数把我的命名函数"挤"到了后面,而且那个匿名函数里调了 remove_all_actions('init') 又自己加了一套。
罪魁祸首是客户站里一个 MU 插件(Must-Use),它在 mu-plugins/early-loader.php 里干了这事:
// MU 插件,总是最先加载
add_action('plugins_loaded', function() {
// 客户想"重置"某些插件的初始化
remove_all_actions('init');
// 然后自己重新编排...
}, 0);
问题出在时序:MU 插件的 plugins_loaded 钩子比标准插件的 plugins_loaded 早触发,而我的 add_action('init', ...) 写在插件主文件顶层,PHP 解析时立即执行。看似没问题,但客户站用了个"插件加载器"MU 插件,它把所有标准插件的加载推迟到了 plugins_loaded 优先级 1,而那个"重置"逻辑挂在优先级 0。
所以实际顺序是:
- MU 加载器解析,把我的
add_action('init', ...)注册进$wp_filter - MU 重置脚本在
plugins_loaded优先级 0 执行,remove_all_actions('init'),我的挂载被清掉 - MU 加载器在优先级 1 才真正
include我的插件文件——但此时我的顶层代码已经执行过一次,再次add_action又会被清掉...
等等,这里我搞混了。实际更诡异:MU 加载器用的是 include_once,我的插件文件只被加载一次。真正的问题是我的 add_action 执行时,init 钩子还没被重置,但重置脚本执行时我的回调已经在队列里了,然后被一锅端。
解决思路不是改 MU 插件(客户不让动),而是让我的挂载时机"跳"出被清理的窗口。几种实测有效的方案:
方案一:延迟到更晚的钩子再挂 init
// 不直接挂 init,而是等 plugins_loaded 后期再挂
add_action('plugins_loaded', function() {
add_action('init', 'zsens_bootstrap', 5);
}, 20); // 故意晚于那个重置脚本
这样即使 remove_all_actions('init') 执行过,我的 add_action 是在它之后补上去的。
方案二:用"自修复"包装器
如果不知道前面有什么妖魔鬼怪,可以做个防御性挂载:
function zsens_persist_init($callback, $priority = 10) {
$hook = 'init';
// 先正常挂
add_action($hook, $callback, $priority);
// 再挂一个"修复器"到 plugins_loaded 末尾
add_action('plugins_loaded', function() use ($hook, $callback, $priority) {
// 检查是否还在队列里(用 has_filter 不行,它只查回调存在性,不区分优先级)
global $wp_filter;
$still_there = false;
if (isset($wp_filter[$hook])) {
foreach ($wp_filter[$hook]->callbacks as $prio => $callbacks) {
if ($prio == $priority) {
foreach ($callbacks as $cb) {
if ($cb['function'] === $callback) {
$still_there = true;
break 2;
}
}
}
}
}
if (!$still_there) {
error_log('ZSENS: init was wiped, re-registering');
add_action($hook, $callback, $priority);
}
}, PHP_INT_MAX);
}
// 使用
zsens_persist_init('zsens_bootstrap', 5);
方案三:调试期快速定位"谁删了我"
写一个临时追踪脚本,贴到 wp-config.php 同级的一个 debug-hooks.php 里,然后 MU 插件加载它:
// 临时调试:追踪 init 钩子的增删
add_action('init', function(){}, PHP_INT_MIN); // 占个位
add_action('all', function($tag) {
if ($tag !== 'init') return;
static $snapshot = null;
global $wp_filter;
$current = isset($wp_filter['init']) ? count($wp_filter['init']->callbacks) : 0;
if ($snapshot !== null && $current < $snapshot) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8);
$culprit = '';
foreach ($trace as $frame) {
if (isset($frame['file']) && strpos($frame['file'], 'wp-includes') === false) {
$culprit = basename($frame['file']) . ':' . ($frame['line'] ?? '?');
break;
}
}
error_log("INIT HOOK SHRUNK: {$snapshot} -> {$current}, first non-core: {$culprit}");
}
$snapshot = $current;
});
这个帮我锁定了是 early-loader.php:23 干的。
一点经验
以前觉得 add_action 写了就等于"挂上了",现在会习惯性地在关键路径加这种断言:
// 开发模式断言
if (defined('ZSENS_DEBUG') && ZSENS_DEBUG) {
add_action('wp_footer', function() {
global $wp_filter;
$has_bootstrap = false;
if (isset($wp_filter['init'])) {
foreach ($wp_filter['init']->callbacks as $callbacks) {
foreach ($callbacks as $cb) {
if ($cb['function'] === 'zsens_bootstrap') {
$has_bootstrap = true;
break 2;
}
}
}
}
if (!$has_bootstrap) {
trigger_error('ZSENS: bootstrap missing from init!', E_USER_WARNING);
}
});
}
钩子调试最烦的不是"没挂上",是你以为挂上了、它也执行过、但某个时机被悄悄抹了。特别是 remove_all_actions、remove_action 配合匿名函数的时候,堆栈里连个名字都看不到。
你们遇到过类似"挂载后消失"的情况吗?我好奇还有啥更隐蔽的时序陷阱。

