搜索全站
插件 13 分钟阅读

WordPress 钩子优先级(priority):冲突导致功能失效排查

插件功能时灵时不灵,多半是两个回调抢同一个钩子。本文讲清 WordPress 钩子优先级的排序规则,给出回调清单导出代码、remove_action 生效时机与重排方案。

U
UFO_PF 2019-03-24 · 阅读 13 分钟
Wo 插件
阅读提示
本文约 5,200 字,预计阅读 13 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:代码没改,功能自己就不灵了

某天你发现:文章底部的「相关文章」模块时不时出现,SEO 插件的描述偶尔不输出,某个短代码有时渲染成纯文本。代码一行没动,重启一次又好了,过两天又坏。

这类偶发的八成是钩子优先级冲突:多个插件在同一个钩子上挂了回调,做互相矛盾的事情,谁最后执行谁说了算。而「谁最后执行」取决于注册顺序——注册顺序又取决于插件加载顺序——插件加载顺序取决于 active_plugins 数组里的排列,而这个数组在你停用再启用某个插件时就会重排。于是「重启一下又好了」得到了完美解释。

原理分析:优先级的三条排序规则

WordPress 的每个钩子都有一个 WP_Hook 对象,内部是 callbacks[优先级][回调唯一ID] = 回调信息 的二维数组。执行时规则只有三条:

  1. 优先级数字小的先执行(add_action( $tag, $cb, 5 ) 比 10 先跑),默认优先级是 10;
  2. 同一优先级内,按注册顺序执行(先 add_action 的先跑);
  3. 优先级相同、回调相同(同一函数挂两次且参数个数相同)会被后来的覆盖——add_filter 的去重机制。

还有一条关于参数:add_action 的第四个参数 $accepted_args 决定回调能拿到几个参数。设成 1 却想拿第二个参数,拿到的永远是 null,这是「回调执行了但结果不对」的常见原因。

典型冲突场景:

钩子 冲突双方 表现
the_content 懒加载插件(20) vs 内容加密插件(10) 图片被加密后懒加载失效
wp_head 插件 A 输出 canonical(1) vs SEO 插件(1) 两条 canonical,或后者覆盖前者
template_redirect 两个重定向插件(同为 10) 跳转目标随机
init 自定义文章类型注册(0) vs 权限插件(0) 权限规则拿不到未注册的 post type
save_post 缓存清除插件(10) vs 数据同步插件(10) 清了缓存才同步,前台拿到旧数据

remove_action() / remove_filter() 的两条硬规则:

  • 必须在被移除的回调注册之后调用。插件在 init(10)注册,你在 plugins_loaded 上移除,那时它还没注册,移除无效;
  • 闭包(匿名函数)无法用 remove_action 移除,因为拿不到函数名。只能用 remove_all_actions( $tag, $priority ) 或 remove_all_filters( $tag ) 一把梭。

保姆级实操

第一步:导出某个钩子上的全部回调

<?php
// mu-plugins/hook-dump.php(排查完删除)
add_action( 'shutdown', function () {
    if ( ! current_user_can( 'manage_options' ) || empty( $_GET['mmf_hook'] ) ) {
        return;
    }
    $tag = sanitize_key( $_GET['mmf_hook'] );
    global $wp_filter;

    if ( empty( $wp_filter[ $tag ] ) ) {
        echo "钩子 {$tag} 上没有任何回调";
        return;
    }

    printf( "<pre>### %s (已执行次数: %d)\n", $tag, did_action( $tag ) );
    foreach ( $wp_filter[ $tag ]->callbacks as $priority => $callbacks ) {
        foreach ( $callbacks as $id => $cb ) {
            if ( is_object( $cb['function'] ) ) {
                $name = 'closure(无法 remove_action)';
            } elseif ( is_array( $cb['function'] ) ) {
                $obj  = is_object( $cb['function'][0] ) ? get_class( $cb['function'][0] ) : $cb['function'][0];
                $name = $obj . '::' . $cb['function'][1];
            } else {
                $name = (string) $cb['function'];
            }
            printf( "%-6s %-55s args=%d  id=%s\n", $priority, $name, $cb['accepted_args'], $id );
        }
    }
    echo '</pre>';
} );

访问 https://example.com/?mmf_hook=the_content(需管理员登录)即可看到完整清单,包括优先级、回调名和参数个数。did_action() 还能告诉你这个钩子在本次请求里已经跑过几次——如果是 0,说明你查的页面根本没触发它。

第二步:把冲突的回调摘掉

