首页 / 文章 / JSON.stringify 会默默丢弃、转换哪些内容,以及为何拒绝序列化某些数据

JSON.stringify 会默默丢弃、转换哪些内容,以及为何拒绝序列化某些数据

了解 JSON.stringify 会忽略或修改哪些 JavaScript 值,toJSON、替换函数和恢复函数如何解决这些问题,以及何时使用 structuredClone 更为合适。

1602 词

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"}

输出结果中仅包含五个属性中的两个。onExpirelastActivitytempToken均不见了,既没有抛出异常,也没有发出警告,生成的字符串中更没有任何提示表明有内容被移除。在对象处理过程中,JSON.stringify会跳过那些值为undefined、函数或Symbol的属性。这些类型在JSON格式中无法表示,但序列化工具并不会因此出错,而是会为剩余的内容生成有效的JSON。

其后果较为隐蔽。如果消费者解析该字符串并期望能找到lastActivity,即便其值为undefined,也找不到它。因为该键实际上并不为空,从一开始就未曾出现在输出结果中。那些通过“lastActivity” in objObject.keys等方式来区分“缺失”与“存在但为undefined”的代码,在另一端的表现将会不同。

数组的行为也有所不同

在数组中,这些不受支持的值会得到不同的处理:

console.log(JSON.stringify([undefined, function () {}, 1]));
// [null,null,1]

在这里,它们会变成 null 而非完全消失。数组无法在不移动其后所有元素索引的情况下移除某个元素,因此序列化工具会保留该位置,并用 JSON 中最接近“空值”的形式填充它。根本原因是一样的,但根据该值是存在于对象中还是数组中,会出现两种不同的静默行为。另外两个相关的边缘情况也遵循同样的规律:NaNInfinity 会被序列化为 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"}}

如果没有 toJSONMoney 实例将会以其内部结构 {"cents":3499} 的形式被序列化,从而泄露本不应被系统其他部分依赖的实现细节。而有了 toJSON 后,对象可以自行定义其公共表示形式。Date 正是使用了这种机制:Date.prototype.toJSON 负责生成 ISO 字符串。

请记住,这是单向的。解析输出后会得到一个包含 amountcurrency 属性的普通对象,而非 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中鲜为人知的复杂部分。它们正是精确控制序列化的预期方式,而且比在字符串化前删除属性或在解析后手动修改对象要可靠得多。

MapSet会丢失所有内容

那些期望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 使用结构化克隆算法实现真正的深度复制。它能保留DateMapSet类型的数据,还能处理循环引用——而这些都是JSON方法要么会破坏数据结构要么直接拒绝处理的。不过它也有局限性:对于函数和DOM节点它会抛出DataCloneError错误,而类实例则会被还原为没有原型属性的普通对象。如果只是为了复制数据,structuredClone几乎总是更好的选择。JSON.stringify从一开始就不是为克隆设计的,只是因为它足够方便,人们才将其用于此目的。

关键要点

  • JSON.stringify仅能序列化JSON能够表示的那些JavaScript值,而非所有JavaScript值。
  • 在对象内部,undefined、函数和符号会被丢弃;在数组中它们会变为null
  • 日期会以ISO字符串的形式保留下来,但会失去其类型;需使用恢复函数来还原它们。
  • 循环引用和BigInt会导致错误;MapSet则会默默地被序列化为{}
  • 可使用toJSON来定义对象的公共结构,用替换函数过滤输出,再通过恢复函数重建类型。
  • 若需要复制内容,请使用structuredClone;而应将JSON.stringify视为一种仅控制格式的过滤工具,需自行处理其中的缺失部分,以避免字段、集合和类型在系统不同模块之间悄然消失。
  • 相关阅读

  • npm install的真正作用:注册表、package.json与锁文件 —— 一篇关于npm的实用指南:包括注册表和命令行界面,npm install如何解析包,package.json与package-lock.json记录了什么内容,以及如何发布包。