Skip to content
另一种序列化方式:
JSON 序列化的边界行为
概述
JSON.stringify 与 JSON.parse 是语言内置的序列化工具,但实际行为远比“传入对象即得字符串”复杂。函数处理、循环引用、BigInt 报错、Date 自动转换——这些都是 API 表面的固定规则,不同场景下会产生截然不同的工程后果。从 V8 中 JsonStringifier 的执行路径出发,可以梳理这些行为的内在机制,并建立基于数据风险的序列化决策框架。
基本概念
JSON 是一种与语言无关的文本数据交换格式,仅支持六种基本类型:string、number、boolean、null、array、object。JavaScript 中的 JSON.stringify 和 JSON.parse 是这个格式在 JS 引擎中的具体实现,它们必须将 JS 类型映射到 JSON 类型,反过来也一样。因此,所有 JS 特有的类型——undefined、Function、Symbol、BigInt、Map、Set 等——在序列化时都会产生非直观的结果。这不是实现缺陷,而是格式定义与语言现实之间的鸿沟。
工作原理
执行路径与循环引用检测
在 V8 中,JSON.stringify(value) 的实际执行路径大致如下:
text
JSON.stringify(value)
→ C++ JsonStringifier::SerializeJSObject(value)
→ 序列化为 std::vector<uint8_t>
→ 转回 v8::String
→ 返回 JS 字符串JsonStringifier 对象内部维护了两个关键状态:
- Stack(访问栈):用于循环引用检测。序列化每进入一个对象时 push,离开时 pop。如果在栈中发现同一个对象引用,立刻抛出
TypeError: Converting circular structure to JSON。 - PropertyList:对于定义了
toJSON()方法的对象,JsonStringifier会优先调用该方法,然后仅序列化toJSON()返回的值,而不再遍历原对象的自有属性。
这两条规则直接决定了序列化时的两个常见“意外”:循环引用报错和 Date 对象的自动转换。
toJSON 方法
当 JSON.stringify 遇到一个对象时,会先检查该对象是否拥有 toJSON 方法(无论是自身属性还是原型链上的)。若有,则调用 toJSON(),并将返回值作为新的序列化入口值,替代原对象。这一行为由 JsonStringifier 的 PropertyList 逻辑保证。
Date 的原型上就定义了 toJSON:
js
Date.prototype.toJSON = function() {
return this.toISOString();
};所以在序列化 Date 时得到的总是 ISO 格式字符串。
自定义 toJSON 常用来做三件事:
- 排除不可序列化的字段(如函数、临时状态)
- 转换类型(如 Date → 时间戳)
- 调整输出结构以匹配外部系统的 schema
下面这个例子同时展示了类型转换和结构调整:
js
const record = {
event: 'click',
timestamp: new Date(),
handler() {},
toJSON() {
return {
event: this.event,
time: this.timestamp.getTime()
};
}
};
JSON.stringify(record);
// '{"event":"click","time":1700000000000}'handler 函数和 timestamp 的 Date 实例都没有出现在输出中;toJSON 返回的普通对象接管了整个序列化过程。
基本用法
JSON.stringify(value[, replacer [, space]])
value:要序列化的 JS 值。replacer:可选。可以是属性数组(白名单)或一个(key, value) => newValue转换函数。space:可选。用于美化输出的缩进空格数或字符串。
JSON.parse(text[, reviver])
text:待解析的 JSON 字符串。reviver:可选。在解析完成后、返回最终值之前,对每个键值对调用的转换函数,形式为(key, value) => transformedValue。
转换规则
序列化时各种 JS 类型的处理方式,由上述 JsonStringifier 行为直接决定。下表汇总了常见情况:
| 值 | 行为 | 解释 |
|---|---|---|
undefined | 对象属性中跳过;数组中转为 null | JSON 无 undefined 类型 |
Function | 属性中跳过;数组中转为 null | JSON 不支持函数 |
Symbol | 属性中跳过;数组中转为 null | JSON 不支持 Symbol |
BigInt | 抛出 TypeError | 没有安全的数字表示(超出 2⁵³ 精度范围) |
NaN / Infinity | 转为 null | JSON 规范不允许 NaN/Infinity |
Date | 调用 toISOString() | 因为内置 Date.prototype.toJSON |
Map / Set / WeakMap / WeakSet | 转为 {} | 内部数据存储在不可枚举的内部槽中,stringify 只遍历可枚举自有属性 |
| 循环引用 | 抛出 TypeError | Stack 去重检测 |
js
// BigInt 直接报错
JSON.stringify({ id: 1n });
// TypeError: Do not know how to serialize a BigInt
// Infinity 与 NaN 变为 null
JSON.stringify({ val: 1/0, nan: 0/0 });
// '{"val":null,"nan":null}'
// Map 变为空对象
JSON.stringify({ m: new Map([['a', 1]]) });
// '{"m":{}}'注意点
BigInt 与循环引用
这些规则中最容易引发运行时错误的是 BigInt 和循环引用。BigInt 在日志上报或状态持久化时经常出现——因为后端下发的 ID 可能超出 Number 范围,前端接收后转为 BigInt,后续 JSON.stringify 时直接抛出异常,而不是静默转换。对于这类场景,必须在序列化前通过 replacer 将 BigInt 显式转为字符串或数字(当值在安全范围内)。
js
const safeStringify = (data) =>
JSON.stringify(data, (key, value) =>
typeof value === 'bigint' ? value.toString() : value
);reviver 与代码执行风险
JSON.parse 的 reviver 参数会在每个键值对解析完成后被调用,常被用来尝试“恢复”一些 JSON 无法表达的数据——比如函数。
js
// 危险做法:用 eval 恢复函数
JSON.parse(str, (key, value) => {
if (typeof value === 'string' && value.startsWith('function')) {
return eval(`(${value})`); // 这里执行了任意代码
}
return value;
});如果 str 来自用户输入、URL 参数或任何不可信来源,上面的代码等同于一个远程代码执行(RCE)入口。这在任何安全审计中都是不可接受的。
根本的解决方法不是改良 reviver 里的匹配规则,而是不要在数据中混入行为。如果业务模型里确实有“根据场景执行不同逻辑”的需求,可以考虑:
- 在 JSON 中只传递函数标识符(名称或 key),消费方通过查表调用预定义的函数
- 使用 Web Worker 的
postMessage搭配预定义的消息类型,不传输可执行代码 - 在严格受限的环境中,使用
new Function()来动态创建函数——这比eval稍安全(作用域更封闭),但在有 CSP(Content Security Policy)的环境里通常不可用
另一种序列化方式:structuredClone
从 2022 年起,所有现代浏览器和 Node.js 17+ 都支持了 structuredClone。它使用的是 HTML 规范中定义的结构化克隆算法,和 postMessage 传输数据时的内部机制相同,因此不经过字符串中间态。
js
const original = { a: 1, map: new Map(), date: new Date() };
const cloned = structuredClone(original);
// cloned.map 是可用的 Map
// cloned.date 是一个 Date 实例与 JSON.parse(JSON.stringify(obj)) 这种“序列化 + 反序列化”的模式相比,差异集中在类型支持和循环引用上:
| 特性 | JSON 往返 | structuredClone |
|---|---|---|
| 循环引用 | 抛出 TypeError | 正确克隆 |
| Date | 变为字符串 | 保持 Date 对象 |
| Map / Set | 变为 {} | 完整克隆 |
| RegExp | 变为 {} | 完整克隆 |
| ArrayBuffer / TypedArray | 丢失 | 完整克隆 |
| Function | 跳过 | 抛出 DataCloneError |
| Symbol | 跳过 | 抛出 DataCloneError |
| Error 对象 | 变为 {} | 完整克隆 |
| DOM 节点 | 跳过 | 抛出 DataCloneError |
对大型对象(>100KB),structuredClone 通常更快,因为它直接操作引擎内部的二进制格式,无需生成和解析 JSON 字符串。对于小型对象,性能差异可以忽略。
structuredClone 的限制很清楚:它不支持函数、Symbol 和 DOM 节点。当数据中包含这些类型时会直接抛出错误,而不是静默丢弃。这个设计对于调试是有利的——错误比静默数据丢失更容易发现。
在浏览器不支持 structuredClone 的环境里,可以使用 MessageChannel 来间接获得同样的行为:
js
function structuredCloneFallback(obj) {
return new Promise(resolve => {
const { port1, port2 } = new MessageChannel();
port2.onmessage = ev => resolve(ev.data);
port1.postMessage(obj);
});
}但这种方式是异步的,且同样受限于结构化克隆算法的支持类型。对于纯粹的对象深拷贝,lodash.cloneDeep 之类的工具库可以兜底,但它们不提供 structuredClone 对二进制类型的完整支持。
应用场景
不同的业务场景下,序列化的选择不仅取决于类型支持矩阵,还取决于数据生命周期、安全边界和性能开销。以下是一个基于实际约束的参考框架:
| 场景 | 推荐方案 | 关键考量 |
|---|---|---|
| HTTP API 请求/响应 | JSON.stringify + JSON.parse | 兼容性、文本可调试 |
| 纯数据深拷贝 | structuredClone | 类型保真、性能 |
| Web Worker 消息传递 | postMessage(自动使用结构化克隆) | 内置、零开销序列化 |
| IndexedDB 持久化 | structuredClone(部分浏览器可直接存储结构化克隆数据) | 保持类型同步 |
| 包含循环引用的状态拷贝 | structuredClone 或 MessageChannel fallback | 循环引用必须处理 |
| 数据结构中包含 Date / Map / Set | structuredClone | JSON 往返会丢失类型 |
| 包含函数的配置对象 | 重构:分离数据和行为 | 安全边界不允许代码传输 |
| 大型对象(>1MB)的深拷贝 | structuredClone | 避免字符串中间态的内存和 GC 压力 |
| 需要兼容旧浏览器 | lodash.cloneDeep 或 MessageChannel hack | 按需引入 |
