`TypeError` 与 `Error` 混着抛,日志里只显示行号不显示堆栈:我整理的 PHP→JS 跨端异常"双语词典"
插件开发里最怕的不是报错,是报错信息"说了等于没说"。尤其是现在前后端混着写,PHP 抛个异常,AJAX 返回 500,浏览器控制台只收到 parsererror;或者 JS 里 await 了个 Promise,reject 回来的东西在 PHP 日志里完全没痕迹。两边各说各话,排查像猜谜。
这篇不聊具体业务,就整理我这一年攒下来的"双语对照"——同样一个根因,在 PHP 端和 JS 端分别长什么样,以及我最快定位到真凶的那条路径。
一、PHP 抛了,JS 只看到 200 或 500
最经典场景:admin-ajax.php 或者 REST 路由里,某个 WP_Query 参数写错了,PHP 直接 Fatal error。但前端 fetch 的 response.text() 拿到的是 HTML 错误页,JSON.parse 炸掉,JS 里变成 SyntaxError: Unexpected token <。
这时候别盯着 JS 看。我的习惯是:先给 PHP 那端加个"临终捕获"。
// 在插件入口或者特定路由回调顶部
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) return false;
throw new ErrorException($message, 0, $severity, $file, $line);
});
这样 E_WARNING 也能被转成异常接住,统一走你包的 try-catch,最终给前端返回标准 JSON:{'error': true, 'message': '...'}。前端再也不用猜 500 里头藏了什么。
反过来也成立:如果 JS 明明收到了 200,但 data.success 是 false,先检查是不是 PHP 里手动 wp_send_json_error() 时,把业务错误和系统错误混在一个通道里了。我现在的做法是分两层:success: false 只表示业务校验没过(比如积分不足),真正的异常走 HTTP 500,这样 Sentry 或者服务器日志能自动抓。
二、WP_Error 不是 Exception,throw 不出去
WordPress 很多核心函数返回 WP_Error 对象而不是抛异常。wp_insert_post、wp_update_user 都是这德行。新手容易写成:
$post_id = wp_insert_post($data);
// 没检查 is_wp_error,直接往下用,结果 $post_id 是对象,进数据库查询时炸出 TypeError
PHP 8 之后更狠,传错类型直接 TypeError,但错误信息是 WP_Error given, int expected,行号指在很下游的位置,根本不在 wp_insert_post 调用点。
我的补丁:给常用函数包个"硬壳"。
function my_plugin_insert_post(array $data): int {
$result = wp_insert_post($data, true); // 第二个参数 true,强制返回 WP_Error 而不是 0
if (is_wp_error($result)) {
throw new RuntimeException(
'wp_insert_post failed: ' . $result->get_error_message(),
0,
new Exception($result->get_error_data() ? json_encode($result->get_error_data()) : 'null')
);
}
return $result;
}
这样堆栈里能直接看到是谁传的脏数据,WP_Error 的原始信息塞在 previous 异常里也不丢。
三、JS 端的 undefined is not a function,根因在 PHP 输出的 HTML
插件后台页经常混用 PHP 生成初始数据和 JS 接管交互。如果 PHP 里某个 wp_localize_script 的变量因为权限问题没输出,JS 里 window.myPluginData.ajaxUrl 是 undefined,然后 fetch(undefined) 不是报错,是发了个请求到当前页 URL,拿到 HTML,再走一遍 JSON.parse 炸掉。
这条链路的错误信息在浏览器里是 Uncaught (in promise) SyntaxError,行号指在 response.json(),完全误导。
我现在给所有 fetch 包一层"验尸官":
async function apiFetch(url, options = {}) {
const response = await fetch(url, options);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
console.error('Expected JSON, got:', text.slice(0, 200));
throw new TypeError(`Server returned ${contentType}, status ${response.status}`);
}
return response.json();
}
这招帮我抓到过:安全插件拦截了 AJAX,返回 403 HTML 页;CDN 缓存了旧版本 JS,新 PHP 接口字段改名;甚至还有一次是服务器磁盘满了,PHP 输出了一半被截断,JSON 成了非法格式。
四、钩子里的"静默失败":异常被吞掉,页面白屏
WordPress 的 action/filter 回调如果抛异常,顶层通常有 try-catch 或者错误处理器,但位置很隐蔽。比如 do_action('wp_loaded') 里某个回调炸了,可能被 wp_die() 截住,也可能被主题的错误处理盖掉,表现就是页面加载到一半白屏,或者某个区块消失,error_log 里啥也没有。
我的笨办法:开发环境给所有核心钩子插"探针"。
add_action('all', function ($tag) {
if (!in_array($tag, ['wp_loaded', 'init', 'admin_init', 'rest_api_init'])) return;
$start = microtime(true);
$current = current_filter();
add_action($tag, function () use ($start, $tag) {
error_log("[HOOK_PROFILER] {$tag} reached after " . round((microtime(true) - $start) * 1000, 2) . "ms");
}, PHP_INT_MAX);
}, 1);
更狠一点,用 register_shutdown_function 抓未捕获异常:
register_shutdown_function(function () {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
error_log("[FATAL_SHUTDOWN] " . json_encode($error));
// 如果还在 AJAX 上下文,强行输出 JSON 别让前端挂死
if (wp_doing_ajax()) {
wp_send_json_error(['fatal' => $error['message'], 'file' => basename($error['file'])]);
}
}
});
这段代码在生产环境慎用,但开发调试时救过我很多次——尤其是那种"本地正常,测试环境白屏"的玄学问题。
五、快速定位的"三板斧"
现在遇到跨端异常,我的顺序基本固定了:
1. 先看网络面板,不看 Console
确认 HTTP 状态码、Response 的 Content-Type、实际返回体前 200 字符。这能区分是 PHP 炸了、JS 解析错了、还是中间层(CDN/WAF)插了一脚。
2. 给两端加"握手标记"
每个 AJAX/REST 请求带个 X-Request-ID header,PHP 端生成 uniqid 写进日志。两边日志一对,秒级定位。
// PHP
$request_id = $_SERVER['HTTP_X_REQUEST_ID'] ?? uniqid('req_', true);
error_log("[{$request_id}] Entering handler");
// JS
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
fetch(url, { headers: { 'X-Request-ID': requestId } });
3. 怀疑堆栈说谎时,查 debug_backtrace
PHP 的 Exception::getTrace() 在闭

