Zsens Admin 插件钩子"幽灵挂载":我的 `add_action` 明明执行了,回调却像丢进黑洞——原来是 `plugins_loaded` 时序与 MU 插件的"抢先注册"在打架

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

上周被一个很阴间的问题折腾到凌晨:我在插件主文件里写了句最基础的挂载,结果回调死活不执行,连报错都没有。

代码看起来毫无问题:

// zsens-admin.php
add_action('init', 'zsens_bootstrap', 5);

function zsens_bootstrap() {
    error_log('ZSENS: bootstrap fired');
    // ... 初始化逻辑
}

日志文件安静得像太平间。我先是怀疑优先级被别的插件盖了,把 5 改成 PHP_INT_MIN 都没用。然后怀疑是不是文件名没加载,在文件顶部加了个 die('here')——能输出,文件确实执行了。

最后是靠这个"钩子探针"才定位到:

add_action('all', function($tag) {
    static $fired = [];
    if (!isset($fired[$tag]) && strpos($tag, 'zsens') !== false) {
        $fired[$tag] = true;
        error_log("HOOK TRACE: {$tag} | priority check");
    }
});

探针显示 init 确实触发了,但我的回调不在挂载队列里。顺着 $wp_filter['init'] 翻,发现队列里有个匿名函数把我的命名函数"挤"到了后面,而且那个匿名函数里调了 remove_all_actions('init') 又自己加了一套。

罪魁祸首是客户站里一个 MU 插件(Must-Use),它在 mu-plugins/early-loader.php 里干了这事:

// MU 插件,总是最先加载
add_action('plugins_loaded', function() {
    // 客户想"重置"某些插件的初始化
    remove_all_actions('init');
    // 然后自己重新编排...
}, 0);

问题出在时序:MU 插件的 plugins_loaded 钩子比标准插件的 plugins_loaded 早触发,而我的 add_action('init', ...) 写在插件主文件顶层,PHP 解析时立即执行。看似没问题,但客户站用了个"插件加载器"MU 插件,它把所有标准插件的加载推迟到了 plugins_loaded 优先级 1,而那个"重置"逻辑挂在优先级 0

所以实际顺序是:

  1. MU 加载器解析,把我的 add_action('init', ...) 注册进 $wp_filter
  2. MU 重置脚本在 plugins_loaded 优先级 0 执行,remove_all_actions('init'),我的挂载被清掉
  3. MU 加载器在优先级 1 才真正 include 我的插件文件——但此时我的顶层代码已经执行过一次,再次 add_action 又会被清掉...

等等,这里我搞混了。实际更诡异:MU 加载器用的是 include_once,我的插件文件只被加载一次。真正的问题是我的 add_action 执行时,init 钩子还没被重置,但重置脚本执行时我的回调已经在队列里了,然后被一锅端。

解决思路不是改 MU 插件(客户不让动),而是让我的挂载时机"跳"出被清理的窗口。几种实测有效的方案:

方案一:延迟到更晚的钩子再挂 init

// 不直接挂 init,而是等 plugins_loaded 后期再挂
add_action('plugins_loaded', function() {
    add_action('init', 'zsens_bootstrap', 5);
}, 20); // 故意晚于那个重置脚本

这样即使 remove_all_actions('init') 执行过,我的 add_action 是在它之后补上去的。

方案二:用"自修复"包装器

如果不知道前面有什么妖魔鬼怪,可以做个防御性挂载:

function zsens_persist_init($callback, $priority = 10) {
    $hook = 'init';
    
    // 先正常挂
    add_action($hook, $callback, $priority);
    
    // 再挂一个"修复器"到 plugins_loaded 末尾
    add_action('plugins_loaded', function() use ($hook, $callback, $priority) {
        // 检查是否还在队列里(用 has_filter 不行,它只查回调存在性,不区分优先级)
        global $wp_filter;
        $still_there = false;
        if (isset($wp_filter[$hook])) {
            foreach ($wp_filter[$hook]->callbacks as $prio => $callbacks) {
                if ($prio == $priority) {
                    foreach ($callbacks as $cb) {
                        if ($cb['function'] === $callback) {
                            $still_there = true;
                            break 2;
                        }
                    }
                }
            }
        }
        
        if (!$still_there) {
            error_log('ZSENS: init was wiped, re-registering');
            add_action($hook, $callback, $priority);
        }
    }, PHP_INT_MAX);
}

// 使用
zsens_persist_init('zsens_bootstrap', 5);

方案三:调试期快速定位"谁删了我"

写一个临时追踪脚本,贴到 wp-config.php 同级的一个 debug-hooks.php 里,然后 MU 插件加载它:

// 临时调试:追踪 init 钩子的增删
add_action('init', function(){}, PHP_INT_MIN); // 占个位

add_action('all', function($tag) {
    if ($tag !== 'init') return;
    
    static $snapshot = null;
    global $wp_filter;
    
    $current = isset($wp_filter['init']) ? count($wp_filter['init']->callbacks) : 0;
    
    if ($snapshot !== null && $current < $snapshot) {
        $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8);
        $culprit = '';
        foreach ($trace as $frame) {
            if (isset($frame['file']) && strpos($frame['file'], 'wp-includes') === false) {
                $culprit = basename($frame['file']) . ':' . ($frame['line'] ?? '?');
                break;
            }
        }
        error_log("INIT HOOK SHRUNK: {$snapshot} -> {$current}, first non-core: {$culprit}");
    }
    $snapshot = $current;
});

这个帮我锁定了是 early-loader.php:23 干的。

一点经验

以前觉得 add_action 写了就等于"挂上了",现在会习惯性地在关键路径加这种断言:

// 开发模式断言
if (defined('ZSENS_DEBUG') && ZSENS_DEBUG) {
    add_action('wp_footer', function() {
        global $wp_filter;
        $has_bootstrap = false;
        if (isset($wp_filter['init'])) {
            foreach ($wp_filter['init']->callbacks as $callbacks) {
                foreach ($callbacks as $cb) {
                    if ($cb['function'] === 'zsens_bootstrap') {
                        $has_bootstrap = true;
                        break 2;
                    }
                }
            }
        }
        if (!$has_bootstrap) {
            trigger_error('ZSENS: bootstrap missing from init!', E_USER_WARNING);
        }
    });
}

钩子调试最烦的不是"没挂上",是你以为挂上了、它也执行过、但某个时机被悄悄抹了。特别是 remove_all_actionsremove_action 配合匿名函数的时候,堆栈里连个名字都看不到。

你们遇到过类似"挂载后消失"的情况吗?我好奇还有啥更隐蔽的时序陷阱。

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