JsonMind
JSON 与数据11 分钟阅读更新于 2026年11月9日

JSON 解析报错:Unexpected token < in JSON at position 0 怎么修

解释 JSON.parse 常见报错的原因,区分 HTML 错误页、空响应、重复编码和非法 JSON,并给出可复用的安全解析写法。

JM作者:JsonMind Engineering最后复核: 2026年11月9日依据权威规范与引用来源复核。
快速答案

这个报错通常不是 JSON 本身写错了,而是传给 JSON.parse 的内容根本不是 JSON。最常见的原因是接口返回了 HTML 错误页、空响应、被重复编码的字符串或被截断的响应。先打印原始文本和前 80 个字符,再决定怎么修。

核心要点
  • JSON.parse 只接受完整的 JSON 文本,HTML、JavaScript 对象和空字符串都会报错。
  • 最快的排查方法是先读取 response.text(),检查状态码、Content-Type 和前几个字符。
  • 以 < 开头通常意味着接口返回了 HTML 错误页或登录跳转页。
  • 204 和 304 响应没有正文,必须在解析前单独处理。
  • 先校验和格式化 JSON,再修改业务逻辑。

JSON.parse 的报错信息很少,但每个报错都指向不同的输入问题。很多人看到 Unexpected token 就去改 JSON 字符串,结果发现字符串是接口返回的,真正的问题在响应处理层。

这篇文章按“先取证、再分类、最后修复”的顺序,把最常见的几类 JSON 解析错误拆开讲清楚。

报错到底在说什么

