12k
All articles

如何避免 JSON 把你的对象“压扁”

用replacer、reviver、toJSON和context.source解决JavaScript中的JSON扁平化,准确恢复Date、Map、Set和BigInt。

OpenReplay Team
OpenReplay Team
如何避免 JSON 把你的对象“压扁”

JSON.stringify 通过调用 Date 的 toJSON 方法来转换它,返回一个 ISO 8601 字符串;而 JSON.parse 没有对应的反向步骤,因此除非你自己用 reviver 转换,否则这个值回来时就是一个字符串。

它通常以同样的方式暴露出来:一个缓存对象顺利存入 localStorage,顺利取出,然后 .getFullYear() 抛错,或者某个表格单元格渲染出 Invalid Date。数据从未被破坏。它只是在这两次调用之间的某个地方不再是 Date 了。

本文涵盖这趟往返的两半:出站时的 toJSON 和 replacer,回程时的 reviver,以及用于处理那些在你看到之前就已丢失精度的值的 reviver 第三个参数。本文假定你已掌握基础知识;如果想先补一补,请参阅如何在 JavaScript 中读写 JSON。本文从第二个参数讲起。

要点速览

  • JSON.stringify 通过 toJSON 将 Date 序列化为 ISO 字符串,而 JSON.parse 会原样返回该字符串,除非有 reviver 将其转换回来。
  • 从 reviver 返回 undefined 会让该键从结果中消失,因此每个 reviver 都需要在末尾为它未处理的键加上 return value。
  • reviver 会在每一对键值上运行,先子后父,最后再以键 "" 对整个解析结果调用一次。
  • Map 和 Set 会被序列化为 {},因此恢复它们需要成对编写的 replacer 和 reviver。
  • reviver 的第三个参数是一个 context 对象,其 source 属性保存了原始 JSON 文本,这让你能在 Number 舍入之前把一个大整数读取为 BigInt。

出问题的 JSON 往返长什么样?

const session = { user: "ada", lastLogin: new Date("2024-03-01T09:30:00Z") };

const wire = JSON.stringify(session);
// '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}'

const back = JSON.parse(wire);
typeof back.lastLogin; // "string"
back.lastLogin.getFullYear(); // TypeError

出站转换没有问题。有问题的是入站的那一次——它完全不知道这个字符串原本是什么。

JSON 序列化中哪些值能存活,哪些不能?

JSON 的语法没有为 JavaScript 对象所承载的大部分内容预留位置,所以超出其范围的一切都会被转换或丢弃。MDN 记录了 JSON.stringify 的完整序列化规则;下表第三列是你在 parse 之后实际拿到的东西。

值JSON.stringify 写出JSON.parse 返回
Date通过 toJSON 得到的 ISO 字符串字符串
Map、Set、WeakMap、WeakSet{}空对象
对象中的 undefined、函数、symbol属性被省略属性不存在
数组中的同类值nullnull
NaN、Infinitynullnull
BigInt抛出 TypeError不适用
类实例由可枚举自有属性构成的普通对象普通对象,原型丢失
以 symbol 为键的属性被忽略不存在
循环引用抛出 TypeError不适用
装箱的 Number、String、Boolean拆箱后的原始值原始值

其中两行值得强调。Map 和 Set 会输出为 {},因为 JSON.stringify 遍历的是对象的自有可枚举属性,而它们的条目并不存在于那里。另外,在对象内部,undefined、函数和 symbol 值会被完全省略,而在数组内部同样的值会变成 null,因此索引得以保留,尽管值没有。

toJSON 决定写出什么

当一个值拥有 toJSON 方法时,JSON.stringify 会写出该方法返回的任何内容,并忽略对象本身。MDN 的 toJSON 示例还展示了该方法会收到其值所在的键,因此同一个对象可以根据出现位置的不同而输出不同的结果。

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }
  format() {
    return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
  }
  toJSON() {
    return { __type: "Money", amount: this.amount, currency: this.currency };
  }
}

JSON.stringify({ total: new Money(4599, "EUR") });
// '{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}'

__type 字段就是 reviver 将要查找的判别标记(discriminator)。写出它是这份契约的出站那一半。

JSON.parse 的 reviver 在回程时运行

reviver 是 JSON.parse 的第二个参数,解析产生的每一对键值都会调用它。MDN 的遍历示例展示了调用顺序:最深层的值先来,然后是包含它们的容器,最后一次调用以键 "" 覆盖整个结果。

JSON.parse('{"a":1,"b":{"c":2,"d":{"e":3}}}', (key, value) => {
  console.log(JSON.stringify(key));
  return value;
});
// "a", "c", "e", "d", "b", ""

接下来是那条会悄无声息地毁掉数据的规则:从 reviver 返回 undefined,该键就会从对象中消失;如果在根调用上这么做,整个 parse 的结果就是 undefined。

const json = '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}';

// 破坏性写法:每一个未处理的键都会在末尾掉落并被删除。
JSON.parse(json, (key, value) => {
  if (key === "lastLogin") return new Date(value);
});
// undefined

// 正确写法:兜底的 return 保持其余内容完好。
JSON.parse(json, (key, value) =>
  key === "lastLogin" ? new Date(value) : value,
);
// { user: "ada", lastLogin: Date 2024-03-01T09:30:00.000Z }

两个版本都不会抛错。这正是第一个版本危险之处:损失表现为渲染输出中缺失的字段或原始 ISO 字符串,而不是一条堆栈跟踪——这类缺陷往往在有人提交 bug 报告之前,就已经被会话回放(session replay)捕捉到了。

如何恢复类实例和 Map?

恢复一个真正的实例需要往返的两半:toJSON 在数据旁边写入类型标记,reviver 检查该标记并把其余字段传给构造函数。

