钩子挂载顺序"乱战"实录:我用 `doing_action` 与 `current_filter` 搭了一套"钩子现场勘查"工具链
写插件久了,最烦的不是钩子挂不上,而是挂上了、跑了、但跑的不是你以为的那个时机。上周重构一个支付回调插件,wp_loaded 里注册了一个处理器,结果用户反馈说偶发"订单状态没更新"。本地复现不了,线上日志又看不出毛病——最后发现是另一个插件在 init 优先级 1 就触发了重定向,把我的 wp_loaded 处理器直接跳过了。
这种"钩子乱战"在插件生态里太常见了。多个插件抢同一个钩子、互相覆盖优先级、或者 A 插件的回调里移除了 B 插件的回调,排查起来跟破案似的。我后来攒了一套"现场勘查"方法,专门用来抓这种时序问题。
第一招:doing_action 做"时间戳"标记
别只打日志说"我跑了",要记录当前 WordPress 执行到哪个阶段:
add_action('plugins_loaded', function() {
// 记录全局执行阶段快照
$GLOBALS['my_plugin_hook_trace'] = [
'hook' => current_filter(),
'doing' => doing_action('plugins_loaded'), // true
'all' => array_slice($GLOBALS['wp_actions'], -5), // 最近5个已完成的action
'time' => microtime(true),
];
}, 5); // 故意用5,看看有没有人在我前面搞事
关键在这里:doing_action 返回的是这个 action 当前是否正在执行中,而 $GLOBALS['wp_actions'] 记录的是已经执行完毕的。两个一对比,能画出一条粗糙的时间线。
第二招:劫持 $wp_filter 做"钩子族谱"
WordPress 的钩子队列存在 $wp_filter['hook_name'] 里,是个 WP_Hook 对象。我写了段"快照代码",在关键节点 dump 出某个钩子的完整回调队列:
function dump_hook_ancestry(string $tag, string $context = '') {
global $wp_filter;
if (!isset($wp_filter[$tag])) {
error_log("[{$context}] {$tag}: 空队列");
return;
}
$callbacks = [];
foreach ($wp_filter[$tag]->callbacks as $priority => $cb_group) {
foreach ($cb_group as $idx => $cb) {
$fn = $cb['function'];
$name = is_string($fn) ? $fn : (is_array($fn)
? get_class($fn[0]) . '::' . $fn[1]
: 'Closure#' . spl_object_id($fn));
$callbacks[] = [
'priority' => $priority,
'idx' => $idx,
'fn' => $name,
'accepted' => $cb['accepted_args'],
];
}
}
// 按优先级排序后输出
usort($callbacks, fn($a, $b) => $a['priority'] $b['priority']);
error_log("[{$context}] {$tag} 当前队列:\n" . print_r($callbacks, true));
}
这段代码我通常插在三个地方:注册回调前、预期触发点前、以及怀疑被移除后的检查点。有一次发现某个 SEO 插件在 template_redirect 优先级 999 里调了 remove_all_actions('wp_head'),把我塞的追踪代码一锅端了。
第三招:用"诱饵钩子"抓动态移除
更阴间的是运行时动态移除。你注册得好好的,某个第三方插件在特定条件下 remove_action 了你。这种我搞了个"诱饵"策略:
// 主回调,正常业务
add_action('my_critical_hook', 'my_real_handler', 10);
// 诱饵:高优先级先跑,记录"我是否还活着"
add_action('my_critical_hook', function() {
$has_real = has_action('my_critical_hook', 'my_real_handler');
if ($has_real === false) {
error_log('警报: my_real_handler 在队列中消失! 当前filter=' . current_filter());
// 紧急兜底:重新注册
add_action('my_critical_hook', 'my_real_handler', 10);
}
}, 1); // 优先级1,最先检查
这玩意救过我两次。一次是缓存插件在内存不足时"优化"掉了部分钩子,一次是某个"性能优化"插件自作聪明地清理了"非核心"回调。
第四招:给生命周期钩子做"分段染色"
插件自己的生命周期钩子(activate/deactivate/uninstall)特别容易出时序问题,因为 WordPress 在这些场景下的执行环境跟正常请求不一样。我的做法是显式染色:
register_activation_hook(__FILE__, function() {
// 标记当前处于激活流程
if (!defined('MY_PLUGIN_IN_ACTIVATION')) {
define('MY_PLUGIN_IN_ACTIVATION', true);
}
// 记录"谁调的我"
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8);
$callers = array_column($backtrace, 'function');
error_log('激活钩子调用链: ' . implode(' -> ', $callers));
// 实际业务...
});
// 然后在任何可能提前触发的代码里检查这个标记
add_action('init', function() {
if (defined('MY_PLUGIN_IN_ACTIVATION') && MY_PLUGIN_IN_ACTIVATION) {
error_log('警告: init 在激活流程中被触发,可能非预期');
}
});
有个冷知识:register_activation_hook 内部其实是通过 activate_{plugin} action 实现的,而这个 action 在某些多站点场景下会提前触发 wp_loaded,导致你以为"还没激活完"的代码其实已经跑在正常的请求流里了。
最后:我现在的调试起手式
遇到钩子行为异常,我现在按这个顺序排查,基本不绕弯路:
has_action/has_filter确认回调是否还在队列里current_filter+doing_action确认当前执行上下文- dump
$wp_filter[$tag]看优先级和回调顺序 - 检查是否有
remove_all_actions或remove_action的调用痕迹 - 用
debug_backtrace在可疑节点抓调用链
这套东西我封装成了一个小工具类,开发环境自动加载,线上通过 WP_DEBUG_LOG 控制开关。钩子调试最耗时间的是"猜",有了这些"现场证据",至少能把猜测范围缩到最小。
你们有没有遇到过更离谱的钩子时序问题?比如某个钩子在 CLI 和 Web 请求里行为不一致,或者多站点切换站点时的钩子"穿越"?

