Zsens Admin 插件钩子"时序迷宫":我用 `all` 钩子做"时间切片"才抓到那个在 `muplugins_loaded` 和 `plugins_loaded` 之间偷跑的"内鬼"

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

上周被个邪门问题折磨了一下午:我的插件在 A 站正常,在 B 站某个回调死活不触发。`add_action` 执行了,`has_action` 返回真值,优先级没撞车,命名空间没冲突——但就是不进断点。

最后靠往 `wp-includes/plugin.php` 里临时塞 `error_log` 才破案:B 站跑了个 MU 插件,在 `muplugins_loaded` 里提前触发了某个我依赖的钩子,而我的插件还在 `plugins_loaded` 里排队。时序差了一个身位,钩子已经跑完一轮了,我的挂载才姗姗来迟。

这事后我搞了个"生命周期探针"的轻量版,专门用来抓这种"时序幽灵"。核心思路是用 `all` 钩子做全局监听,把每个钩子的触发时机、参数指纹、调用栈切片记下来。不是生产环境用的,是本地调试时快速定位"谁抢了先":

// 塞在 MU 插件或当前插件最前面,wp-config 里 define('ZSENS_PROBE', true);
if (defined('ZSENS_PROBE') && ZSENS_PROBE) {
    $GLOBALS['_zsens_probe_log'] = [];
    $GLOBALS['_zsens_probe_start'] = microtime(true);
    
    add_action('all', function ($tag) {
        $now = microtime(true) - $GLOBALS['_zsens_probe_start'];
        $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8);
        $caller = '';
        
        // 往上爬,跳过 wp-includes/plugin.php 本身
        foreach ($backtrace as $frame) {
            if (isset($frame['file']) && false === strpos($frame['file'], 'plugin.php')) {
                $caller = basename($frame['file']) . ':' . ($frame['line'] ?? '?');
                break;
            }
        }
        
        $GLOBALS['_zsens_probe_log'][] = [
            'time_ms'   => round($now * 1000, 2),
            'hook'      => $tag,
            'caller'    => $caller,
            'priority'  => $GLOBALS['wp_filter'][$tag]->current_priority ?? 'n/a',
        ];
    }, PHP_INT_MIN); // 抢在最前面
}

然后在 `shutdown` 或者你怀疑的钩子末尾输出:

add_action('shutdown', function () {
    if (!defined('ZSENS_PROBE') || !ZSENS_PROBE) return;
    
    $target = $_GET['probe_target'] ?? 'init'; // 关注某个钩子
    $entries = array_filter($GLOBALS['_zsens_probe_log'], function ($e) use ($target) {
        return $e['hook'] === $target;
    });
    
    // 按时间排序,看同一钩子被触发了几次、分别是谁干的
    usort($entries, fn($a, $b) => $a['time_ms'] <=> $b['time_ms']);
    
    error_log("=== PROBE: {$target} triggered " . count($entries) . " time(s) ===");
    foreach ($entries as $e) {
        error_log(sprintf(
            "[%.2f ms] priority=%s caller=%s",
            $e['time_ms'],
            $e['priority'],
            $e['caller']
        ));
    }
}, PHP_INT_MAX);

这玩意帮我抓到过几个典型场景:

1. "早产"钩子

MU 插件在 `muplugins_loaded` 里直接 `do_action('init')`——别笑,真见过。我的探针输出里 `init` 在 0.5ms 就亮了,而正常应该在 50ms 之后。这种时候你的 `plugins_loaded` 挂载再快也追不上。

2. "多胞胎"钩子

同一个钩子被触发了两次,但来自不同文件。一次是核心正常流程,一次是某个插件里手贱的 `do_action('init')`。你的回调被执行了两次,第二次可能参数已经被前面的钩子改得面目全非。

3. 优先级"伪碰撞"

你以为优先级 10 和 11 是安全的?探针显示两个回调都在 10 上,因为其中一个用了字符串 `'10'` 被 PHP 弱类型转成 10,另一个也是 10,执行顺序变成注册先后——而注册顺序又受 `plugins_loaded` 时序影响。

现在我的调试流程变成:遇到"挂不上"先不急着查 `has_action`,而是 `?probe_target=那个钩子` 跑一遍,看时间轴上它到底出现了几次、每次谁发起的。比 Xdebug 轻量,比 `var_dump` 靠谱。

有个细节:`all` 钩子本身也会被 `do_action('all')` 触发,所以上面代码里我没在探针内部再记 `all`,不然递归爆炸。另外 `PHP_INT_MIN` 和 `PHP_INT_MAX` 是故意用的极端值,确保探针不被其他优先级干扰。

你们平时怎么抓这种"时序幽灵"的?我试过 `QM_Collectors` 但太重,自己写又怕漏。这版探针算是个折中,有更好思路的求拍。

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