`plugins_loaded` 到 `shutdown` 之间到底埋了多少"暗桩":我如何用一段"钩子听诊器"代码把生命周期可视化

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

写插件久了,最烦的不是逻辑错,是钩子挂错了时机——回调跑了,但依赖的服务还没注册;或者更阴的是,你的钩子其实跑了两次,你压根不知道。今天把我折腾出来的一套"生命周期听诊器"分享出来,专门用来治这种"时机盲症"。

先上核心问题:WordPress 插件生命周期不是线性的

很多人脑子里是一张图:plugins_loadedinitwp_loaded → ... → shutdown。实际跑起来,在 CLI、AJAX、REST、后台四种场景下,这张图的形状完全不一样。比如 REST 请求可能根本不走 wp 这个主查询钩子,后台的 admin_init 和前端的 template_redirect 是互斥的。

我之前踩的一个坑:在 init 里注册自定义 post type,然后在 rest_api_init 里给它挂 REST 字段。本地看着没问题,上线后偶发字段缺失。最后发现是 object cache 环境下,某个边缘请求走了提前的 REST 调用,那时候 init 还没跑到我的优先级。

我的解法:不记日志,直接"染色"

不是打 error_log,而是在每个关键钩子点埋一个轻量标记,最后输出一张"执行热力图"。代码很糙但管用:

class Lifecycle_Stethoscope {
    private static $timeline = [];
    private static $start_time;
    
    public static function init() {
        self::$start_time = microtime(true);
        
        // 要监听的核心钩子,按真实执行顺序分组
        $hooks = [
            'muplugins_loaded', 'plugins_loaded',
            'setup_theme', 'after_setup_theme',
            'init', 'widgets_init', 'register_sidebar',
            'wp_loaded', 'parse_request', 'send_headers',
            'parse_query', 'pre_get_posts', 'wp',
            'template_redirect', 'get_header', 'wp_enqueue_scripts',
            'wp_head', 'loop_start', 'the_post',
            'wp_footer', 'shutdown',
            // 后台特有
            'admin_init', 'admin_menu', 'admin_enqueue_scripts',
            'load-*', // 动态页面钩子
            // REST 特有
            'rest_api_init', 'rest_pre_dispatch',
            // AJAX 特有
            'admin_ajax_*',
        ];
        
        foreach ($hooks as $hook) {
            add_action($hook, [__CLASS__, 'tick'], PHP_INT_MIN);
            add_action($hook, [__CLASS__, 'tick'], PHP_INT_MAX);
        }
        
        // 最后输出
        add_action('shutdown', [__CLASS__, 'dump'], PHP_INT_MAX + 1);
    }
    
    public static function tick() {
        $hook = current_filter();
        $priority = has_filter($hook) ? 
            $GLOBALS['wp_filter'][$hook]->current_priority() : 'n/a';
        
        self::$timeline[] = [
            'hook'      => $hook,
            'priority'  => $priority,
            'time'      => round((microtime(true) - self::$start_time) * 1000, 3),
            'memory'    => round(memory_get_usage() / 1024 / 1024, 2),
            'doing'     => implode(',', array_slice($GLOBALS['wp_current_filter'], -3)),
        ];
    }
    
    public static function dump() {
        if (!defined('WP_DEBUG') || !WP_DEBUG) return;
        
        $html = "<script>console.table(" . json_encode(self::$timeline) . ");</script>";
        // CLI 环境下直接输出
        if (php_sapi_name() === 'cli') {
            foreach (self::$timeline as $row) {
                printf("[%6.2fms] %-25s p=%-4s %6.2fMB 栈:%s\n",
                    $row['time'], $row['hook'], $row['priority'],
                    $row['memory'], $row['doing']
                );
            }
        } else {
            echo $html;
        }
    }
}

// 挂到最早能挂的地方
add_action('muplugins_loaded', [Lifecycle_Stethoscope::class, 'init'], PHP_INT_MIN);

这段代码暴露了什么

跑一遍你会发现很多反直觉的事:

1. init 钩子上的回调,优先级 0 和 10 之间可能隔着几十毫秒——不是 WordPress 慢,是某个插件在 init 优先级 5 的地方做了数据库升级检查。

2. template_redirect 之后、get_header 之前,经常有一大块空白时间——那是主题在 functions.php 里搞事情,或者更常见的,你在 wp_enqueue_scripts 里注册了脚本,但实际的 wp_print_scripts 还没执行。

3. REST 请求里,rest_api_init 的执行时机相对于 init 的优先级,在不同端点下会漂移——取决于 rest_route 的匹配复杂度。

实战:用"染色"定位钩子冲突

上周遇到个真事:两个插件都注册了 save_post,A 插件更新完自定义字段后调了 wp_update_post,触发了 B 插件的 save_post,B 又更新了别的字段,结果 A 的第二次进入以为是用户手动保存,把字段回滚了。

用听诊器一看,同一个 save_post 钩子,执行栈里出现了 save_post → save_post → save_post,深度 3 层。立刻明白是递归触发,而不是并发。

我的修复不是加锁,是改时机:A 插件的字段更新挪到 wp_insert_post_data 过滤器,在数据入库前修改,避开 save_post 的触发链。

进阶:给特定回调做"切片"

知道钩子什么时候跑还不够,得知道"我的回调"在钩子队列里的什么位置。加一段:

public static function mark_callback($hook, $callback_name, $phase = 'start') {
    self::$timeline[] = [
        'hook'      => $hook . '::' . $callback_name,
        'type'      => $phase, // 'start' | 'end'
        'time'      => round((microtime(true) - self::$start_time) * 1000, 3),
        'memory'    => round(memory_get_usage() / 1024 / 1024, 2),
    ];
}

// 用法:在自己的回调首尾插桩
function my_expensive_thing() {
    Lifecycle_Stethoscope::mark_callback('init', 'my_expensive_thing', 'start');
    // ... 逻辑
    Lifecycle_Stethoscope::mark_callback('init', 'my_expensive_thing', 'end');
}

这样能直接算出单个回调的耗时和内存增量,比 Xdebug 轻量,比盲猜靠谱。

几个挂钩子的时机建议(血泪换的)

- 注册 post type / taxonomy:init 优先级 ≤ 5,确保后面 register_meta 和 REST 字段注册时类型已存在

- 改主查询:pre_get_posts,但先判断 $query->is_main_query(),后台列表页也是主查询

- 注册 REST 字段:别挂 rest_api_init,挂 init 优先级 20+,让 post type 先注册完;REST 内部会缓存 schema,晚注册等于没注册

- 改权限判断:尽量用 map_meta_cap 而不是 user_has_cap,后者在太多地方被调用,容易性能爆炸

- 清理/埋点:shutdown 不是万能的,CLI 环境下如果提前 exit,shutdown 可能不跑;关键清理用 register_shutdown_function 双保险

最后

这套听诊器代码我扔在 mu-plugin 里,开发环境常驻,生产环境靠 WP_DEBUG 开关。它解决不了所有问题,但至少让你在问"为什么我的钩子没跑"之前,先确认一件事:钩子到底跑了没有,以及,在它该跑的时候,世界是不是已经准备好了。

你们有什么"钩子时机"的诡异

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