钩子挂载顺序"乱战"实录:我用 `doing_action` 与 `current_filter` 搭了一套"钩子现场勘查"工具链

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 89 浏览 0 回复

写插件久了,最烦的不是钩子挂不上,而是挂上了、跑了、但跑的不是你以为的那个时机。上周重构一个支付回调插件,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,导致你以为"还没激活完"的代码其实已经跑在正常的请求流里了。

最后:我现在的调试起手式

遇到钩子行为异常,我现在按这个顺序排查,基本不绕弯路:

  1. has_action/has_filter 确认回调是否还在队列里
  2. current_filter + doing_action 确认当前执行上下文
  3. dump $wp_filter[$tag] 看优先级和回调顺序
  4. 检查是否有 remove_all_actionsremove_action 的调用痕迹
  5. debug_backtrace 在可疑节点抓调用链

这套东西我封装成了一个小工具类,开发环境自动加载,线上通过 WP_DEBUG_LOG 控制开关。钩子调试最耗时间的是"猜",有了这些"现场证据",至少能把猜测范围缩到最小。

你们有没有遇到过更离谱的钩子时序问题?比如某个钩子在 CLI 和 Web 请求里行为不一致,或者多站点切换站点时的钩子"穿越"?

评论0
回复 · 0
还没有回复
微信客服 微信客服