`register_activation_hook` 里建表成功,前台却 404:我花了三小时才意识到 `flush_rewrite_rules` 的"时差谋杀"
上周给客户交付一个带自定义文章类型(CPT)的会员插件,本地一切正常,打包上传后激活——表建了、选项写了、菜单出了,前台访问 `/member/card/123` 直接 404。重启 PHP、清缓存、甚至重装插件,幽灵 404 纹丝不动。
最后发现凶手是 register_activation_hook 里那句 flush_rewrite_rules() 的调用时机。写出来给同样被坑过的兄弟提个醒。
案发现场还原
插件主文件里的激活逻辑大概长这样:
register_activation_hook( __FILE__, function() {
// 1. 建表
myplugin_create_tables();
// 2. 注册 CPT(其实这里写错了位置,但先按下不表)
myplugin_register_member_cpt();
// 3. 刷新重写规则
flush_rewrite_rules();
// 4. 写默认配置
update_option( 'myplugin_version', '1.0.0' );
} );
看起来没毛病?问题出在 第 2 步和第 3 步的"伪执行"。
陷阱一:register_post_type 在激活钩子内"注册即销毁"
register_activation_hook 是在插件文件被加载的 单次请求 内执行的。你在这里调用 register_post_type(),WordPress 确实会把它写进全局变量,但这个请求结束后,下次正常访问前台时,CPT 的注册要靠插件日常加载逻辑——也就是 init 钩子上的回调。
所以激活时 flush_rewrite_rules() 刷进去的规则集,根本不包含你的 CPT 路由,因为你还没在 init 上挂注册函数呢!它刷的是旧规则,或者更准确说,是"当前这次请求里临时存在、下次请求就不见了"的残缺规则。
陷阱二:flush_rewrite_rules() 的"假刷新"
更坑的是,flush_rewrite_rules() 默认参数是 true(硬刷新写入数据库),但它在激活钩子内部执行时,由于前面说的 CPT 未真正注册,写入的 rewrite_rules 选项值是不带你的自定义端点的。
然后你前台访问,WordPress 从数据库读出这套"干净"的规则,404 是必然的。
我的修复方案:把刷新推迟到"注册完成后"的第二次请求
改完后的结构分成三块:
// === 1. 日常加载:在 init 上注册 CPT ===
add_action( 'init', 'myplugin_register_member_cpt', 5 ); // 优先级 5,早点跑
function myplugin_register_member_cpt() {
register_post_type( 'member_card', [
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'member/card' ],
// ... 其他参数
] );
}
// === 2. 激活钩子:只负责"标记需要刷新",不直接刷 ===
register_activation_hook( __FILE__, function() {
myplugin_create_tables();
update_option( 'myplugin_version', '1.0.0' );
// 关键:设置一个标志位,而不是直接 flush
update_option( 'myplugin_flush_rewrite_flag', 1 );
} );
// === 3. 在 init 末尾检查标志位,此时 CPT 已注册,再刷才有效 ===
add_action( 'init', 'myplugin_maybe_flush_rewrites', 99 ); // 优先级 99,等所有注册跑完
function myplugin_maybe_flush_rewrites() {
if ( get_option( 'myplugin_flush_rewrite_flag' ) ) {
flush_rewrite_rules();
delete_option( 'myplugin_flush_rewrite_flag' );
}
}
核心逻辑:激活时只插旗子,下次 init 跑完(CPT 已稳)再真正刷新。这样数据库里写入的规则集才是完整的。
额外踩的第三个坑:多站点环境下 switch_to_blog 的副作用
客户环境是子目录多站点(/site1/、/site2/),我在网络激活时用了 switch_to_blog 循环建表,结果 flush_rewrite_rules() 在切回主站点后行为异常——它把子站点的规则写到了主站点,或者干脆没写。
最后把刷新逻辑完全剥离出网络激活循环,每个子站点靠访问时的 init 钩子自刷新,才彻底干净。
总结:三条铁律
1. 激活钩子内绝不直接 flush_rewrite_rules,除非你确定所有自定义路由已注册——而它们通常要在 init 才注册。
2. 用"延迟标志位"模式:激活时 update_option 插旗,init 末尾检测并执行刷新。
3. 多站点网络激活时,flush_rewrite_rules 和 switch_to_blog 不要混用,让每个站点按需自刷新。
这坑我踩了三小时,日志里没有错误、数据库里表也在、选项也写了,就是 404。最难查的 bug 从来不是报错,是"一切正常但结果不对"。

