跳到主要内容
API 测试2026-09-085 分钟阅读

Unexpected token '<' is not valid JSON?接口返回 HTML 而不是 JSON 的 5 种原因

现象:接口「调通了」,数据却解析不了

Uncaught (in promise) SyntaxError: Unexpected token '<', "<html>... is not valid JSON

代码大概长这样:

const res = await fetch('/api/user');
const data = await res.json();   // ← 这里抛错

一行结论:响应体第一个字符是 <,说明服务器返回的是 HTML 页面,res.json() 在第一个字符就解析失败了。这不是 JSON 语法问题,是 HTTP 层出了状况——你要的接口根本没被命中。

和 Unexpected token u(undefined)、Unexpected token ,(尾逗号)不同,'<' 这个 token 几乎总是指向同一个方向:你拿到的是一张网页。

DevTools 里还有第二条线索:若 Network 面板显示 Content-Type: text/html(或类型空着但响应体是 HTML),就别再盯 JSON Schema 了。解析器在尽职工作;传错的是传输层交付的文档类型。

五种原因,按概率排序

1. SPA 路由兜底:404 被重写成 index.html(最常见)

前端项目常见 nginx 配置:

location / {
  try_files $uri $uri/ /index.html;   # 找不到的路径全部回退到首页
}

/api/user 路径打到了这个 location(比如 /api 的 proxy_pass 忘了配、或路径前缀不一致),nginx 找不到对应文件,就把 index.html 原样返回——状态码还是 200,前端毫无防备地 .json(),炸。

验证:DevTools Network 面板看该请求的 Response,如果是一段完整的 HTML 且含 <div id="root">,就是它。