解析器读取第一个有意义的字符,然后决定应该解析哪种 JSON 值:

  • { 开头是对象。
  • [ 开头是数组。
  • " 开头是字符串。
  • t、f、n 开头分别是 true、false、null。

如果第一个字符是 <,解析器无法开始任何合法的 JSON 值,于是报错。旧版 V8 的提示是:

SyntaxError: Unexpected token < in JSON at position 0

新版 V8 会更明确地显示:

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

两种提示都指向同一件事:输入是一段 HTML。常见来源包括服务器 500 错误页、CDN 拦截页、登录跳转页和反向代理维护页。

报错里的位置信息值得单独说明。旧版 V8 的 position 按 UTF-16 码元计算,不是字节偏移,所以它和按 UTF-8 字节计数的编辑器列号对不上,排查时不要拿它去定位 HTML 源码的列。新版 V8 直接引用输入开头的一小段内容,这段内容比堆栈更快暴露来源:<!DOCTYPE 是 HTML5 页面,<?xml 是网关返回的 XML 错误,而开头已经是正常 JSON 字符时,说明语法错误出现在正文后面,不在 position 0。

另一个容易忽略的点是 JSON.parse 会先把参数转成字符串再解析。JSON.parse(undefined) 抛出 SyntaxError: "undefined" is not valid JSON,JSON.parse({}) 因为对象被转成 [object Object] 而失败,但 JSON.parse(null) 会返回 null,因为 null 会被转成四个字符的 "null"。最后一种最隐蔽:字段缺失也能通过解析,直到业务代码去读属性时才报 TypeError。

RFC 8259 还允许顶层是任意 JSON 值,不要求一定是对象或数组。接口返回 true、42 或 "maintenance" 都是合法 JSON,解析阶段不会报错;如果调用方马上解构返回值,就会得到 Cannot read properties of true 之类的运行时错误。解析错误和结构错误是两道检查,不能混为一谈。

另一类高频报错是:

SyntaxError: Unexpected end of JSON input

它表示解析器还在期待更多字符时,输入就结束了。通常是空响应或被截断的响应。

第一步:拿到原始文本

不要在不知道响应内容的情况下加重试或兜底对象。先打印原始文本:

async function readJson(response) {
  const text = await response.text();
  console.log({
    status: response.status,
    contentType: response.headers.get("content-type"),
    firstChars: text.slice(0, 120),
    length: text.length
  });

  if (!text.trim()) {
    throw new Error(`响应为空,状态码 ${response.status}`);
  }

  try {
    return JSON.parse(text);
  } catch (error) {
    throw new Error(`来自 ${response.url} 的 JSON 无效:${error.message}`);
  }
}

这段代码会直接告诉你三件事:状态码、内容类型和开头的字符。如果开头是 <!DOCTYPE html>,接口没有返回 JSON;如果开头是 "{\",可能被重复编码了;如果长度是 0,响应体为空。

如果片段还不足以定位来源,按下面的顺序向外查:

  1. 用 curl -i 带同样的方法、请求头和正文复现。curl 也收到 HTML,说明问题出在应用之前的网关或路由层。
  2. 记下响应里的 x-request-id 或 trace ID,到服务端日志里搜这个值。
  3. 比较 response.url 和实际请求的 URL。两者不同说明 fetch 跟随了重定向,最终响应可能来自登录页或另一个环境。
  4. 非压缩响应下,把解码后的长度和 content-length 对比,明显偏短就是截断;有 content-encoding: gzip 时,content-length 是压缩后的字节数,不能直接和解码长度比较。
  5. 确认原始输入之后,再改解析代码。

text() 和 json() 都会把整个响应缓冲到内存,二者都不做流式解析。20 MB 的响应转成 UTF-16 字符串可能占用两倍传输体积,随后 JSON.parse 还要在字符串仍然存活时构建完整对象图,峰值内存是响应体的数倍。普通接口可以接受,导出类大接口不行。Fetch API 也没有“先看开头再把剩余部分交给解析器”的标准做法,所以只为打日志读一小段,仍然要付出全量缓冲的代价。

日志里的预览同样要当成敏感数据。HTML 错误页可能回显请求参数,JSON 错误体可能带邮箱或 Token。只记录固定长度,已知密钥字段要脱敏;渲染到页面上时用 textContent,不要用 innerHTML,否则错误页会变成 XSS 入口。

原因一:HTML 错误页被当成 JSON

接口返回 404、500 或登录跳转时,正文经常是 HTML。如果客户端不检查 response.ok 就直接调用 response.json(),就会得到这个令人困惑的解析错误。

const response = await fetch("/api/orders");

if (!response.ok) {
  const detail = await response.text();
  throw new Error(`接口 ${response.status}:${detail.slice(0, 200)}`);
}

const data = await response.json();

先检查状态码和内容类型,再解析。如果接口前面有代理或网关,还要确认代理是否拦截了请求并返回了自己的 HTML 页面。修复点可能在代理配置,而不是业务代码。

一个具体的失败场景:前端请求 https://api.example.com/orders,但 CDN 上没有这条路由,因为服务刚改过名,边缘节点直接返回自己的 502 页面。DevTools 里状态是 502、content-type 是 text/html,控制台是 SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON。这种情况下 JavaScript 再怎么写都没用,请求根本没有到达业务服务。

只看状态码也不够。SSO 网关、网络强制门户(captive portal)、单页应用的回退路由都可能返回 200 加 HTML。Express 的 app.get("*") 兜底路由会把未知的 /api/... 路径交给 index.html 并返回 200,response.ok 会是 true。解析前应该确认 content-type 以 application/json 开头;同时把 API 的 404 处理注册在 SPA 兜底路由之前,让未知接口路径以 JSON 形式失败。

不要用“返回 {}”或“尝试解析 HTML”来兜底。那会把路由或网关故障伪装成业务数据缺失,问题更难发现,也更难追溯。正确做法是带着状态码、URL 和有限长度的预览直接失败。

原因二:响应体为空

204 No Content 和 304 Not Modified 没有正文。fetch 对这两种状态不会抛错,但 response.json() 会在解析阶段失败。

if (response.status === 204) {
  return null;
}

条件请求的 304 应该单独处理,复用缓存值,而不是解析空正文。

空响应的范围比 204 和 304 更大。HEAD 响应和 205 Reset Content 也属于 null body,读取之前判断 response.body === null 就能识别它们。200 加 Content-Length: 0 则不同:响应体是空流而不是 null,response.body 判断不出来,仍然要靠 text().trim() 检查。

浏览器缓存会改变你观察到的结果。正常重新验证时,304 由浏览器自己处理,JavaScript 拿到的是缓存里的 200;只有当请求显式带上 If-None-Match 或 If-Modified-Since,或者在服务端执行 fetch 时,代码才会看到 304。此时 response.status 是 304,response.ok 是 false,一句笼统的 if (!response.ok) throw 会把正常的缓存命中当成错误。每个调用点都要明确 304 到底表示“用缓存”还是“异常”。

原因三:重复编码

重复编码的典型样子是:

"{\"id\":42,\"status\":\"ready\"}"

外层是一个 JSON 字符串,不是对象。解析一次得到字符串,解析两次才得到对象。不要为了让它工作而在客户端永久加第二次 JSON.parse。找到生产端,确保只序列化一次。

可以先判断类型确认:

const first = JSON.parse(text);
console.log(typeof first); // "string" 说明被嵌套了
const payload = typeof first === "string" ? JSON.parse(first) : first;

第二次解析只用于诊断,确认后应该修生产端。

整段响应被重复编码容易发现,更常见的是单个字段被重复编码:{"id":42,"metadata":"{\"source\":\"etl\",\"version\":2}"}。顶层解析完全成功,不会抛异常;问题在后面才出现,比如 metadata.source 是 undefined,或者别的代码对 metadata 做了字符串转换,日志里出现 [object Object]。如果 JSON Schema 把 metadata 声明为 object,校验会在边界就拦住错误结构,而不是让它扩散到存储层。

根因通常在生产端。数据库的 text 列存 JSON 时返回的就是字符串,ORM 可能又序列化了一次;Java 服务用 Jackson 序列化一个本身已含 JSON 的 String 字段,PHP 的 json_encode 处理字符串值时也会这样。产出的 JSON 完全合法,只是内容是“序列化后的 JSON”,于是每个消费方都得替生产端擦屁股。

不要写递归函数去解析所有“看起来像 JSON”的字符串。用户评论里以 { 开头的文本是普通数据,解析它可能改变语义或直接抛错。只有契约明确说明某个字段是文本编码的 JSON(例如文档里写明 payload 列存储 JSON 字符串)时,才应该二次解析。

原因四:不是标准 JSON

标准 JSON 不允许下面这些 JavaScript 写法:

写法JavaScript 对象标准 JSON
单引号字符串可以不可以
尾随逗号可以不可以
注释可以不可以
未加引号的键可以不可以
undefined可以不可以
NaN、Infinity可以不可以
前导零数字可以不可以

如果内容来自配置文件、日志或手写测试数据,先用 JSON 格式化与校验工具 检查。校验器会给出行号和列号,比读堆栈快得多。

这些写法对应的报错信息通常已经点名了问题。对象里的尾随逗号在当前 V8 中报 SyntaxError: Expected double-quoted property name in JSON at position N;单引号键会报 Unexpected token 并指出引号字符;注释则报出与斜杠相关的 token。position 按 UTF-16 码元计算,前面出现中文会让它和按字节定位的编辑器列号产生偏差。

有两个语法边缘情况不会报错,而是悄悄改坏数据。第一是重复键:{"role":"user","role":"admin"} 是合法 JSON,JSON.parse 取最后一个值,得到 {"role":"admin"},没有任何警告。如果网关和应用使用不同的解析器,两边对“哪个值生效”的判断可能不一致,这是已经被记录在案的鉴权绕过类别。跨信任边界时应该拒绝重复键。第二是数字精度:JSON 数字是 IEEE 754 双精度浮点,JSON.parse('{"id":9007199254740993}') 得到 9007199254740992,超过 2^53 的整数无法精确表示。雪花 ID 和数据库 bigint 字段应该以字符串传输。

第三种安静的问题是孤立代理项。"\ud800" 语法上合法,解析后得到包含孤立代理项的字符串;一旦再编码成 UTF-8 存储或传输,就会变成替换字符 U+FFFD,原码点永久丢失。数据需要二次编码时,校验阶段就应该拒绝未配对的代理项。

原因五:截断、流式读取和编码问题

响应可能因为网络超时、压缩层配置错误或流被读取两次而截断。部分旧解析器对 UTF-8 BOM 也不友好。

const cleaned = text.replace(/^/, "");
const data = JSON.parse(cleaned);

不要用重试循环掩盖截断问题。检查响应大小、Content-Length(如果存在)和服务端日志。如果响应很大,应该使用流式解析器,而不是把整个响应缓冲到内存。

截断有很明显的特征。Node 服务在写出半个对象后崩溃,客户端会收到 SyntaxError: Unexpected end of JSON input,但状态行可能仍是 200,因为响应头在崩溃前已经发出。代理读超时会把正文切在超时边界上,收到的长度往往是个整数,比如 30 秒超时对应恰好 30,000 字节。非压缩响应可以把解码长度和 content-length 对比;带 content-encoding: gzip 时,content-length 是压缩字节数,完全不能和解码后的字符串长度比较。

BOM 需要单独说明。RFC 8259 规定实现不应添加 BOM,但可以忽略它。V8 的 JSON.parse 不忽略,字符串以 U+FEFF 开头会直接抛错。Fetch API 反而不会出问题,因为它按 Encoding 标准解码 UTF-8,解码过程会去掉开头的 BOM,所以 response.text() 和 response.json() 能成功;同一个文件用 fs.readFileSync(path, "utf8") 读进来则保留 BOM,JSON.parse 失败。这就是“浏览器能解析、Node 解析不了”的常见原因,也是前面手动去 BOM 的代码仍然有意义的原因。

响应大到不能整体缓冲时,JSON.parse 不是“慢一点”,而是根本不该用。按行分隔的 NDJSON 可以逐行解析;单个大 JSON 文档需要事件式流式解析器。两种方案都牺牲了一次调用的简单性,换来背压、部分失败处理和不同的错误定位方式。浏览器主线程解析几 MB 的字符串还会阻塞渲染,这种大小的数据即使已经缓冲,也应该放进 Web Worker。

一个可复用的安全解析器

type JsonResult<T> =
  | { ok: true; value: T }
  | { ok: false; reason: "empty" | "html" | "invalid"; preview: string };

export async function parseJsonResponse<T>(response: Response): Promise<JsonResult<T>> {
  const text = await response.text();
  const trimmed = text.trim();

  if (!trimmed) return { ok: false, reason: "empty", preview: "" };
  if (trimmed.startsWith("<")) return { ok: false, reason: "html", preview: trimmed.slice(0, 120) };

  try {
    return { ok: true, value: JSON.parse(trimmed) as T };
  } catch {
    return { ok: false, reason: "invalid", preview: trimmed.slice(0, 120) };
  }
}

调用方可以根据 reason 决定重试、提示用户还是上报监控。关键是失败原因要能在日志和指标里看见。

生产环境还要补两点。trim() 会复制整个字符串,输入很大时应该只检查开头一小段,前导空白本来就由 JSON.parse 自己处理。reason 应该分别打点:html 持续上升说明网关或鉴权层有问题,已知接口的 invalid 上升说明生产端在发坏数据;如果两者都记成笼统的解析错误,两个完全不同的故障在监控面板上会长得一模一样。

还要明确每种 reason 对调用方意味着什么。401 之后返回的 HTML 登录页不应该重试;空的 204 是正常结果;截断的正文可能值得换连接重试一次。把策略写进解析结果契约里,而不是散落在每个调用点,新增消费方时行为才能保持一致。

修复后怎么验证

至少测试四种情况:

  1. 正常 JSON 加 200 状态码。
  2. HTML 错误页加 500 状态码。
  3. 空的 204 响应。
  4. 被截断的响应。

用 JSON 树形查看器 检查嵌套结构,用 JSON Schema 校验工具 确认解析后的对象符合预期结构。解析成功只能证明文本是 JSON,不能证明数据符合业务约定。

回归测试至少覆盖九种情况:正常的 200 加 application/json;SPA 回退返回的 200 加 text/html;500 加 HTML 错误页;空的 204;条件请求返回的 304;200 但 Content-Length: 0;被截断的正文;语法合法但结构错误;以及网络失败,此时 fetch 在任何 Response 对象出现之前就 reject 了。最后两种最容易被漏掉:解析成功不代表结构正确,网络错误则根本到不了解析器。Node 18 以后可以直接用 new Response(body, { status, headers }) 构造前七种情况,不需要启动服务器就能测试这个辅助函数。

结构校验方面,required 和 type 能发现字段缺失和类型错误。additionalProperties: false 看起来更严格,但服务端以后每加一个字段都会变成客户端故障;只对端到端都由自己控制的契约使用,不要用在会持续演进的第三方接口上。

什么时候该修复 JSON,什么时候该拒绝

用户粘贴的日志、导出的文件可以尝试修复;接口响应不应该静默修复,否则会掩盖接口契约破坏。一次性修复可以用 JSON 清理与修复工具,但每次修改都要人工确认。生产环境应该拒绝非法输入,给出清晰错误,并保留原始文本用于排查。

还有一条边界:参与认证、签名或支付的载荷永远不要修复。修掉一个尾随逗号可能改变重复键的胜负,丢掉一个无法解析的片段可能把本该拒绝的请求变成语义不同的成功请求。这类路径上的解析失败是篡改或版本不匹配的证据,必须保持可见。

修复时保留原文。用户报告失败时,修复后的 JSON 本身无法说明原来错在哪;原始输入(脱敏后)加上每一步修复记录,才能事后验证修复是否正确,并在生产端补上对应的防护。

原则很简单:先让非法输入可见,再修生产端,最后才在边界加便利处理。

常见问题

Unexpected token < in JSON at position 0 是什么意思?

它的意思是输入的第一个非空白字符是 <,解析器遇到了 HTML 或 XML,而不是 JSON 值。常见原因是接口返回了 HTML 错误页、登录跳转页或网关页面,被前端当成 JSON 解析了。

为什么会出现 Unexpected end of JSON input?

输入在解析器读完一个完整 JSON 值之前就结束了。常见原因是响应被截断、流被读取了两次、返回了空字符串,或者接口返回了 204 No Content。

可以把 JavaScript 对象直接传给 JSON.parse 吗?

不可以。JSON.parse 接受的是包含 JSON 文本的字符串。如果你已经有对象,直接使用即可;如果字符串来自非 JSON 格式,应该修生产端,而不是在客户端再套一层 JSON.parse。

生产环境应该如何安全地解析 JSON?

在 try/catch 中解析,先检查 response.ok 和 Content-Type,显式处理空响应,失败时记录原始文本的有限片段。一次性排查可以把内容粘贴到 JSON 格式化校验工具中查看具体行和列。

尾随逗号会导致 JSON 解析失败吗?

会。标准 JSON 不允许尾随逗号、单引号字符串、注释和未加引号的键。这些是 JavaScript 对象字面量的语法,不是 JSON 的语法。

参考资料

把指南用起来

浏览全部工具

打开浏览器工具。输入内容保留在浏览器中,不会上传到业务服务器。

相关指南

全部指南