Zsens Admin 插件钩子调试:我造了个"生命周期探针"把 `init` 到 `shutdown` 的 47 个挂载点全可视化之后,才发现问题出在优先级"撞车"而非"没挂上"

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

上周被个诡异 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_routerenqueue_assets 都是我写的,但中间插了个第三方插件的 license 校验。更坑的是那个 license 校验里有个 wp_redirect 没加 exit,导致后续钩子继续跑,但 header 已发送,我的 wp_enqueue_script 实际失效了。

修复不是改优先级就完事——得把 boot_router 提到 0PHP_INT_MINenqueue_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
}

三、生命周期"断点":哪些钩子之间不能存数据

另一个踩坑点是假设 initadmin_menu 之间可以往 $GLOBALS 塞东西。实际上如果某个安全插件在 init 优先级 1wp_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 靠谱得多。

你们有没有遇到过"钩子明明挂了但执行顺序完全不对"的诡异情况?或者更好的可视化方案?欢迎砸过来。

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