JSON.parse 性能:V8 如何处理大型负载
现代 V8(Node.js、Chrome、Deno 背后的引擎)在 JSON.parse 中并不使用通用的递归下降解析器。自 2019 年起,V8 内置了一个用 C++ 编写的专用 fast-path 解析器,它直接在 UTF-16 字符串上工作,在 young generation 中分配对象,并利用 transition tree(hidden class)让数组中的同级对象共享形状(shape)信息。对于 [{"id":1,"name":"a"},{"id":2,"name":"b"}, ...] 这样的负载,第一个之后的每个对象都会以相同的 hidden class 创建——因此后续的属性访问是 monomorphic 的,并且会被内联。
JSON.parse 的开销主要由三部分决定:(1) 字符串的分词与反转义,(2) 结果对象图带来的 GC 压力,(3) reviver 回调的成本。添加 reviver 函数是最大的性能陷阱:它迫使 V8 退回到缓慢的通用路径,并对树中的每个键都调用一次 JS 函数。在 10 MB 的负载上,差距通常是 4–8 倍。如果只需要转换少数几个字段,先解析,再自己遍历结果。
对于由同质对象组成的超大数组,V8 常常能胜过流式 JSON 库,因为 fast path 优化得实在太好。但一旦单个文档超过约 100 MB,JSON.parse 会同时把整个原始字符串和结果对象树保留在内存中,峰值 RSS 可能达到文件大小的 3–4 倍。此时应改用 simdjson、oboe.js 或 stream-json 之类的流式解析器,它们在产生 token 的同时发出事件,从不物化完整的树。
V8 还会在多次调用之间缓存解析器的分词缓冲区,因此在紧凑循环中对许多小负载调用 JSON.parse,比把同样的总字节量放进一次巨型调用中解析更快。如果线上传输格式由你掌控,优先选择 NDJSON(每行一个 JSON 对象)——它可以增量解析,能按行从错误中恢复,并且与简单的按行缓冲 IO 配合良好。
// SLOW: reviver kills the fast path
const data = JSON.parse(raw, (key, val) => {
if (key === 'createdAt') return new Date(val);
return val;
});
// FAST: parse first, walk later
const data = JSON.parse(raw);
function walk(o) {
for (const k in o) {
if (k === 'createdAt' && typeof o[k] === 'string') o[k] = new Date(o[k]);
else if (o[k] && typeof o[k] === 'object') walk(o[k]);
}
}
walk(data);
// FASTER for huge files: NDJSON
import readline from 'node:readline';
const rl = readline.createInterface({ input: fs.createReadStream('big.ndjson') });
for await (const line of rl) {
const row = JSON.parse(line); // 1 record at a time
process(row);
}生产环境中你一定会遇到的 JSON 陷阱
JSON 看起来简单,而这正是它伤人的原因。RFC 8259 对语法有严格定义:数字只有 IEEE-754 双精度浮点一种,字面量只有 true / false / null,字符串必须是双引号包裹的 UTF-8,而且没有原生日期类型、没有注释、没有尾随逗号,也没有 Infinity / NaN。所有与 JSON 相关的“生产环境怪异 Bug”,都是这六条规则中的某一条被现实打破的结果。
NaN 和 Infinity 不是合法的 JSON 值。JavaScript 的 JSON.stringify 会悄悄把它们转换成 null,这意味着 { "p99": Infinity } 这样的指标会被序列化为 { "p99": null },而你的仪表盘则安静地显示零。解决办法是在序列化之前对这些值做钳制或特殊处理——永远不要信任往返转换。
Date 对象的往返是不对称的。JSON.stringify(new Date()) 会通过 Date.prototype.toJSON 方法输出 ISO-8601 字符串,但 JSON.parse 完全不知道那个字符串是日期——你拿回来的是字符串,而不是 Date。如果你的代码在一次网络往返之后调用 data.createdAt.getTime(),它会抛出异常。要么用已知的 schema(zod、valibot、ajv)包裹解析过程,要么在文档中明确说明:时间戳返回时是字符串。
BigInt 是硬错误:JSON.stringify(1n) 会抛出 TypeError: Do not know how to serialize a BigInt。以 BigInt 返回 64 位 ID 的数据库驱动(Postgres 的 bigint、MySQL 的 UNSIGNED BIGINT)会不断撞上这个问题。务实的解法是:(a) 在应用启动时定义 BigInt.prototype.toJSON = function() { return this.toString() },并接受接收方拿到的是字符串;(b) 使用 superjson 这类能保留类型的序列化器。
循环引用同样是 TypeError。从 DOM 树到带 parent 指针的图数据结构,任何这类对象都会让 JSON.stringify 爆炸。replacer 参数可以跳过已知的循环键(返回 undefined 即丢弃)。调试时,util.inspect 或 structuredClone(内部会检测循环)更加友好。
最后是键的顺序:规范说对象是无序的,但 V8 的 JSON.stringify 会先输出类整数键,其余按插入顺序输出。如果你的测试断言的是精确的 JSON 字符串,那你测试的其实是实现细节——应该改为对解析后的对象进行断言。
// NaN/Infinity silently become null
JSON.stringify({ p99: NaN, max: Infinity });
// => '{"p99":null,"max":null}' (BUG: silent data loss)
// Date asymmetric round-trip
const o = { at: new Date() };
const back = JSON.parse(JSON.stringify(o));
back.at.getTime(); // TypeError: back.at.getTime is not a function
// BigInt fix at app boot
BigInt.prototype.toJSON = function () { return this.toString(); };
// Circular reference handler
function safeStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v)) return '[Circular]';
seen.add(v);
}
return v;
});
}JSON vs JSON5 vs JSONC——选对方言
JSON 本身是被冻结的标准——RFC 8259 就是规范,它刻意省略了一切便于人类使用的特性。这种克制对线上传输格式极好,对手工编辑的配置文件却很糟糕,这正是两种非官方方言主导配置领域的原因。
JSONC(“JSON with comments”)是 VS Code、TypeScript 的 tsconfig.json 以及大多数 Microsoft 工具链采用的方言。它在 RFC 8259 之上恰好只增加两样东西:行注释(//)和块注释(/* */)。尾随逗号被 VS Code 的解析器所容忍,但不属于任何成文规范。JSONC 刻意保持极简——除去注释之外,每个 JSONC 文档都是 JSON 文档。在 TypeScript 项目中无需安装任何库:编译器自带宽容的解析器,用一个小正则去掉注释(注意字符串内的处理)就能得到合法 JSON。
JSON5 更有野心:它加入了单引号字符串、不加引号的 ECMAScript-5 标识符键、多行字符串、十六进制数、前置/后置小数点(.5、5.)、数字前的正号、NaN 和 Infinity,以及尾随逗号。代价是 JSON5 不再是 JSON 的子集——你不能把 JSON5 文档喂给 JSON.parse。把它用在以人为本、机器端可以使用 json5 npm 包的配置文件上;绝不要把它当作线上传输格式。
第三个竞争者 HJSON 连键乃至短字符串值的引号都省掉了。它比 JSON5 更宽松,但生态支持更窄。除非你的工具已经以它为标准,否则请避开。
经验法则:API 和进程间消息坚持使用 RFC 8259 严格 JSON,以获得最大兼容性(每种语言都有稳定的解析器)。真正由人阅读和编辑的编辑器与构建配置可以用 JSONC,低摩擦地获得注释能力。只有当你确实需要 JSON5 的丰富特性、且写入方和读取方都在你掌控之中时,才去用它。并且绝不要让 JSON5 文档泄漏进期望 JSON 的系统——这种不匹配会在最糟糕的时刻抛出令人费解的“Unexpected token”错误。
// JSON (RFC 8259) — strict
{
"name": "app",
"ports": [3000, 3001]
}
// JSONC — comments + (de facto) trailing commas
{
// tsconfig.json style
"compilerOptions": {
"target": "ES2022", /* matches Node 18+ */
"strict": true,
},
}
// JSON5 — humans first
{
name: 'app', // unquoted key, single-quoted string
port: 0xABCD, // hex
rate: .5, // leading decimal
retries: +Infinity, // signed Infinity
// multi-line string
banner: "line1\
line2",
}