Zsens Admin 插件 AJAX 端点:我因 `die()` 位置放错导致 JSON 被截断,排查两小时才发现是"响应流污染"
上周写个批量导入功能,前端用 `fetch` 调后台 AJAX,结果 20% 请求返回的 JSON 解析失败。Chrome 里看响应体,末尾总粘着一段 HTML 报错或者干脆是 WordPress 的 admin footer。最后定位到问题极其愚蠢:我在处理函数里提前 `echo json_encode` 之后,没立刻终止执行,后面的钩子继续往输出流里吐东西。
先贴我当时的错误写法,估计不少人踩过:
// ❌ 错误:echo 完就以为结束了
public function handle_import() {
if ( ! current_user_can( 'manage_options' ) ) {
echo json_encode( [ 'error' => '无权操作' ] );
// 这里漏了 die(),程序继续往下跑
}
$result = $this->importer->run( $_POST['data'] );
echo json_encode( $result );
// 又漏了!wp_ajax_ 钩子结束后 WordPress 还会走 admin-footer
}
问题在哪?`wp_ajax_*` 钩子的执行环境是完整的 admin 流程。你不显式中断,WordPress 会继续加载 `admin_footer`、`shutdown` 等钩子,任何插件的主题的 `wp_footer` 动作都能往输出里塞字符。JSON 末尾多了 `` 标签或者空白换行,`JSON.parse` 直接爆炸。
更隐蔽的是,如果某个插件在 `shutdown` 阶段抛 Notice,这个 Notice 也会混进响应体。我那次就是 Debug Bar 插件在 footer 输了一段性能数据,刚好夹在 JSON 后面。
正确做法其实就一条:echo 之后立刻 `wp_die()` 或 `die()`,让 WordPress 的 AJAX 框架接管响应头:
// ✅ 正确:每个分支都要显式终止
public function handle_import() {
check_ajax_referer( 'zsens_import_nonce', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( '无权操作', 403 ); // 内部自动 wp_die()
}
$raw = wp_unslash( $_POST['data'] ?? '' );
if ( empty( $raw ) ) {
wp_send_json_error( '数据为空', 400 );
}
$result = $this->importer->run( $raw );
if ( is_wp_error( $result ) ) {
wp_send_json_error( $result->get_error_message(), 422 );
}
wp_send_json_success( [
'inserted' => $result['created'],
'skipped' => $result['duplicates'],
'trace_id' => $result['batch_id'] // 给前端做轮询用
] );
}
几个细节:
1. 永远用 `wp_send_json_*` 家族
别手写 `echo json_encode` 再手动塞 `Content-Type`。`wp_send_json_success/error` 会正确处理 MIME、HTTP 状态码、跨域头(如果你开了 `REST_REQUEST` 兼容),而且内部调 `wp_die()` 断得干净。
2. 早返回早断流
权限校验、nonce 检查、参数清洗,任何失败分支都要立刻 `wp_send_json_error`。我见过有人把校验逻辑写成 "if 失败则 $error = xxx",最后统一在函数尾部判断,中间某个 `apply_filters` 或者早期返回的钩子就把输出污染了。
3. 别在 AJAX 里用 `wp_redirect`
另一个相关坑:有人习惯在表单处理失败时 `wp_redirect( wp_get_referer() )`。AJAX 请求遇到 302,浏览器自动跟随后拿到的 HTML 登录页,前端 `response.json()` 直接抛异常。AJAX 端点就返回 JSON,跳转逻辑交给前端路由判断。
4. 调试时故意破坏看看
我在团队里推了个小规矩:写完 AJAX 端点后,故意在 `wp_die()` 前面加行 `do_action( 'zsens_test_footer' )`,挂个输出内容的钩子,跑一遍单元测试。如果 JSON 解析还正常,说明你的响应封装足够健壮。
最后补个我封装的基类方法,省得每次手写重复:
abstract class Ajax_Endpoint {
abstract public function action(): string;
abstract public function handle( array $payload ): array;
final public function register(): void {
add_action( "wp_ajax_{$this->action()}", [ $this, '_dispatch' ] );
}
final public function _dispatch(): void {
try {
check_ajax_referer( $this->action() . '_nonce', 'nonce' );
$payload = $this->sanitize( $_POST );
$result = $this->handle( $payload );
wp_send_json_success( $result );
} catch ( \InvalidArgumentException $e ) {
wp_send_json_error( $e->getMessage(), 400 );
} catch ( \RuntimeException $e ) {
wp_send_json_error( $e->getMessage(), 500 );
}
// 这里不需要 die,wp_send_json_success/error 内部已处理
}
protected function sanitize( array $raw ): array {
return map_deep( $raw, 'sanitize_text_field' );
}
}
具体端点继承实现 `handle()` 就行,返回数组,异常按语义抛,基类保证流不会漏。用了两个月,团队里再也没出现过 JSON 截断的工单。
你们还遇到过哪些 AJAX 响应被污染的奇葩场景?比如 `ob_start()` 嵌套没清、或者 `register_shutdown_function` 里写日志把缓冲冲掉的?

