Skip to content
API 测试2026-09-083 分钟阅读

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 几乎总是指向同一个方向:你拿到的是一张网页

五种原因,按概率排序

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">,就是它。

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 跳登录。

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

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

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

很多前端的错误处理只 catch 了 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,和你以为的对比。

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

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

排查三板斧(按顺序做)

# 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 在线测试工具里看完整响应头。

前端防御性写法:先看 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 还是别的。

和「JSON 语法错误」的区别

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

排查清单

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

本文由 ToolVault 工具匣 提供。相关工具:API 在线测试JSON 格式化校验HTTP 状态码速查curl 命令转代码。访问 首页 查看更多开发者工具。


广告