从堆栈里一眼认出"凶手":我整理的插件开发高频 Exception 速查手册与"三段式"定位法
写插件最烦的不是报错,是报错信息跟实际犯错的地方八竿子打不着。上周有个兄弟在群里甩了张图,Uncaught Error: Call to a member function get_items() on null,他盯着那行代码看了两小时,其实问题出在二十行之外的一个提前 return。这种"借尸还魂"式的异常,我踩过太多次了,整理了一份自己用的速查+定位流程,分享出来。
一、先认"脸":六种我每周都能见到的 Exception 真身
下面这六种,覆盖了插件开发里大概七成的情况,我把它们分成"自己作的"和"环境坑的"两类:
【自己作的】
Fatal error: Uncaught Error: Class 'MyPlugin\SomeClass' not found
九成九是 autoloader 没注册对,或者命名空间写劈叉了。我现在的习惯:先 var_dump(class_exists('MyPlugin\SomeClass')),false 的话直接去查 composer.json 的 psr-4 映射或者手动 spl_autoload_register 的回调路径。
Fatal error: Cannot declare class MyPlugin\Admin, because the name is already in use
经典重复加载。插件里如果用了 require_once 但文件里又套了 class_exists 判断,很容易搞出这种"我以为我防了但其实没防住"的情况。我的做法:所有类文件顶部统一加 if ( class_exists( __NAMESPACE__ . '\Admin' ) ) { return; },比依赖 _once 后缀靠谱。
Warning: array_merge(): Expected parameter 2 to be an array, null given
过滤钩子回调里最常见的"空值炸弹"。比如 apply_filters( 'myplugin_settings', [] ) 结果某个回调 return 了个 null。我现在写过滤钩回调必做类型检查:return is_array( $settings ) ? array_merge( $settings, $mine ) : $mine;
【环境坑的】
Fatal error: Maximum execution time of 30 seconds exceeded
插件里批量处理数据时必遇。WordPress 的 set_time_limit() 在 safe mode 或某些主机商会失效,别信它。我的保底方案:拆队列,用 wp_schedule_single_event 或者 Action Scheduler,单次处理不超过 50 条。
Warning: Cannot modify header information - headers already sent
BOM 头、?> 后面的空行、echo 调试语句没删——这三剑客我全中过。最阴的是某些编辑器自动加的 BOM,hexdump -C file.php | head 一眼能看到 EF BB BF 开头。
wpdb::prepare was called incorrectly
这货不是 Exception 是 notice,但值得单拎出来。$wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $id ) 这样写,如果 $table 是变量拼接的,就会触发。正确姿势:$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mytable WHERE id = %d", $id ),表名用 {$wpdb->prefix} 硬拼,只把值交给 prepare。
二、三段式定位:从报错行到真凶的"逆推链"
我现在的调试流程固定三步,不瞎猜:
第一段:看"第一帧"还是"最后一帧"
PHP 的堆栈是从下往上长的,Fatal error 那行是"爆炸点",但原因往往在更下面。比如:
Fatal error: Uncaught Error: Call to undefined function myplugin_do_thing()
in /wp-content/plugins/myplugin/includes/class-worker.php:42
Stack trace:
#0 /wp-content/plugins/myplugin/includes/class-admin.php(88): MyPlugin\Worker->run()
#1 /wp-includes/class-wp-hook.php(324): MyPlugin\Admin->process_request('')
#2 /wp-includes/plugin.php(205): WP_Hook->apply_filters('', Array)
爆炸点在 Worker::run(),但 myplugin_do_thing() 为啥没定义?看 #1 的 Admin::process_request,八成是那个方法在不该触发的时候被钩子拉起来了——比如 admin_init 里没判断 $_REQUEST['page'],导致每次后台请求都跑一遍,某些场景下依赖的函数还没加载。
第二段:grep 找"谁最后碰过它"
如果是变量变成预期外的类型(null、false、WP_Error),直接搜赋值链:
grep -rn "my_var\s*=" --include="*.php" .
重点看有没有分支没覆盖到。我见过最离谱的是一个 switch 少了 default,某个新加的 post type 没匹配上,变量就裸奔出去了。
第三段:隔离复现,二分法砍代码
把插件目录复制一份改个名字, deactivate 原插件,启用副本。然后注释掉一半钩子,报错还在就再注释一半,直到定位到最小复现单元。这比加 var_dump 到处喷干净得多,尤其 production 环境只能改文件不能动配置的时候。
三、两个我定制的"防呆"小工具
1. 开发模式的"严格壳"
我在插件入口加了个开关,开发时强制把 warning 抬成 exception,不让它们默默溜走:
if ( defined( 'MYPLUGIN_DEV' ) && MYPLUGIN_DEV ) {
set_error_handler( function( $severity, $message, $file, $line ) {
if ( $severity & E_WARNING || $severity & E_NOTICE ) {
throw new ErrorException( $message, 0, $severity, $file, $line );
}
return false;
});
}
这样 Trying to access array offset on value of type null 这种 PHP 7.4+ 的 notice 会直接炸出来,而不是变成某个后续逻辑里的"随机数"。
2. 自定义 Exception 带上下文
插件里抛异常时,我把当时的用户角色、当前钩子、请求参数一起打包:
class MyPlugin_Exception extends Exception {
public function __construct( $message, $context = [] ) {
$this->context = $context;
parent::__construct( $message );
}
public function get_log_entry() {
return sprintf(
'[%s] %s | hook: %s | user: %d | context: %s',
current_time( 'mysql' ),
$this->getMessage(),
current_filter(),
get_current_user_id(),
wp_json_encode( $this->context )
);
}
}
配合 error_log( $e->get_log_entry() ),production 环境下也能快速还原现场,不用跟用户要账号密码去复现。
四、一个最近的真实案例
上周接手的报错:Uncaught TypeError: Argument 1 passed to MyPlugin\Exporter::output() must be of the type array, string given
堆栈指向 Exporter::output() 的形参类型声明。但传进来的字符串是啥?顺着调用链往上,是 get_option( 'myplugin_export_config' ) 的返回值。去数据库一看,这 option 的值是序列化后的字符串 'a:0:{}'——但 get_option 没反序列化成功,原样返回了。
根因?另一个开发者在 update_option 之前手贱做了 maybe_serialize,导致双重序列化。get_option 只反序列化一次,外层还是字符串。
这种"类型对不上"的报错,真凶