前言
后端日志里一片正常,前端却在控制台里喊:"SyntaxError: Unexpected token '<', "<br />" is not valid JSON"。这是"接口数据格式异常"最典型的症状——PHP 没报错,前端却解析不了。
之所以会这样,是因为 PHP 的错误输出和接口的响应体共用同一个输出通道。任何一个 Warning、Deprecated、Notice,甚至源文件开头多出来的一个字节,都会被当作响应体的一部分发给前端。前端拿到的东西不是 JSON,自然解析失败。
在 PHP 8.2 上这件事比以往更容易发生,因为 8.2 一口气新增了几条弃用(deprecation)规则,最容易撞上的两条是动态属性(dynamic property)弃用(给没声明过的属性赋值会发Deprecated: Creation of dynamic property Foo::$bar is deprecated)和${var}字符串插值弃用。这些信息本身无害,但只要display_errors=On,它们就会跑到{前面,接口就"格式异常"了。
本文按"先看响应体原始字节,再分四层排查"的顺序讲:JSON 编码失败、输出污染、传输层、以及一个不会被击穿的响应封装。
一、第一步永远是看原始字节
不要在前端调试,也不要在浏览器 Network 面板里猜。先拿到裸的响应体:
# -i 显示响应头,-sS 静默但保留错误,-o 把响应体和响应头一起存下来 curl -i -sS https://example.com/api/user/1 -o /tmp/body.bin # 响应头:Content-Type 对不对 head -n 12 /tmp/body.bin # 前 32 个字节的十六进制:BOM、空白、警告文本一眼就能认出来 xxd -l 32 /tmp/body.txt拿到十六进制之后,对照这张表定位:
| 观察到的现象 | 最可能的原因 | 属于哪一层 |
|---|---|---|
以efbbbf开头 | 源文件带 UTF-8 BOM(记事本"另存为 UTF-8"的默认产物) | 输出污染 |
以0d0a或20开头 | PHP 文件结尾?>后面有空白字符 | 输出污染 |
以Warning/Deprecated/Notice开头 | display_errors=On,警告进了响应体 | 输出污染 |
以<开头,内容是 HTML | 被 web server 的错误页接管,或include了一个 HTML 文件 | 输出污染 |
| 响应体为空 | json_encode()返回了false,或 fatal error 被display_errors=Off吞掉 | 编码失败 |
Content-Type: text/html | 忘了发Content-Type头,或头发送前已经有输出 | 传输层 |
| JSON 内容对但前端报 "Unexpected end of JSON input" | Content-Length与实际长度不符,或被二次 gzip | 传输层 |
数据里出现�(U+FFFD) | 源数据不是合法 UTF-8,被替换了 | 数据编码 |
二、JSON 编码失败:json_encode()返回false
json_encode()失败时返回false而不是抛异常,echo false会输出空字符串,于是前端拿到一个空的响应体。这是"接口偶尔返回空"的头号原因。
失败的原因只有四类,全部由JSON_ERROR_*常量表示:
json_last_error()的取值 | 触发条件 | 典型来源 |
|---|---|---|
JSON_ERROR_UTF8 | 字符串不是合法 UTF-8 | 从 GBK 库里读的数据、上传的文件名、substr()切出来的半个汉字 |
JSON_ERROR_INF_OR_NAN | 出现INF、-INF、NAN | 除零结果、log(0)、未初始化的浮点计算 |
JSON_ERROR_RECURSION | 数组里有自引用 | 对象互相持有对方 |
JSON_ERROR_UNSUPPORTED_TYPE | 出现 resource 或闭包 | 把fopen()的句柄塞进了响应数据 |
正确的处理方式是不检查返回值,而是让编码失败直接抛异常(JSON_THROW_ON_ERROR是 PHP 7.3 引入的):
<?php declare(strict_types=1); // 最低版本:PHP 7.3(JSON_THROW_ON_ERROR) $data = ['name' => $name, 'rows' => $rows]; try { $payload = json_encode( $data, JSON_UNESCAPED_UNICODE // 中文不转成 \uXXXX | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE // PHP 7.2 起:非法字节替换成 U+FFFD 而不是失败 | JSON_THROW_ON_ERROR ); } catch (JsonException $e) { // 一旦走到这里,说明数据结构本身有问题,而不是"运气不好" error_log('JSON 编码失败: ' . $e->getMessage()); http_response_code(500); $payload = '{"code":50000,"message":"响应数据格式错误","data":null}'; }注意JSON_INVALID_UTF8_SUBSTITUTE和JSON_THROW_ON_ERROR的取舍:前者让非法字节"降级通过",后者让问题立刻暴露。开发环境用JSON_THROW_ON_ERROR尽早发现脏数据,生产环境两个一起用——毕竟接口挂了比返回一个带�的字段更糟。
顺带说清一个版本问题:网上很多"用json_validate()先校验一下"的建议,json_validate()是 PHP 8.3 才引入的,PHP 8.2 上没有这个函数,调用会直接报Call to undefined function。在 8.2 上校验一个字符串是不是合法 JSON,只能走json_decode(),再看json_last_error() === JSON_ERROR_NONE。
三、输出污染:警告是怎么跑进响应体的
PHP 的错误输出走的是"标准输出",而接口的响应体也走标准输出,两者没有隔离。想让它们分开,只有一个办法:把错误写进日志,而不是写进输出。
<?php ini_set('display_errors', '0'); // 不要显示给客户端 ini_set('log_errors', '1'); // 但要记到日志里 error_reporting(E_ALL); // 记全量在 PHP 8.2 上,最容易混进响应体的三类信息是:
1. 动态属性弃用(PHP 8.2 新增)
<?php class UserDto { public function __construct(public int $id) {} } $dto = new UserDto(1); $dto->nickname = 'Tom'; // Deprecated: Creation of dynamic property ...三种修法,按推荐顺序:给类补上属性声明(治本);用__set()魔术方法接管(需要"任意字段"的数据容器);给类加#[\AllowDynamicProperties](这个属性是 PHP 8.2 引入的,只是把警告压下去)。继承自stdClass的对象不受这条弃用影响。
2. 传null给内置函数的非空参数(PHP 8.1 起弃用)
strlen(null)、htmlspecialchars(null)这类调用会发Deprecated: Passing null to parameter #1 of type string is deprecated。内置函数(internal function)的签名用的是"非可空类型",传null虽然还能跑,但会发弃用。这个坑在 8.1 引入,很多项目是在 8.2 上才集中暴露出来——因为升级时顺手把error_reporting调到了E_ALL。
3.${var}字符串插值弃用(PHP 8.2 新增)
<?php // Deprecated: Using ${var} in strings is deprecated, use {$var} instead $sql = "SELECT * FROM t WHERE id = ${id}";改成{$id}即可。这类弃用特别隐蔽:它出现在拼 SQL、拼路径的地方,而那一行可能每次请求都会执行,于是日志被刷满,响应体前面也挂着一条。
除了 PHP 自己产生的文本,还有两个纯"文件内容"层面的污染源:
- UTF-8 BOM:某个被
include的文件开头有三个字节EF BB BF,它会在任何header()之前被输出,导致"headers already sent"和响应体前多三个字节。用编辑器把编码存成"UTF-8 无 BOM"。 ?>之后的空白:纯 PHP 文件不要写结束标签,这是 PSR-12 的明确规定。结束标签后面的换行、空格都会被原样输出。
最后,别忘了缓冲区里可能已经有内容。发头之前先清干净:
<?php // 把此前所有缓冲区丢弃,确保响应体是干净的 while (ob_get_level() > 0) { ob_end_clean(); }四、传输层:头、长度与压缩
响应体是对的、前端还是报错,问题往往在这里。
Content-Type必须是application/json。只写application/json不够,中文场景要带charset=utf-8。某些客户端在拿到text/html时会按 HTML 解析,{开头的 JSON 也能被当成文本——但一旦内容里有<,解析立刻跑偏。顺手加X-Content-Type-Options: nosniff,禁止浏览器猜类型。
Content-Length不要手工算错。如果确实要发,就用strlen($payload)算字节数(不是mb_strlen),而且不能再对响应体做手工 gzip——web server(nginx 的gzip、Apache 的mod_deflate)会自动压缩并重写长度。两边都压一次,或者一边压一边手写了长度,客户端就会抱怨"JSON 提前结束"。
不要在有输出之后调header()。一旦有任何字节被送出,header()只会发一条Warning: Cannot modify header information - headers already sent by (...)——而这条警告本身又进了响应体,于是格式更乱了。判断是否还能发头用headers_sent($file, $line),它会把第一个输出发生的位置告诉你,排查时非常有用。
五、实战:一个不会被"格式异常"击穿的响应封装
下面这份代码把前面四节的要点全部固化。最低版本 PHP 8.2(用到了readonly class,它是 PHP 8.2 引入的;枚举是 8.1 引入的)。
<?php declare(strict_types=1); enum ApiCode: int { case Ok = 0; case InvalidParam = 40000; case ServerError = 50000; } /** * 接口响应封装:错误只进日志,响应体只输出 JSON * 最低版本:PHP 8.2 */ final readonly class ApiResponse { public function __construct( public ApiCode $code, public string $message, public mixed $data = null, ) {} public function send(): void { // 1. 警告写日志,不写输出 ini_set('display_errors', '0'); ini_set('log_errors', '1'); error_reporting(E_ALL); // 2. 丢弃此前的所有缓冲区(同事的调试 echo 也一并清掉) while (ob_get_level() > 0) { ob_end_clean(); } // 3. 递归清洗数据,把"一定会让编码失败"的值先换成可以表示的值 $normalized = self::normalize($this->data); try { $payload = json_encode( [ 'code' => $this->code->value, 'message' => self::toUtf8($this->message), 'data' => $normalized, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR ); } catch (JsonException $e) { error_log('接口响应编码失败: ' . $e->getMessage()); http_response_code(500); $payload = '{"code":50000,"message":"服务器内部错误","data":null}'; } // 4. 头必须在输出之前发出 if (!headers_sent($file, $line)) { http_response_code($this->code === ApiCode::Ok ? 200 : 500); header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); header('Content-Length: ' . strlen($payload)); } else { // 这种情况一定要记日志,否则前端只会看到"格式错误" error_log("响应头已在 {$file}:{$line} 处被提前发出,接口格式可能异常"); } echo $payload; } private static function normalize(mixed $value, int $depth = 0): mixed { if ($depth > 32) { return null; // 防深递归 } if (is_float($value)) { return is_finite($value) ? $value : null; // INF / NAN 无法表示成 JSON } if ($value instanceof BackedEnum) { return $value->value; } if ($value instanceof UnitEnum) { return $value->name; } if (is_resource($value)) { return null; } if (is_object($value)) { // 只暴露公有属性,避免把资源、闭包这类不可编码的东西带进去 $value = get_object_vars($value); } if (is_array($value)) { return array_map( static fn(mixed $v): mixed => self::normalize($v, $depth + 1), $value ); } return $value; } private static function toUtf8(string $s): string { // mb_check_encoding 由 mbstring 提供:非法的按 UTF-8 重新解释并替换 return mb_check_encoding($s, 'UTF-8') ? $s : mb_convert_encoding($s, 'UTF-8', 'UTF-8'); } } // ---------------- 使用示例 ---------------- $response = new ApiResponse(ApiCode::Ok, '查询成功', [ 'id' => 1, 'name' => '张三', 'score' => 88.5, 'ratio' => NAN, 'raw' => "\xC3\x28", // 非法 UTF-8 字节 'nickname' => null, ]); $response->send();再配一个"格式自检"的小脚本,把前面那张判定表变成代码,接口出问题时先跑它:
<?php declare(strict_types=1); // 最低版本:PHP 8.0(str_starts_with 与 str_contains 都是 8.0 引入的) function inspectPayload(string $body): void { printf("长度: %d 字节, 前 12 字节: %s\n", strlen($body), bin2hex(substr($body, 0, 12))); if (str_starts_with($body, "\xEF\xBB\xBF")) { echo "发现 BOM:把输出文件另存为 UTF-8 无 BOM\n"; } if (preg_match('/^\s/', $body)) { echo "以空白开头:检查 PHP 结束标签后面的字符\n"; } foreach (['Warning', 'Deprecated', 'Notice', 'Fatal error'] as $needle) { if (str_contains(substr($body, 0, 200), $needle)) { echo "开头出现 {$needle}:display_errors 必须关掉\n"; } } json_decode($body); echo json_last_error() === JSON_ERROR_NONE ? "JSON 结构合法\n" : 'JSON 不合法: ' . json_last_error_msg() . "\n"; } inspectPayload('{"code":0,"message":"ok","data":null}');常见坑点
1. 不检查json_encode()的返回值
❌ 错误写法:
<?php echo json_encode($data); // 失败时返回 false,echo 出来是空字符串,前端报"响应为空"✅ 正确写法:
<?php echo json_encode($data, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE); // 外面套 try/catch (JsonException),失败时返回一个结构完整的错误响应2. 在 PHP 8.2 上调json_validate()
❌ 错误写法:
<?php if (!json_validate($input)) { // PHP 8.3 才有,8.2 上直接 Fatal error throw new InvalidArgumentException('不是合法 JSON'); }✅ 正确写法(8.2):
<?php json_decode($input); if (json_last_error() !== JSON_ERROR_NONE) { throw new InvalidArgumentException('不是合法 JSON: ' . json_last_error_msg()); }3. 动态属性弃用信息被写进响应体
❌ 错误写法:DTO 里不声明属性,直接$dto->extra = $v;,同时display_errors=On,响应体前面多出一行Deprecated。
✅ 正确写法:声明属性;确实需要动态字段的数据容器,显式声明意图:
<?php #[\AllowDynamicProperties] // PHP 8.2 引入 class DynamicPayload {}4. 把null直接喂给内置函数
❌ 错误写法:
<?php echo htmlspecialchars($_GET['q'] ?? null, ENT_QUOTES, 'UTF-8'); // PHP 8.1 起发 Deprecated,8.2 上升级到 E_ALL 后立刻刷屏✅ 正确写法:
<?php echo htmlspecialchars((string) ($_GET['q'] ?? ''), ENT_QUOTES, 'UTF-8');5. 纯 PHP 文件写?>且后面留了换行
❌ 错误写法:
<?php // config.php return ['debug' => false]; ?>✅ 正确写法:不写结束标签。文件末尾多出的那个换行会被原样输出,一旦这个文件在发header()之前被include,接口立刻"格式异常",而且报错位置会指到header()那一行,离真正的原因很远。
6. 用header()之前已经有输出
❌ 错误写法:在配置里echo了一行调试信息,然后才header('Content-Type: application/json')。
✅ 正确写法:把header()放在脚本尽可能靠前的位置,或者用输出缓冲兜底:
<?php ob_start(); // ... 业务逻辑 ... if (!headers_sent($file, $line)) { header('Content-Type: application/json; charset=utf-8'); } ob_end_clean(); // 丢掉业务代码里意外产生的一切输出 echo $payload;7. 手工设置Content-Length又开了 gzip
❌ 错误写法:
<?php header('Content-Length: ' . strlen($payload)); // 长度按未压缩算 ob_start('ob_gzhandler'); // 又压了一次 echo $payload; // 长度与实际不符✅ 正确写法:要么完全交给 web server 处理压缩,要么完全自己在 PHP 里做,两者只能选一个。绝大多数情况下选前者。
8. 用字符串拼接生成 JSON
❌ 错误写法:
<?php $json = '{"name":"' . $name . '","age":' . $age . '}'; // 名字里一个引号或反斜杠,JSON 就废了;中文还会变成非法 UTF-8✅ 正确写法:一律json_encode()。它负责转义、负责处理 Unicode、负责把类型映射正确——手写字符串永远做不完整。
总结
| 排查层 | 症状 | 处理方向 |
|---|---|---|
| 数据层 | JSON_ERROR_UTF8/INF_OR_NAN/RECURSION | 编码前递归清洗,用JSON_THROW_ON_ERROR暴露问题 |
| 输出层 | 响应体以Warning、Deprecated开头 | display_errors=0+log_errors=1,发头前清空缓冲 |
| 文件层 | 响应体以efbbbf或空白开头 | 源文件存成 UTF-8 无 BOM,纯 PHP 文件不写?> |
| 传输层 | Content-Type不对、JSON 提前结束 | 明确发 JSON 头,Content-Length与 gzip 只能选一种做法 |
| 版本层 | 8.2 的动态属性 /${var}弃用刷屏 | 声明属性、改用{$var},或升到 8.3 用json_validate() |
排查"接口格式异常"的顺序永远是反过来的:先看响应体的原始字节,再往上追是哪一层往里写了东西。前端报的错只是结果,真正的线索在xxd输出的前 16 个字节里。