const reviver = (key, value) =>
  value && value.__type === "Money"
    ? new Money(value.amount, value.currency)
    : value;

JSON.parse('{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}', reviver)
  .total.format(); // "45.99 EUR"

同样的模式适用于那些没有 toJSON 的内置类型。Map 通过 replacer 以条目数组的形式输出,再由一个能识别“数组的数组”的 reviver 还原回来。

const flags = new Map([["beta", true], ["darkMode", false]]);

const text = JSON.stringify({ flags }, (key, value) =>
  value instanceof Map ? Array.from(value.entries()) : value,
);
// '{"flags":[["beta",true],["darkMode",false]]}'

const restored = JSON.parse(text, (key, value) =>
  Array.isArray(value) && value.every(Array.isArray) ? new Map(value) : value,
);
restored.flags.get("beta"); // true

这种形状检测只是一种猜测,而且在空数组上会误判:[].every(Array.isArray) 为 true,因此载荷中任何位置的普通 [] 都会变成一个空 Map。使用类型标记——就像 Money 所写入的那样——可以消除这种猜测。

replacer 和 reviver 是关于同一种传输格式(wire format)的同一份约定。单独改动任何一侧,往返就会失效。

replacer:出站时的过滤

replacer 是 JSON.stringify 的第二个参数,有两种形式。作为函数时,它会对每一对键值运行,返回 undefined 则省略该属性。作为数组时,它充当白名单,其中只有字符串和数字条目才算数,你放进列表里的其他任何东西(包括 symbol)都完全不起作用。

const account = { id: 7, email: "ada@example.com", password: "hunter2" };

JSON.stringify(account, (key, value) => (key === "password" ? undefined : value));
// '{"id":7,"email":"ada@example.com"}'

JSON.stringify(account, ["id", "email"]);
// '{"id":7,"email":"ada@example.com"}'

同样的技巧可以丢掉一个已知的反向引用键,否则它会让 JSON.stringify 在遇到环时抛出 TypeError。一个通用的、能安全处理环的序列化器需要一个记录已访问对象的 WeakSet;丢掉某个具名键只能处理你已知的那种情况。

有一个时序细节很重要:toJSON 在 replacer 看到值之前就已运行,因此对于 Date,replacer 的 value 参数已经是 ISO 字符串,而 this[key] 仍然是原始对象。

JSON.stringify({ lastLogin: new Date() }, function (key, value) {
  // 必须使用普通函数:箭头函数在这里没有 `this` 绑定。
  return key === "lastLogin" ? this[key].getTime() : value;
});

第三个参数 space 只影响格式。要求超过 10 个空格,你仍然只会得到 10 个;长度超过 10 个字符的缩进字符串会被截断为前 10 个字符。

用 context.source 读取原始文本

reviver 的第三个参数是一个 context 对象,每次调用都会新建,其 source 属性保存该值对应的原始 JSON 文本。该参数只会在原始值上出现;对象或数组不会得到它。这来自 TC39 的 JSON.parse 源文本访问提案,它已进入 Stage 4,并随 ECMAScript 2026 发布,于 2026 年 6 月 30 日经 Ecma International 批准。

它解决的是一种在任何 reviver 能够介入之前就已发生的损失:等你拿到 value 时,大整数早已被舍入成一个双精度浮点数。

const wire = '{"orderId": 9007199254740993}';

JSON.parse(wire).orderId;
// 9007199254740992  <- 精度已经丢失

JSON.parse(wire, (key, value, context) =>
  key === "orderId" ? BigInt(context.source) : value,
).orderId;
// 9007199254740993n

value 是有损的产物。context.source 才是传输中实际存在的内容。在依赖它之前,请先确认你的目标运行时是否支持。

小结

序列化是一份你要写两遍的契约:一遍写在 toJSON 或 replacer 中,一遍写在能理解前一半产出的 reviver 中。梳理一遍你推入 localStorage 或缓存层的对象,找出其中携带 Date、Map、Set 或类实例的那些,为每一个加上类型标记和对应的 reviver 分支。然后检查你已有的每一个 reviver 是否都以兜底的 return value 结尾。

常见问题

structuredClone 能免去对 reviver 的需要吗?

不能,因为 structuredClone 产生的是内存中的副本而不是 JSON 字符串,所以它无法被写入 localStorage 或请求体。它确实能保留 Date、Map、Set 和循环引用,但遇到函数会抛出 DataCloneError,而且不会复制原型链,因此类实例仍然会变成没有方法的普通对象。要从文本恢复实例,仍然需要 reviver。

我该用 toJSON 还是 replacer 函数?

当类型自身拥有其传输格式时使用 toJSON:该方法定义在类上,因此该值的每一次序列化都会产生相同的形状,调用方无需做任何事。当规则只属于某一个调用点时使用 replacer,例如剥离 password 字段,或转换来自你无法控制的库的 Map。toJSON 先运行,所以 replacer 收到的是 toJSON 返回的内容。

reviver 也会在数组元素上运行吗?

会。数组索引会以字符串形式传给 reviver,因此第一个元素到来时键为 '0',随后数组本身再以它自己的键向上传递。对某个元素返回 undefined 会删除该元素,而不会让其余元素前移,从而留下一个空洞,同时数组 length 保持不变。数组的 reviver 与对象的 reviver 一样需要兜底的 return value。

在 TypeScript 中如何为 JSON.parse 的 reviver 标注类型?

无论 reviver 做了什么,JSON.parse 在 TypeScript 中都返回 any。标准库将 reviver 声明为接收 string 类型的 key、any 类型的 value 并返回 any,因此一个重建 Date 或类实例的 reviver 不会给编译器提供任何额外信息。请在调用点用显式类型标注结果,或者在信任其形状之前先让解析后的值通过一个 schema 校验器。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.