`admin_init` 与 `wp_loaded` 之间那道"玻璃门":我是怎么用 `shutdown` 钩子做"临终遗言日志"的
写插件久了,你会发现有些钩子像邻居家的猫——看着温顺,实际边界感极强。admin_init 和 wp_loaded 就是典型,一个在后台门口晃悠,一个在前台后台都露面,但中间那道分界,我踩过不止一次。
先说个血案。去年做个数据同步插件,需要在 WordPress 完全跑起来之后、但页面还没渲染之前,抓一波当前用户的权限状态做预校验。我顺手挂在了 admin_init,本地测试丝滑上线,结果用户反馈偶尔拿到的是上一个人的权限缓存。
扒了半天,发现 admin_init 触发时,当前用户对象理论上已经就绪,但某些多站点环境下、或者用了对象缓存插件的场景,wp_get_current_user() 返回的居然是 WP_User 的空实例——ID 为 0,角色数组为空。不是每次都复现,是那种"一千次里漏三次"的幽灵 bug。
根源在哪?admin_init 的触发时机在 wp-admin/admin.php 里,确实在 require_wp_db() 和 wp() 之后,但用户认证流程的某些边缘分支(比如从缓存恢复的 session)会延迟到更晚才真正落盘。而 wp_loaded 虽然更靠后,但它不分前台后台,你在里面写 admin-only 的逻辑,等于给前台每个请求都加了一层无谓的负重。
我现在的做法是分三级挂号:
// 第一级:只认证,不做事
add_action( 'set_current_user', function() {
// 这里只标记"用户已确认",或者把 user_id 写进静态属性
My_Plugin::note_user_ready( get_current_user_id() );
}, 20 ); // 注意优先级,某些插件会在 10 改用户
// 第二级:admin 专属,但用 wp_loaded 的后期阶段
add_action( 'wp_loaded', function() {
if ( ! is_admin() ) return;
// 真正的权限敏感操作放这里
My_Plugin::run_permission_gate();
}, 20 ); // 默认 10,给自己留缓冲
// 第三级:"临终遗言",专门抓漏网之鱼
add_action( 'shutdown', function() {
$errors = My_Plugin::get_deferred_errors();
if ( empty( $errors ) ) return;
// 写日志、发告警、或者给下一次请求埋个标记
error_log( '[MyPlugin] Shutdown catch: ' . wp_json_encode( $errors ) );
} );
重点说 shutdown。这个钩子太容易被忽视了——它不管前面有没有 fatal error,只要 PHP 执行到了收尾阶段,它基本都会跑。我把它当成"临终遗言"收集器:前面逻辑里遇到非致命异常不敢直接抛的,先存进静态数组,shutdown 里统一处理。
但 shutdown 也有坑。如果你在它里面调用 wp_redirect, headers 可能已经发送完毕,直接白屏或者 "headers already sent"。我的 workaround 是:
add_action( 'shutdown', function() {
if ( headers_sent() ) {
// 退而求其次,用 JS 跳转或者只记日志
echo '<script>location.href="' . esc_url( $fallback_url ) . '";</script>';
return;
}
wp_safe_redirect( $fallback_url );
exit; // shutdown 里 exit 是安全的,反正结束了
} );
调试这套生命周期的时候,我搭了个土办法"探照灯":在关键钩子入口插时间戳和内存占用,最后 shutdown 汇总输出。比 Xdebug 轻量,适合线上抓偶发问题:
class Hook_Profiler {
private static $marks = [];
public static function mark( $hook, $note = '' ) {
self::$marks[] = [
'hook' => $hook,
'time' => microtime( true ),
'mem' => memory_get_usage(),
'note' => $note,
];
}
}
// 用法:在感兴趣的钩子头部尾部各插一个
add_action( 'admin_init', function() {
Hook_Profiler::mark( 'admin_init', 'start' );
}, PHP_INT_MIN );
add_action( 'admin_init', function() {
Hook_Profiler::mark( 'admin_init', 'end' );
}, PHP_INT_MAX );
add_action( 'shutdown', function() {
$report = array_map( function( $m ) {
return sprintf( '[%s] %s @ %.4fs / %sKB',
$m['hook'],
$m['note'],
$m['time'] - $_SERVER['REQUEST_TIME_FLOAT'],
round( $m['mem'] / 1024 )
);
}, Hook_Profiler::$marks );
error_log( implode( "\n", $report ) );
} );
有个细节:优先级 PHP_INT_MIN 和 PHP_INT_MAX 不是炫技,是为了确保你的探针真的挂在同钩子的最前和最后。WordPress 的 $wp_filter 是按优先级排序的,这两个极值能帮你框住整个钩子的执行窗口。
最后提一嘴 wp_die。很多教程教你用它做权限拦截,但 wp_die 内部会触发 wp_die_handler 过滤器,然后直接 die——你的 shutdown 钩子依然能跑,但如果 wp_die 是在某个嵌套钩子中间被调用的,外层钩子的后续回调会被截断,这个要心里有数。
你们有没有在 admin_init 和 wp_loaded 之间踩过类似的"玻璃门"?或者 shutdown 还有什么更野的用法?


