关于一家代理公司为何要撰写关于 JSON.parse 的文章,这里说明一下。我们是 Geonode,主要销售代理服务。客户反馈的最常见 SyntaxError 问题与 JSON 完全无关——而是 Unexpected token '<',这意味着响应内容是 HTML 而非 JSON,即 API 返回了被封锁页面、登录重定向或错误页面。 因此,这里有一个诚实的免责声明:**如果 JSON.parse 在处理您获取的数据时报错,请在进行任何其他更改之前,先记录原始响应正文。**十有八九,解析器工作正常,并且准确地报告了您收到的是一个网页。 购买代理仅能在你被封禁的特定情况下解决此问题;对于错误的 URL、过期的令牌或你本应遵守的速率限制,它毫无作用。请先输出响应内容。
既然已经说清楚了这一点,接下来就是实际的 API 了。
基础知识以及你实际会遇到的错误
const data = JSON.parse('{"name": "Ada", "born": 1815}');
// { name: "Ada", born: 1815 }
两个参数:文本和一个可选的恢复函数。仅此而已。
当输入违反 JSON 语法时,它会抛出 SyntaxError
异常,而 JSON 语法比 JavaScript 对象字面量语法更为严格,这往往会让人们措手不及。以下四种情况几乎涵盖了所有失败原因。
单引号。 MDN 明确指出:“JSON 字符串必须用双引号(而非单引号)括起。”这是有效的 JavaScript,但不是有效的 JSON。
JSON.parse("{'name': 'Ada'}"); // SyntaxError
JSON.parse('{"name": "Ada"}'); // fine
尾随逗号。 在现代 JavaScript 中合法,但在 JSON 中非法:
JSON.parse("[1, 2, 3, 4, ]"); // SyntaxError
未加引号的键。 {name: "Ada"}
这是一个有效的对象字面量,但不是 JSON。键必须是带引号的字符串。
响应不是 JSON。 即上文所述的情况。Unexpected token '<'
表示响应正文以 <
开头,这意味着是 HTML。Unexpected end of JSON input
通常表示正文为空——可能是 204 状态码、被截断的响应,或是你忘记等待的 fetch 请求。
请务必将其封装起来,并记录实际接收到的内容:
function parseOrThrow(text, url) {
try {
return JSON.parse(text);
} catch (err) {
throw new Error(
`Failed to parse JSON from ${url}: ${err.message}. ` +
`First 200 chars: ${text.slice(0, 200)}`
);
}
}
其中 text.slice(0, 200)
部分至关重要。如果仅显示 SyntaxError
,则表示解析失败;前 200 个字符会说明原因,通常答案一目了然。
Reviver 函数及其用途
第二个参数会在解析值时对其进行转换:
const data = JSON.parse(text, (key, value) => {
if (key === "created") return new Date(value);
return value;
});
有三点行为值得详细了解。
它采用深度优先的方式运行。 嵌套属性会在其父属性之前被访问,而最后一次调用会使用空字符串作为根值的键。 因此,当你的 reviver 遇到一个对象时,其子节点已经处理完毕。
**返回 ``undefined`
会删除该属性。** MDN 说明:“如果 ``reviver
函数返回 ``undefined
(或未返回任何值),则该属性将从对象中删除。” 这很容易意外触发——如果一个复活器包含一个条件分支,而该分支执行到了末尾,它会返回 ``undefined
并悄无声息地移除键。请始终显式地将 ``value
` 作为默认值返回。
根值可以被完全替换。 “如果你从 ``reviver`
` 返回另一个值,该值将完全替换最初解析的值。这甚至适用于根值。"
经典用法是日期恢复,因为 JSON 没有日期类型:
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
const data = JSON.parse(text, (key, value) =>
typeof value === "string" && ISO_DATE.test(value)
? new Date(value)
: value
);
请谨慎使用模式匹配。任何将日期样式内容转换为日期的恢复器,都会将你希望保留为字符串的内容(如版本号、标识符、偶然看起来像时间戳的用户内容)也一并转换。在了解模式的情况下,建议根据键名进行匹配。
另请注意性能开销:该恢复器会针对文档中的每个值调用一次。对于大型数据负载,这确实是一个需要考虑的性能问题,通常直接解析数据,随后仅转换你关心的字段,反而更高效。
源文本访问:context.source 和 JSON.rawJSON
这是近期最重要的新增功能,许多开发者尚未接触过它。
JSON.parse 源文本访问提案 已进入 TC39 流程的 第 4 阶段,这意味着该提案已获准纳入标准。它解决了提案中直接指出的一个问题:“ECMAScript 值与 JSON 文本之间的转换会造成数据丢失。”
reviver 函数现在针对基本值接收第三个参数。MDN 将 ``context.source`
描述为“表示该值的原始 JSON 字符串”——而提案的表述更为精确,将其定义为源文本“包含标点符号但不包含首尾无关的空格”,同时还定义了 ``index
、``input
和 ``keys
`。
通过一个示例,其重要性便显而易见:
const text = '{"id": 9007199254740993}';
JSON.parse(text).id;
// 9007199254740992 — wrong, silently
JSON.parse(text, (key, value, context) =>
key === "id" ? BigInt(context.source) : value
).id;
// 9007199254740993n — correct
若无法访问源文本,当你的 reviver 看到该数字时,它早已被转换为 JavaScript 双精度数。在你能够干预之前,精度就已经丢失了。context.source
则能为你提供原始的数字。
该提案还新增了 JSON.rawJSON()
,它允许你提供原始的 JSON 文本,JSON.stringify
会将其原样输出——从而完成整个循环,确保从 JSON 中读取的 BigInt 能够无损写回。
JSON.stringify({ id: JSON.rawJSON("9007199254740993") });
// '{"id":9007199254740993}'
在依赖此功能之前,请先检查目标环境是否支持,但这现在已成为解决大数问题的正确方案,而非权宜之计。
数值精度是你在发布时会带入的漏洞
值得单独列出一节,因为它会无声无息地出错,且症状与根本原因相去甚远。
JSON 中的数字会被转换为 JavaScript 数字,即 IEEE 754 标准下的双精度浮点数。大于 Number.MAX_SAFE_INTEGER — 9,007,199,254,740,991 的整数无法全部精确表示。MDN 直截了当地指出:数字“在此过程中可能会丢失精度”。
其危险之处在于不会抛出任何异常。你得到的虽然是一个数字,但它根本不是最初发送的那个数字。
JSON.parse('{"id": 12345678901234567890}').id;
// 12345678901234567000
实际应用中可能引发问题的场景:
- 数据库标识符。 64 位整数主键超出了安全范围。两个不同的记录在解析后可能得到相同的 JavaScript 数字。
- Snowflake 风格的 ID。 多个大型平台都在使用,其值通常超出安全范围。
- 以小单位表示的金融金额。 以美分或聪为单位的大额资金。
- 以纳秒为单位的时间戳。 自 1970 年以来的任何纳秒纪元值都已超出安全范围。
三种缓解方案,按优先级排序:
要求提供字符串。 如果您控制 API,请将大型标识符序列化为字符串。这是最干净的修复方案,在任何地方都有效,且无需任何成本。 MDN 正是这样建议的:“在不损失精度的条件下传输大数的一种方法是将其序列化为字符串,然后恢复为 BigInt。”
使用 context.source 并配合恢复器。 如上所述,当您无法控制数据生成方且您的环境支持此功能时。
使用支持 BigInt 的 JSON 库。 对于较旧的环境,有多种解析器可以处理此情况。这会增加一个依赖项,并带来一些性能开销。
行不通的方法:在解析后检查数字是否“看起来正确”。到那时,信息已经丢失,错误的值与正确的值无法区分。
__proto__ 与原型污染
MDN 指出了 JSON 与 JavaScript 在语义上存在差异的唯一一种情况:“当处理 \"__proto__\" 键时,一段 JSON 文本所表示的值才会与相同的 JavaScript 表达式所表示的值不同。”
在 JavaScript 对象字面量中,__proto__ 会设置原型。而在 JSON.parse 中,它会创建一个普通的自有属性:
const fromLiteral = { __proto__: { admin: true } };
fromLiteral.admin; // true — prototype was set
const fromJson = JSON.parse('{"__proto__": {"admin": true}}');
fromJson.admin; // undefined — plain own property
Object.hasOwn(fromJson, "__proto__"); // true
因此,JSON.parse 本身在此处是安全的——这是设计使然且正确的行为。
危险在于后续发生的情况。原型污染漏洞几乎总是发生在:当代码将解析后的数据合并到另一个对象中,却未对危险键进行过滤时:
// Unsafe: a naive deep merge can walk into Object.prototype
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
merge(target[key] ?? (target[key] = {}), source[key]);
} else {
target[key] = source[key];
}
}
}
若向其输入包含 __proto__ 的有效载荷,便可修改整个程序中的 Object.prototype。防御措施:
显式过滤危险键值 —— __proto__, constructor, prototype —— 在任何涉及不可信数据的合并或赋值操作中。
使用 Object.create(null) 来存储包含不可信键值的对象,这样就不会有原型可供污染。
使用 Map,当您真正构建的是键值存储而非结构化对象时。
根据模式进行验证。 这是通用的解决方案,也能发现其他问题。解析和验证是两个独立的步骤,且两者都必不可少。
JSON.parse 与 eval 与 Response.json()
切勿使用 eval。 它会执行任意代码,用于此目的时效率较低,且会接受非 JSON 格式的内容。在任何情况下,eval 都不适合用于解析 JSON。
Response.json() 才是处理 fetch 时所需的功能。它能一步完成读取正文和解析:
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
res.ok 的检查是人们常会跳过的一步,而跳过这一步正是引言中 Unexpected token '<' 错误的直接原因。fetch 不会因 HTTP 错误状态而拒绝请求——403 或 500 状态码都会被正常解析,随后 .json() 便会尝试解析错误页面。请先检查状态码,若想更彻底,请参考 content-type:
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
const type = res.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
const body = await res.text();
throw new Error(`Expected JSON, got ${type}: ${body.slice(0, 200)}`);
}
const data = await res.json();
请注意,Response.json() 不接受 reviver。如果需要 reviver,请先使用 res.text(),然后使用 JSON.parse。
不同库在此处的处理方式也各不相同——有些会自动解析状态码,并在非 2xx 状态码时抛出异常,这会改变错误处理的位置。我们在 axios 与 fetch 的对比 中比较了它们的行为。
解析大型 JSON 而不导致页面卡死
JSON.parse 是同步且阻塞的。在主线程上,解析大型文档会使其界面在整个解析过程中处于卡死状态——这是导致界面卡顿的一个常见且易于诊断的原因。
大致指导原则:小于 1 兆字节时,无需多虑。 在 1 到 10 兆之间,请在性能最差的目标设备上进行测试。超过 10 兆,请另寻他法。
以下方案按所需工作量由低到高排序:
将其移至 Web Worker。 这是最简单的实际解决方案。在主线程之外进行解析,并将结果发回主线程。 请注意,传输结果本身会产生结构化克隆的开销,因此当 Web Worker 还负责后续处理时,这种方法效果最佳。
减少数据请求量。 分页、字段筛选、使用更窄的端点。这几乎总是正确的解决方案,却也几乎总是被忽略,因为它需要与 API 所有者沟通。
使用流式解析器。 已有相关库可在数据到达时即时输出值,而非构建完整的树结构。当文档确实很大,或者你只需要有效载荷的一部分时,这种方法值得采用。
使用换行符分隔的 JSON。 对于大型集合,每行一个 JSON 文档在增量处理时要容易得多,因为每行都可以独立解析,且截断的流仍能生成完整的记录。如果你能控制格式,这通常是更好的设计——与其他格式的权衡已在我们的 JSON 与 CSV 对比 中详细讨论。
何时不应使用 JSON.parse
当输入不是 JSON 时。 JSON5、JSONC 以及包含注释和尾随逗号的配置文件都需要各自专用的解析器。JSON.parse 会正确地拒绝这些输入,你也不应尝试使用正则表达式去除注释——那样做会导致你不得不编写一个原本不打算编写的解析器。
当你需要的是验证,而不仅仅是解析时。 解析成功仅表明语法有效,但无法说明必填字段是否存在或类型是否正确。请先解析,再进行验证;对于第二步,模式验证库才是正确的工具,而 try/catch 则不适用。
当数据必须无损地进行往返传输时。 大整数、日期、undefined、函数、Map、Set、NaN、Infinity——这些在 JSON 中都无法完整保留。如果要求无损往返传输,请有意识地使用 context.source 和 JSON.rawJSON,或者使用专为此设计的格式。
**当你在每次渲染时都进行解析时。**在热点路径中反复解析同一个字符串纯属浪费。解析一次并缓存即可。
当字符串来自未经检查的请求时。 这是开篇提到的情况,在此重申,因为这是最常见的情况。如果 JSON.parse 在处理获取的数据时抛出异常,那么问题出在上游。检查状态码,检查内容类型,记录请求主体。解析器告诉你的就是事实。
大家还问
JSON.parse 有什么作用?
它将 JSON 格式的字符串转换为 JavaScript 值——对象、数组、字符串、数字、布尔值或 null。它接受一个可选的 reviver 函数,该函数可在解析过程中转换每个值。如果输入不是有效的 JSON,它会抛出SyntaxError异常。
为什么 JSON.parse 报错“Unexpected token '<'”?
因为字符串以 < 开头,这意味着你接收的是 HTML 而不是 JSON —— 通常是错误页面、登录重定向或被屏蔽的页面。解析器没有问题;问题出在请求上。记录响应正文的前 200 个字符,原因通常就显而易见了。
如何在 JavaScript 中解析包含大数值的 JSON?
使用 reviver 的 context.source 参数来读取原始数字并构建 BigInt,因为当值到达 reviver 时,精度已经丢失。更好的做法是,如果你控制着 API,请将大数值标识符序列化为字符串。
JSON.parse 中的 reviver 函数是什么?
这是一个可选的第二个参数,会针对每个键值对按深度优先顺序调用,最后以空字符串键下的根节点结束。该函数返回的值将替换该键的值;若返回 undefined,则会删除该属性。其通常的作用是将字符串转换为更丰富的类型,例如 Date。
JSON.parse 是否安全?
就代码执行而言,是的——与 eval 不同,它绝不会执行任何内容。它还能安全地处理 __proto__,会创建一个普通的自有属性,而非设置原型。风险在于后续操作:在未过滤 __proto__ 和 constructor 的情况下,将不可信的解析数据合并到其他对象中,就会导致原型污染。
JSON.parse 和 Response.json() 有什么区别?
Response.json() 会读取 fetch 响应正文并一步完成解析,且不接受 reviver。JSON.parse 则针对你已经拥有的字符串进行处理。请注意,fetch 不会因 HTTP 错误而拒绝解析,因此在调用 .json() 之前,请先检查 res.ok,否则你会解析错误页面。
JSON.parse 能处理注释或尾随逗号吗?
不能。这两者都是无效的 JSON,都会抛出 SyntaxError 异常。如果你的输入中包含它们,那属于 JSON5 或 JSONC 格式,需要使用针对该格式的解析器,而不是通过正则表达式将其去除。
JSON.parse 会阻塞主线程吗?
是的,它是同步的。对于小于 1 兆字节的文档,这无关紧要;但对于大型数据负载,会导致明显的卡顿。请将解析操作移至 Web Worker、减少请求的数据量,或使用流式解析器。
总结 `
`JSON.parse`` 具有两个参数的签名,其背后蕴含着出人意料的深度。值得借鉴的部分是那些“静默失败”而非“抛出异常”的实现。
数值精度问题最为严重:大整数会被悄无声息地篡改,不会抛出任何异常,且错误值与正确值无法区分,直到下游出现故障才会暴露。解决方法要么是在源代码中使用字符串编码的标识符,要么利用 reviver 的 context.source 参数——该功能目前处于第 4 阶段,并已纳入标准。
reviver 值得被更广泛地使用,特别是在处理日期时,但需注意:若在默认路径上忘记返回 value,属性会被无声地删除。此外,原型污染根本不是 JSON.parse 的问题——该函数能正确处理 __proto__——但对于合并结果的任何操作而言,这确实是个问题,其影响之近足以构成实际困扰。
其他所有问题都可以归结为一个习惯:当解析获取到的数据失败时,在修改任何代码之前,先记录原始数据体。错误信息几乎总是包含答案,而答案通常是:你从一开始就根本没有收到 JSON 数据。