<?php
// 关键:必须在对方注册之后执行。插件多在 init(10) 注册,我们在 wp_loaded(0) 摘除
add_action( 'wp_loaded', function () {
    // 1) 普通函数:直接按名字摘
    remove_filter( 'the_content', 'plugin_a_lazyload', 20 );

    // 2) 类方法:必须拿到同一个实例
    remove_action( 'wp_head', [ Plugin_B::get_instance(), 'output_canonical' ], 10 );

    // 3) 闭包:认不出来,只能按优先级整段清掉
    remove_all_filters( 'template_redirect', 10 );
}, 0 );

第三步:把自己挪到最后(或最前)执行

<?php
// 最后执行:确保前面所有插件都改完了,我们再收尾
add_filter( 'the_content', 'mmf_final_touch', PHP_INT_MAX );

function mmf_final_touch( $content ) {
    if ( ! is_singular( 'post' ) ) {
        return $content;
    }
    return $content . '<p class="post-tail">— 正文结束 —</p>';
}

// 最前执行:抢在别人之前插入(例如抢先输出 canonical)
add_action( 'wp_head', 'mmf_early_canonical', 0 );

用 PHP_INT_MAX 或负数(如 -1)虽然能抢到位置,但属于「我不管别人」的强硬做法,只应在自己的站点上用,不要写进准备分发的插件里。

第四步:验证执行顺序

# 用 wp eval 直接跑一次钩子,看执行顺序
wp eval '
add_action( "mmf_test", function(){ echo "A(10)\n"; }, 10 );
add_action( "mmf_test", function(){ echo "B(5)\n"; }, 5 );
add_action( "mmf_test", function(){ echo "C(10)\n"; }, 10 );
add_action( "mmf_test", function(){ echo "D(PHP_INT_MAX)\n"; }, PHP_INT_MAX );
do_action( "mmf_test" );
// 预期输出:B(5) A(10) C(10) D(PHP_INT_MAX)'

# 检查某个回调是否还挂在钩子上
wp eval 'var_dump( has_filter( "the_content", "plugin_a_lazyload" ) );'
wp eval 'var_dump( has_action( "wp_footer" ) );'

第五步:处理跨插件的强顺序依赖

如果确实必须「插件 A 一定在插件 B 之后」,比 priority 数字更稳的方式是用自定义钩子做编排:让 A 把自己的逻辑挂在一个只有你自己 do_action 的钩子上,由你在确定的时机触发它。

// 在 B 执行完毕后(wp_footer 优先级 20)触发自定义钩子
add_action( 'wp_footer', function () {
    do_action( 'mmf_after_all_plugins' );
}, 20 );

// A 挂到自定义钩子上,顺序完全可控
add_action( 'mmf_after_all_plugins', 'plugin_a_sync_data', 10 );

常见踩坑

  1. 在 plugins_loaded 上 remove_action:此时目标插件还没 add_action,移除无效。稳妥时机是 wp_loaded(优先级 0)或目标钩子自身的前置时机。

  2. remove_action 优先级不匹配:remove_action( 'x', 'y' ) 不带优先级时默认是 10,如果对方注册在 20,移除失败而且不报错。

  3. 案例: 某站装了两个翻译插件,都挂 the_content(优先级 10)。站点把其中一个停用再启用后,active_plugins 数组重排,后加载的插件把先加载的译文再翻译一遍,前台出现「Welcome → 欢迎 → 歡迎光臨」这种二次翻译。最后用 remove_all_filters( 'the_content', 10 ) 清掉其中一个的回调,只留一个生效。

  4. accepted_args 写错:add_action('save_post', 'fn', 10, 1) 里函数体却用了 $post 和 $update 两个参数,第二个永远是 null,数据同步写错字段。

  5. wp_head 里 echo 大段内容被缓存插件截断:优先级无关,但经常被误认为优先级问题,实际是输出缓冲。

  6. 同一个函数挂两次被静默去重:add_filter( 'the_content', 'my_fn' ) 写两遍只会执行一次,别指望靠写两遍让内容出现两次。

  7. 优先级的语义不适用于 register_activation_hook 之类的一次性钩子:那些只有一次触发,谈不上抢顺序。WordPress 常用钩子执行顺序

总结

判断标准:偶发 = 顺序不确定(同优先级抢位);100% 复现 = 优先级冲突或被覆盖;移除无效 = 时机不对或目标是闭包。

行动清单:先用钩子导出脚本看清楚谁挂在哪个优先级上;移除操作统一放 wp_loaded(优先级 0),并带上正确的优先级数字;闭包只能 remove_all_filters;自己要抢最后就用 PHP_INT_MAX,但优先推荐「自定义钩子做编排」这种显式顺序;改完用 has_filter() 和 wp eval 的 do_action 验证顺序,而不是靠刷新页面看运气。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。