Zsens Admin 插件钩子调试:我造了个"生命周期探针"把 `init` 到 `shutdown` 的 47 个挂载点全可视化之后,才发现问题出在优先级"撞车"而非"没挂上"
上周被个诡异 bug 折磨:插件 A 的 `wp_enqueue_scripts` 里注册的 JS 在页面死活不加载,但 `error_log` 确认钩子函数进去了。最后发现是插件 B 在同一个钩子、优先级 10 里调了 wp_deregister_script,而 A 的优先级也是 10,执行顺序全靠加载顺序赌人品。
从那以后我不再相信"钩子挂上了就行",开始用一套探针机制把生命周期彻底摊开看。分享下我的做法,权当抛砖。
一、探针核心:劫持 add_action/add_filter 做审计日志
WordPress 的钩子本质是全局数组 $wp_filter,我写了段 mu-plugin 级别的探针,在开发环境自动注入:
// probe-hook-debug.php (放 mu-plugins,生产环境自动短路)
if (!defined('ZSENS_HOOK_PROBE') || !ZSENS_HOOK_PROBE) {
return;
}
global $zsens_probe_logs;
$zsens_probe_logs = [];
function zsens_probe_intercept($tag, $function, $priority, $accepted_args) {
global $zsens_probe_logs;
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 6);
$caller = $backtrace[5] ?? $backtrace[2] ?? ['file' => '?', 'line' => 0];
$zsens_probe_logs[] = [
'tag' => $tag,
'priority' => $priority,
'type' => is_string($function) ? 'str' : (is_array($function) ? 'arr' : 'clo'),
'caller' => basename($caller['file']) . ':' . $caller['line'],
'timestamp' => microtime(true),
];
}
// 用 pre_option 思路太脏,直接包一层
add_action('all', function ($tag) {
static $intercepted = false;
if ($intercepted || did_action('zsens_probe_ready')) return;
// 只抓插件目录内的注册
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4);
if (isset($trace[3]['file']) && strpos($trace[3]['file'], '/plugins/') !== false) {
// 这里简化示意,实际用 Reflection 更稳
}
}, PHP_INT_MIN);
实际我用的是更脏但好用的办法:在插件入口文件最顶部,先备份 $wp_filter,等所有插件加载完再 diff。能直接看到"谁在哪一行、以什么优先级、挂了个什么东西"。
二、优先级"撞车"的可视化
探针跑起来后,我对着 `admin_init` 这个钩子看了眼,直接傻眼:
admin_init @ priority 10:
[0] zsens_admin::boot_router (来自 zsens-admin.php:89)
[1] some_other_plugin::check_license (来自 license-check.php:41)
[2] zsens_admin::enqueue_assets (来自 zsens-admin.php:112)
问题一目了然:boot_router 和 enqueue_assets 都是我写的,但中间插了个第三方插件的 license 校验。更坑的是那个 license 校验里有个 wp_redirect 没加 exit,导致后续钩子继续跑,但 header 已发送,我的 wp_enqueue_script 实际失效了。
修复不是改优先级就完事——得把 boot_router 提到 0 或 PHP_INT_MIN,enqueue_assets 放到 100 之后,并在中间加防御:
// 原来:add_action('admin_init', [$this, 'enqueue_assets']);
// 现在:
add_action('admin_init', [$this, 'enqueue_assets'], 999);
// 并在函数内部加哨兵:
public function enqueue_assets() {
if (headers_sent($file, $line)) {
do_action('zsens_assets_late_load', $file, $line);
// 降级方案:直接 echo script tag,或者记日志告警
return;
}
// ...正常 wp_enqueue_script
}
三、生命周期"断点":哪些钩子之间不能存数据
另一个踩坑点是假设 init 到 admin_menu 之间可以往 $GLOBALS 塞东西。实际上如果某个安全插件在 init 优先级 1 就 wp_die() 了,你的 init 优先级 10 的初始化代码根本没跑,但你觉得"插件加载了所以数据应该在了"。
我的探针现在会标记每个钩子的"实际执行状态":
add_action('all', function ($tag) {
global $zsens_probe_logs;
foreach ($zsens_probe_logs as &$log) {
if ($log['tag'] === $tag && !$log['executed']) {
$log['executed'] = true;
$log['exec_at'] = microtime(true);
// 记录是否在当前请求流中完成,还是被中断
$log['completed'] = !defined('ZSENS_EARLY_EXIT');
}
}
}, PHP_INT_MAX);
配合一个 shutdown 时的汇总输出,开发环境下直接吐到 footer:
[HOOK TRACE] admin_init: 5 callbacks, 3 executed, 2 skipped (early exit at priority 1)
skipped: zsens_admin::init_settings (priority 10, reason: wp_die triggered)
四、调试技巧:用 doing_action 做"钩子内省"
有时候不是钩子没挂,是你在钩子回调里又触发了别的钩子,形成递归或者时序错乱。doing_action() 和 did_action() 的组合比断点好用:
public function on_save_post($post_id) {
// 防递归:如果当前已经在 zsens_sync_meta 流程里,直接 return
if (doing_action('zsens_sync_meta')) {
error_log('递归阻断: on_save_post 在 zsens_sync_meta 内被触发');
return;
}
// 我的业务逻辑会触发 save_post,所以提前标记
do_action('zsens_sync_meta', $post_id);
// ...实际同步
remove_action('zsens_sync_meta', '__return_null'); // 清理标记
}
这个 doing_action 是 WordPress 内置的,但很多人不知道它能查"当前执行栈"而不仅是"是否执行过"。
五、一个实用的"钩子冲突"快速定位命令
最后贴个我放在 wp-cli 里的速查命令,不用装探针也能应急:
# 看某个钩子下所有回调的优先级和来源
wp shell <<< "global \$wp_filter; print_r(array_map(fn(\$cb) => array_keys(\$cb->callbacks), \$wp_filter['admin_init']->callbacks));"
或者更友好的版本,我包成了插件内工具:
// WP-CLI 命令: wp zsens hook-inspect admin_init
public function inspect($args) {
$tag = $args[0];
global $wp_filter;
if (!isset($wp_filter[$tag])) {
\WP_CLI::error("钩子 {$tag} 无注册回调");
}
$hook = $wp_filter[$tag];
foreach ($hook->callbacks as $priority => $callbacks) {
foreach ($callbacks as $cb) {
$name = is_array($cb['function'])
? get_class($cb['function'][0]) . '::' . $cb['function'][1]
: (is_string($cb['function']) ? $cb['function'] : '{closure}');
\WP_CLI::log(sprintf(
"[%3d] %s (args:%d)",
$priority,
$name,
$cb['accepted_args']
));
}
}
}
这套东西不复杂,但把"黑盒"变成"灰盒"之后,排查时间从小时级压到分钟级。尤其是多人协作、插件互相插队的项目,有个统一的钩子审计比单点打 log 靠谱得多。
你们有没有遇到过"钩子明明挂了但执行顺序完全不对"的诡异情况?或者更好的可视化方案?欢迎砸过来。

