钩子明明挂了却像"空气":一次 `add_action` 优先级负数引发的"静默覆盖"惨案
上周给插件加定时任务回调,明明 `add_action( 'my_custom_cron', [ $this, 'do_sync' ], 10 )` 写得规规矩矩,调试时却死活不触发。最诡异的是——错误日志干干净净,钩子注册函数返回值也是 `true`,仿佛这个钩子从未存在过。
花了四十分钟确认了三件事:CRON 表达式解析正确、WP-Cron 实际触发了、钩子的回调类实例化成功。最后把 `global $wp_filter` 打印出来,才在 `my_custom_cron` 键下面看到两坨东西:
// 我的插件
[10] => Array(
[my_plugin_do_sync] => Array(
[function] => Array(...)
[accepted_args] => 1
)
)
// 另一个"幽灵"同优先级条目,function 指向一个闭包
[10] => Array(
[0] => Array(
[function] => Closure Object(...)
[accepted_args] => 1
)
)
注意那个键名:`0`。WordPress 的 `$wp_filter` 是个 `WP_Hook` 对象,内部用数组存回调。当两个回调优先级相同且键名冲突时,后注册的会把先注册的覆盖,而不是追加。那个闭包来自另一个插件,它用了 `add_action( 'my_custom_cron', function(){ ... }, 10 )`——匿名函数在内部被转成字符串键时变成了 `0`,恰好和我的命名回调撞了槽位。
更坑的是:WordPress 4.7 之后 `WP_Hook` 的 `add_filter` 方法里有个 `while ( isset( $this->callbacks[ $priority ][ $idx ] ) )` 的自增逻辑,但前提是 idx 是数字。如果先有人塞了个字符串键(比如我的 `my_plugin_do_sync`),后面来个数字键 `0`,直接覆盖,不报错、不警告。
我的修复很粗暴:优先级改成 `-5`,确保跑在所有人前面。但真正的教训是——
排查钩子"假死"的三板斧:
1. 别信 `has_action` 的返回值,它只检查有没有,不检查你的那个在不在。直接 `var_dump( $wp_filter['钩子名']->callbacks )` 看全貌。
2. 给 `add_action`/`add_filter` 的回调起带插件前缀的具名函数,或者类方法数组。匿名函数在冲突排查时就是黑盒,键名还可能是 `0`、`1` 这种地雷。
3. 优先级别扎堆用 `10`。WordPress 生态里 `10` 是默认重灾区,负数或大于 `100` 的偏僻值反而安全。如果必须挤在中间,用 `spl_object_hash` 或自定义字符串确保键名唯一:
// 丑但保险
add_action( 'my_custom_cron', [ $this, 'do_sync' ], 10, 1 );
// 然后手动确认键名
$priority = 10;
$callback_id = _wp_filter_build_unique_id( 'my_custom_cron', [ $this, 'do_sync' ], $priority );
// 如果 $callback_id 是数字,说明撞了匿名函数的坑
这次踩坑最讽刺的是:问题不是"钩子没触发",而是"触发了别人的,没触发我的"。日志里没有异常,因为另一个插件的闭包正常执行了,只是干的事情和我的完全无关。这种"替身攻击"比直接报错难查十倍。
你们遇到过类似"静默覆盖"吗?我后来在 `mu-plugins` 里塞了个调试助手,自动扫描所有钩子的键名冲突,有需要的话我可以贴出来。