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)}`);
}
}
两个关键点:
- 先判断
res.ok/res.status,再调.json()——错误页最常见的伴随状态码是 404/401/502,先看状态码能拿到更准确的报错信息; - 先
.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。
排查清单
- Network 面板:状态码是多少?200 不代表没问题(SPA 兜底也是 200)
- Response 内容:开头是
<!DOCTYPE html>还是{? curl -i直连:重现吗?带上 Cookie 呢?- 检查 nginx:
/api的proxy_pass在try_files之前匹配吗? - 检查 baseURL:开发/生产环境变量拼对了吗?
- 前端加
res.ok判断 +.text()兜底,别让用户看到裸的 SyntaxError - 鉴权路径对 API 客户端返回 401 JSON,而不是 302 HTML
相关阅读
读响应头时需要状态码含义,可用 HTTP 状态码速查。若请求根本到不了 HTTP 响应,那是另一类问题——见 ECONNREFUSED 排查。
实现注记
排障时把请求转成 curl 复现要注意:生成器里 URL、method、header 全部经过 shell 引号转义——header 值里一个单引号就能闭合字符串、注入任意 shell 命令,这是对抗实测抓出来修的。生成的命令是会被直接贴进终端执行的,拼字符串的输出必须按注入面对待。
来自 ToolVault 工具匣 的开发手记。相关工具:API 在线测试、JSON 格式化校验、HTTP 状态码速查、curl 命令转代码。访问 首页 查看更多开发者工具。
相关工具
相关文章
HTTPS 证书链不完整:浏览器不信任但解码「看起来正常」
叶证书日期有效、SAN 也对,浏览器仍报不受信任?多数是缺中间证书或 fullchain 顺序错。用 openssl s_client 与本地证书解码器逐级核对 Issuer→Subject。
HTTP API 测试完全指南:从基础到高级调试技巧
掌握 HTTP API 测试的核心技巧,包括 GET/POST/PUT/DELETE 请求、请求头配置、请求体格式、认证方式和调试方法。使用在线 API 测试工具提升开发效率。
HTTP GET vs POST:什么时候用哪个?语义、安全与最佳实践
GET 和 POST 的语义差异、幂等性、缓存、URL 长度限制、安全考量、RESTful 约定、常见误用与 curl 示例。附决策表,帮你正确选择 HTTP 方法。