本文约 5,200 字,预计阅读 13 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:代码没改,功能自己就不灵了
某天你发现:文章底部的「相关文章」模块时不时出现,SEO 插件的描述偶尔不输出,某个短代码有时渲染成纯文本。代码一行没动,重启一次又好了,过两天又坏。
这类偶发的八成是钩子优先级冲突:多个插件在同一个钩子上挂了回调,做互相矛盾的事情,谁最后执行谁说了算。而「谁最后执行」取决于注册顺序——注册顺序又取决于插件加载顺序——插件加载顺序取决于 active_plugins 数组里的排列,而这个数组在你停用再启用某个插件时就会重排。于是「重启一下又好了」得到了完美解释。
原理分析:优先级的三条排序规则
WordPress 的每个钩子都有一个 WP_Hook 对象,内部是 callbacks[优先级][回调唯一ID] = 回调信息 的二维数组。执行时规则只有三条:
- 优先级数字小的先执行(
add_action( $tag, $cb, 5 )比10先跑),默认优先级是10; - 同一优先级内,按注册顺序执行(先
add_action的先跑); - 优先级相同、回调相同(同一函数挂两次且参数个数相同)会被后来的覆盖——
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 );
常见踩坑
-
在
plugins_loaded上 remove_action:此时目标插件还没add_action,移除无效。稳妥时机是wp_loaded(优先级 0)或目标钩子自身的前置时机。 -
remove_action优先级不匹配:remove_action( 'x', 'y' )不带优先级时默认是 10,如果对方注册在 20,移除失败而且不报错。 -
案例: 某站装了两个翻译插件,都挂
the_content(优先级 10)。站点把其中一个停用再启用后,active_plugins数组重排,后加载的插件把先加载的译文再翻译一遍,前台出现「Welcome → 欢迎 → 歡迎光臨」这种二次翻译。最后用remove_all_filters( 'the_content', 10 )清掉其中一个的回调,只留一个生效。 -
accepted_args写错:add_action('save_post', 'fn', 10, 1)里函数体却用了$post和$update两个参数,第二个永远是null,数据同步写错字段。 -
wp_head里 echo 大段内容被缓存插件截断:优先级无关,但经常被误认为优先级问题,实际是输出缓冲。 -
同一个函数挂两次被静默去重:
add_filter( 'the_content', 'my_fn' )写两遍只会执行一次,别指望靠写两遍让内容出现两次。 -
优先级的语义不适用于
register_activation_hook之类的一次性钩子:那些只有一次触发,谈不上抢顺序。WordPress 常用钩子执行顺序
总结
判断标准:偶发 = 顺序不确定(同优先级抢位);100% 复现 = 优先级冲突或被覆盖;移除无效 = 时机不对或目标是闭包。
行动清单:先用钩子导出脚本看清楚谁挂在哪个优先级上;移除操作统一放 wp_loaded(优先级 0),并带上正确的优先级数字;闭包只能 remove_all_filters;自己要抢最后就用 PHP_INT_MAX,但优先推荐「自定义钩子做编排」这种显式顺序;改完用 has_filter() 和 wp eval 的 do_action 验证顺序,而不是靠刷新页面看运气。
