JSON.stringify 会默默丢弃、转换哪些内容,以及为何拒绝序列化某些数据
了解 JSON.stringify 会忽略或修改哪些 JavaScript 值,toJSON、替换函数和恢复函数如何解决这些问题,以及何时使用 structuredClone 更为合适。
JSON.stringify 几乎不会报错。当遇到它无法表示的值时,通常会直接忽略该值或将其转换为其他形式并继续处理,最终返回完全有效的 JSON。正因如此,错误往往会在距离其根源数步之远的地方出现:数据在序列化过程中消失了,但错误却在后续代码中显现,而那些代码根本不知道曾经发生过序列化操作。本指南列出了哪些数据会丢失、哪些类型的值会发生变化以及哪些情况会抛出异常,同时还介绍了各种内置工具(toJSON、替换函数、恢复函数以及 structuredClone),帮助你有针对性地控制每种情况。
毫无痕迹消失的属性
假设有一个会话对象,其中包含了普通数据、一个回调函数、一个被明确设置为 undefined 的字段以及一个符号:
const session = {
userId: 42,
role: "admin",
onExpire: () => console.log("session expired"),
lastActivity: undefined,
tempToken: Symbol("temp"),
};
console.log(JSON.stringify(session));
// {"userId":42,"role":"admin"}
输出结果中仅包含五个属性中的两个。onExpire、lastActivity和tempToken均不见了,既没有抛出异常,也没有发出警告,生成的字符串中更没有任何提示表明有内容被移除。在对象处理过程中,JSON.stringify会跳过那些值为undefined、函数或Symbol的属性。这些类型在JSON格式中无法表示,但序列化工具并不会因此出错,而是会为剩余的内容生成有效的JSON。
其后果较为隐蔽。如果消费者解析该字符串并期望能找到lastActivity,即便其值为undefined,也找不到它。因为该键实际上并不为空,从一开始就未曾出现在输出结果中。那些通过“lastActivity” in obj或Object.keys等方式来区分“缺失”与“存在但为undefined”的代码,在另一端的表现将会不同。
数组的行为也有所不同
在数组中,这些不受支持的值会得到不同的处理:
console.log(JSON.stringify([undefined, function () {}, 1]));
// [null,null,1]
在这里,它们会变成 null 而非完全消失。数组无法在不移动其后所有元素索引的情况下移除某个元素,因此序列化工具会保留该位置,并用 JSON 中最接近“空值”的形式填充它。根本原因是一样的,但根据该值是存在于对象中还是数组中,会出现两种不同的静默行为。另外两个相关的边缘情况也遵循同样的规律:NaN 和 Infinity 会被序列化为 null,而直接对 undefined 或函数调用 JSON.stringify 时,返回的也是 undefined 而非字符串。
日期会被转换为字符串
看似日期能够成功完成往返序列化,但实际上只有一半:
const record = { createdAt: new Date() };
const json = JSON.stringify(record);
console.log(json); // {"createdAt":"2026-09-07T14:30:00.000Z"}
const restored = JSON.parse(json);
console.log(restored.createdAt instanceof Date); // false
console.log(typeof restored.createdAt); // "string"
日期信息本身是完整的,它以 ISO 8601 字符串的形式直接出现在输出中。丢失的是其类型。JSON.parse 无法判断某个字符串原本是 Date 对象,还是仅外观上类似文本的普通字符串,因此它会将其视为字符串返回;除非有代码将其转换回原始类型,否则它始终保持字符串状态。
一旦代码在数据往返处理后调用日期相关方法,就会出现问题。比如 record.createdAt.getFullYear() 就会抛出异常,原因并非数据本身有误,而是因为其类型在传输过程中被悄悄改变了。这种情况常见于 API 响应、从 localStorage 恢复的数值,以及通过队列传递的消息中。
循环结构也会引发异常
并非所有错误都会悄无声息地发生。有些对象根本无法被序列化,此时引擎会明确给出错误提示:
const parent = { name: "parent" };
const child = { name: "child", parent };
parent.child = child;
JSON.stringify(parent); // TypeError: Converting circular structure to JSON
JSON.stringify在构建输出时会遍历对象图。当对象图中某个对象指向其祖先节点时,这种遍历将永无止境。为了避免无限递归,引擎会立即检测到循环并抛出TypeError。这是序列化工具少数会诚实地报错的场景之一,原因也很明确:因为循环对象图确实无法被转换为JSON树结构,所以不存在部分或近似的解决方案。BigInt类型也是类似的问题来源;除非手动转换,否则它也会引发TypeError。
使用toJSON控制输出
某些内置类型已经决定了它们作为 JSON 应该呈现的形式,正因如此 Date 会转换为 ISO 字符串而非空对象。任何对象都可以通过定义 toJSON 方法来采用相同的行为。当存在该方法时,JSON.stringify 会调用它并序列化其返回值,而非对象自身的属性:
class Money {
constructor(cents) {
this.cents = cents;
}
toJSON() {
return { amount: this.cents / 100, currency: "USD" };
}
}
const price = new Money(3499);
console.log(JSON.stringify({ price })); // {"price":{"amount":34.99,"currency":"USD"}}
如果没有 toJSON,Money 实例将会以其内部结构 {"cents":3499} 的形式被序列化,从而泄露本不应被系统其他部分依赖的实现细节。而有了 toJSON 后,对象可以自行定义其公共表示形式。Date 正是使用了这种机制:Date.prototype.toJSON 负责生成 ISO 字符串。
请记住,这是单向的。解析输出后会得到一个包含 amount 和 currency 属性的普通对象,而非 Money 实例;重新构建该类则是接下来要介绍的恢复函数的任务。
替换函数与恢复函数:实现双向处理
JSON.stringify 接受一个可选的第二个参数——替换函数,该函数会在每个键值对被写入之前被调用。若返回 undefined,则该条目会被移除;否则会用返回的值替换原有值。这为在输出时过滤敏感字段提供了一种简洁的方法:
const user = { id: 1, name: "Priya", passwordHash: "a1b2c3..." };
const safe = JSON.stringify(user, (key, value) => {
return key === "passwordHash" ? undefined : value;
});
console.log(safe); // {"id":1,"name":"Priya"}
JSON.parse 则提供了对应的功能:恢复函数,在解析完成后会对每个键值对进行调用。这是解决之前提到的 Date 问题的标准方法:
const restored = JSON.parse(json, (key, value) => {
if (key === "createdAt") return new Date(value);
return value;
});
console.log(restored.createdAt instanceof Date); // true
有两点值得注意。Reviver是从底层向上处理的,因此当父节点被处理时,嵌套的值早已被恢复。此外,仅通过键名检查即可在任意深度匹配到该键,所以嵌套对象中的createdAt字段也会被转换;如果这不是你想要的结果,还需检查该值的格式。
这些都不是API中鲜为人知的复杂部分。它们正是精确控制序列化的预期方式,而且比在字符串化前删除属性或在解析后手动修改对象要可靠得多。
Map和Set会丢失所有内容
那些期望JSON能保留任何类型对象的开发者往往会在这里遇到问题:
const tags = new Set(["urgent", "billing"]);
console.log(JSON.stringify({ tags })); // {"tags":{}}
对于序列化器而言,Set既不是数组,也不是具有可枚举自身属性的普通对象,因此它会变成一个空对象,其中存储的所有值都会被悄悄丢弃。Map也会遭遇同样的命运。如果这些数据结构需要被保留,应在序列化之前显式地进行转换,例如将其转换为数组:
const json = JSON.stringify({ tags: [...tags] }); // convert Set to array before stringifying
对于Map来说,使用[...map]或Object.fromEntries(map)可以生成可序列化的形式,这样在反序列化时就可以重新构建出原来的集合。
深度复制:使用structuredClone
多年来,JSON.parse(JSON.stringify(obj))一直是深度克隆对象的常用方法。但实际上这只是巧合,它同样存在上述所有限制:函数和undefined值会消失,日期会被转换为字符串,集合内容会清空,而循环引用则会导致错误。
现代浏览器和Node.js提供了专门设计的替代方案:
const clone = structuredClone(original);
structuredClone 使用结构化克隆算法实现真正的深度复制。它能保留Date、Map和Set类型的数据,还能处理循环引用——而这些都是JSON方法要么会破坏数据结构要么直接拒绝处理的。不过它也有局限性:对于函数和DOM节点它会抛出DataCloneError错误,而类实例则会被还原为没有原型属性的普通对象。如果只是为了复制数据,structuredClone几乎总是更好的选择。JSON.stringify从一开始就不是为克隆设计的,只是因为它足够方便,人们才将其用于此目的。
关键要点
JSON.stringify仅能序列化JSON能够表示的那些JavaScript值,而非所有JavaScript值。
undefined、函数和符号会被丢弃;在数组中它们会变为null。BigInt会导致错误;Map和Set则会默默地被序列化为{}。toJSON来定义对象的公共结构,用替换函数过滤输出,再通过恢复函数重建类型。structuredClone;而应将JSON.stringify视为一种仅控制格式的过滤工具,需自行处理其中的缺失部分,以避免字段、集合和类型在系统不同模块之间悄然消失。相关阅读
- 超越包大小限制:找出真正导致网页应用变慢的原因 — 为何减少几千字节的数据几乎无法解决应用缓慢的问题,以及如何追踪服务器、工作流、资源加载、第三方脚本和图片所带来的实际等待时间。
- async/await真正能保证什么,以及它还留有哪些问题 — 了解await实际上会暂停哪些操作,如何避免请求串行化处理,以及为何错误处理、取消机制、请求顺序和重试功能需要超出async/await范畴的设计。