开发环境里 Vite / webpack-dev-server 是同一类坑:代理没配时,/api/* 会被静态中间件接住并返回 SPA 壳。生产与本地可能报错文案不同,但结构原因一样。

2. 登录态失效:302 跳到了登录页

服务端鉴权失败返回 302 → 浏览器自动跟随重定向 → 最终拿到登录页 HTML(状态码 200)。fetch 默认 redirect: 'follow',中间的 302 对你完全透明。

// 显式观察重定向行为
const res = await fetch('/api/user', { redirect: 'manual' });
console.log(res.type);   // "opaqueredirect" 说明发生了跳转

修法:让后端对 /api/** 返回 401 JSON 而不是 302;前端统一拦截 401 跳登录。基于 Cookie 的会话特别容易踩这个坑:对「整页导航」返回登录 HTML 是合理的,对期望 JSON 的 XHR/fetch 客户端则完全不对。

3. 网关错误页:502/503 时 nginx 输出 HTML

后端进程挂了或超时,nginx 返回自带错误页:

<html>
<head><title>502 Bad Gateway</title></head>
...

很多前端的错误处理只 catch 了 json 解析异常,却没看状态码——错误信息完全丢失。CDN / API 网关的默认错误页同理:对人友好的 HTML,对 res.json() 不友好。

4. baseURL / 端口写错

// 开发环境代理没生效,请求打到了前端 dev server 自己
fetch('/api/user')                    // ❌ vite proxy 未配置
fetch('http://localhost:3000/api/user')  // ❌ 3000 是前端,后端在 8080

环境变量切换(.env.development vs .env.production)拼错前缀是重灾区。验证方法:Network 面板看请求实际 URL,和你以为的对比。指向营销站、过期 staging、或反代路径被裁错,都是同一类 bug 的变体。

5. 服务端错误分支忘了设 Content-Type

// Express 示例:错误分支返回了 HTML
app.get('/api/user', (req, res) => {
  if (!req.session.user) {
    return res.redirect('/login');   // ❌ 返回 HTML
  }
  res.json(user);
});

框架默认值也很关键:有的栈在控制器抛出未捕获异常时会渲染 HTML 错误模板。若 API 与网站同进程,请为 /api 路由显式配置 JSON 错误中间件。

排查三板斧(按顺序做)

# 1. 绕过前端,直接看原始响应——状态码、Content-Type、前几行内容一目了然
curl -i 'https://your-site.com/api/user' | head -20

# 2. 带上和前端一样的请求头(很多 302 是缺 Cookie/Token 触发的)
curl -i -H 'Cookie: session=xxx' 'https://your-site.com/api/user'

# 3. 只看前 200 字符的响应体
curl -s 'https://your-site.com/api/user' | head -c 200

curl -i 的输出能一次回答三个问题:状态码是多少(200/302/502)、Content-Type 是什么(text/html 还是 application/json)、响应体开头是什么(<!DOCTYPE 还是 {")。九成情况看到输出就知道原因了。也可以直接丢进 API 在线测试工具里看完整响应头,少写一堆临时调试代码。

需要把一条可用的 curl 翻译成应用代码时,用 curl 转代码 对齐方法与请求头,避免「修好了 URL,又把 base path 写错」。

前端防御性写法:先看 res.ok,再解析

async function safeJson(res) {
  const text = await res.text();
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`);
  }
  try {
    return JSON.parse(text);
  } catch {
    throw new Error(`响应不是 JSON: ${text.slice(0, 100)}`);
  }
}

两个关键点:

  1. 先判断 res.ok / res.status,再调 .json()——错误页最常见的伴随状态码是 404/401/502,先看状态码能拿到更准确的报错信息;
  2. 先 .text() 再 JSON.parse,失败时能打印出响应体前 100 字符,一眼看出是 HTML 还是别的。

可选加固:若响应带了 Content-Type 且不包含 json,尽早失败并把类型写进错误信息。缺 Content-Type 不能当成「一定是 HTML」——有些 API 会省略——但明确写着 text/html 时,就别再进解析器。

和「JSON 语法错误」的区别

如果状态码是 200、Content-Type 也是 application/json,但仍然报 Unexpected token(且 token 不是 <),那就是 JSON 本身的语法问题了——尾逗号、单引号、注释、BOM 头之类,参考另一篇:JSON 解析报错 Unexpected token 的 5 种原因,可以用 JSON 格式化工具精确定位到出错位置。

快速心智过滤器:token 是 < → 文档类型错了(HTML);来自 undefined 的 u → 根本没拿到字符串;其它 token 且 Content-Type 是 JSON → 语法问题。把三类排查搅在一起会浪费大量时间。

nginx / 反代专项核对

  • 确认 location /api/(或你的前缀)声明在 SPA 的 try_files 兜底之前,且 proxy_pass 指向真正的上游。
  • API location 上尽量让上游 502/504 也回 JSON(error_page 指到小 JSON 文件,或网关错误模板)。
  • 注意路径裁剪:带尾斜杠的 proxy_pass http://upstream/ 会改变 /api 是否被转发。裁错时可能打到静态站点,对方仍返回 HTML 200。

排查清单

  1. Network 面板:状态码是多少?200 不代表没问题(SPA 兜底也是 200)
  2. Response 内容:开头是 <!DOCTYPE html> 还是 {?
  3. curl -i 直连:重现吗?带上 Cookie 呢?
  4. 检查 nginx:/api 的 proxy_pass 在 try_files 之前匹配吗?
  5. 检查 baseURL:开发/生产环境变量拼对了吗?
  6. 前端加 res.ok 判断 + .text() 兜底,别让用户看到裸的 SyntaxError
  7. 鉴权路径对 API 客户端返回 401 JSON,而不是 302 HTML

相关阅读

读响应头时需要状态码含义,可用 HTTP 状态码速查。若请求根本到不了 HTTP 响应,那是另一类问题——见 ECONNREFUSED 排查。

实现注记

排障时把请求转成 curl 复现要注意:生成器里 URL、method、header 全部经过 shell 引号转义——header 值里一个单引号就能闭合字符串、注入任意 shell 命令,这是对抗实测抓出来修的。生成的命令是会被直接贴进终端执行的,拼字符串的输出必须按注入面对待。


来自 ToolVault 工具匣 的开发手记。相关工具:API 在线测试、JSON 格式化校验、HTTP 状态码速查、curl 命令转代码。访问 首页 查看更多开发者工具。