Skip to content

JSON 与复制:保存数据时会丢掉什么 ​

把对象变成文本、复制一份数据、复制一个类实例,是三个不同任务。JSON.parse(JSON.stringify(value)) 看起来一行搞定,实际是在做一次有损的格式往返。先确定要保留什么,再选择工具。

JSON 是数据语法,不是任意 JavaScript 源码 ​

js
const note = { title: 'JSON', minutes: 15, finished: false };
const text = JSON.stringify(note);
console.log(text); // {"title":"JSON","minutes":15,"finished":false}
const restored = JSON.parse(text);
console.log(restored.title, restored === note); // JSON false

JSON 允许对象、数组、字符串、数值、布尔值与 null。键和字符串用双引号,不允许注释、函数调用、尾逗号、undefined 或 BigInt 字面量。JSON.parse 解析数据,不运行 JavaScript;不能用 eval 替代。

解析成功只证明语法合法,不证明数据满足业务要求。仍要检查字段存在性、类型、范围与结构。大整数在 JSON 文本中写成普通数字,解析为 Number 时可能已经丢精度,可靠接口应约定把 ID 编为字符串。

同一个值在对象和数组中命运不同 ​

js
console.log(JSON.stringify({ a: undefined, b: NaN, c: Infinity }));
// {"b":null,"c":null}
console.log(JSON.stringify([undefined, NaN, Infinity])); // [null,null,null]
console.log(JSON.stringify(undefined)); // undefined:不是字符串
console.log(JSON.stringify(new Map([['a', 1]]))); // {}

对象里的 undefined、函数和 Symbol 值通常被省略;数组相应位置变成 null,空槽也变 null。非有限数值变 null。Symbol 键不会输出。Map/Set 不会自动展开内部条目;Date 默认通过 toJSON 生成时间文本。循环引用和默认 BigInt 序列化会抛错。

这些不是“深复制有一点小限制”,而是数据模型发生变化。哪怕返回了一个新对象,也不能证明原数据语义被保留。

replacer、reviver 与 toJSON ​

js
const record = { title: 'JSON', internal: true, id: 123n };
const text = JSON.stringify(record, (key, value) => {
  if (key === 'internal') return undefined;
  if (typeof value === 'bigint') return value.toString();
  return value;
}, 2);
console.log(text);
const restored = JSON.parse(text, (key, value) => key === 'id' ? BigInt(value) : value);
console.log(restored.id === 123n); // true

replacer 可筛选或变换值,数组形式可列出允许输出的属性名;第三参数控制缩进。reviver 自底向上处理解析结果,返回 undefined 会删除对应属性;它也处理最外层空字符串键所代表的根结果。

例子把 id 约定为十进制字符串,所以能安全恢复。不能把所有看起来像数字的字符串都变 BigInt,邮编、电话和普通文本可能被误伤。对象定义 toJSON 时,会先按其结果序列化;这也是类可以明确控制外部数据形式的入口。

较新 JSON 接口如 JSON.rawJSON/isRawJSON、reviver 的源文本上下文用于更细致地保留或注入合法 JSON 数值文本。它们需要单独检查运行时支持,也不替代字段协议;基础接口用明确字符串编码更容易跨语言一致。

reviver 是处理树的每个节点,不只是顶层字段 ​

js
const order = [];
const data = JSON.parse('{"part":{"minutes":12},"done":false}', (key, value) => {
  order.push(key === '' ? '<root>' : key);
  return value;
});
console.log(order); // ['minutes', 'part', 'done', '<root>']
console.log(data.part.minutes); // 12

先处理子节点minutes,再处理包含它的part,最后处理整个根结果。返回值会替换当前节点;返回undefined表示删除当前属性,连根也一样。如果忘记在不需要转换的分支return value,就可能把整棵结果删除,而不是“保持原样”。

数组节点被删除时会产生空槽:

js
const list = JSON.parse('[1,2,3]', (key, value) => key === '1' ? undefined : value);
console.log(list.length, 1 in list); // 3 false
console.log(JSON.stringify(list)); // [1,null,3]

第一次解析删除索引1但长度保留3,再序列化时空槽编码成null。这串变化用到了数组属性规则与JSON规则,不能只把reviver理解成“对每个值map一下”。

日期恢复也需要字段约定。只有知道某个字段语义是时间点,才适合把它恢复为Date;一段小说正文恰好长得像ISO时间,并不该被自动改成日期对象。格式解析和数据语义是两道关卡。

浅复制为第一层新建容器 ​

js
const original = { info: { page: 1 } };
const shallow = { ...original };
shallow.info.page = 2;
console.log(original.info.page); // 2
const detached = { ...original, info: { ...original.info } };
detached.info.page = 3;
console.log(original.info.page); // 2

展开、Object.assign、数组 slice/concat、new Map(oldMap) 都不会递归隔离内部对象。需要修改哪一层,显式复制哪一层,往往比盲目复制整棵数据更清楚。第四章的属性描述符与原型又是另一层对象行为,不由展开保留。

structuredClone 保留支持的数据图 ​

js
const source = { point: { x: 1 }, tags: new Set(['JS']) };
source.self = source;
const copy = structuredClone(source);
copy.point.x = 2;
console.log(source.point.x); // 1
console.log(copy.self === copy, copy.tags.has('JS')); // true true

structuredClone 属于宿主提供的结构化克隆能力,现代浏览器和 Node.js 22 可用,不是 ECMAScript JSON 的一个方法。它支持循环、Date、Map、Set、BigInt、ArrayBuffer 等可克隆数据,并保留克隆图内共享引用关系。

函数、Symbol 值、WeakMap/WeakSet、DOM 节点等不在普通可克隆集合内,会导致 DataCloneError。自定义类的原型、私有字段和属性描述符不会按“复制实例行为”的期望保留;RegExp 的 lastIndex 也不是任意状态快照。普通对象的 getter 可能被读取为数据值,不能认为克隆完全没有取值副作用。

带 transfer 可以转移 ArrayBuffer 等底层资源,源缓冲区会被分离;这不是普通复制,使用前必须明确后续不再读源资源。二进制与 Worker 章节会给出完整转移实验。

深复制还要问:副本内部的共享关系保留了吗 ​

js
const shared = { page: 1 };
const source = { current: shared, saved: shared };
const structured = structuredClone(source);
const jsonCopy = JSON.parse(JSON.stringify(source));
console.log(structured.current === structured.saved); // true
console.log(structured.current === shared); // false
console.log(jsonCopy.current === jsonCopy.saved); // false

结构化克隆既隔离了原图,又保留了副本图中的共同指向。JSON把两个字段各编码一份对象文本,解析后得到两个独立对象;虽然最初字段内容相同,后续修改行为已经不同。

text
原图:current ─┐           结构化副本:current ─┐
              ├→ 对象A                         ├→ 对象B
      saved ──┘                     saved ─────┘
JSON副本:current → 对象C,saved → 对象D

为正确处理这种图,克隆算法需要记录“这个原对象已经对应哪个副本”,遇到同一对象再次出现时复用那个副本。循环引用也是同一原理:返回已经建立的副本,不再无限递归。只用递归复制每个属性却不记录访问关系,会破坏共享,遇到环还可能无法结束。

因此“修改副本不影响原对象”只是一个检查项,还应检查副本内部的共享关系、数据类型、访问器和原型是否符合预期。用于传输的纯数据可以允许身份丢失;用于本地隔离的一张关系图可能不能。选择工具前把要求说清,才能判断哪种损失可接受。

选择时先列出保留清单 ​

目标选择
对外交换可定义的数据JSON 加明确字段协议与校验
改动普通记录某一层展开并复制受影响的嵌套层
隔离支持的复杂数据图structuredClone
重建有行为的类实例类自己的构造或恢复接口

手写递归深复制至少要处理循环、重复引用、原型、描述符、Symbol、内建对象与宿主对象。只检查 typeof x === 'object' 再循环属性的函数,无法称为通用深复制。也不要把两个对象 JSON.stringify 结果相等当作完整深相等判断:字段缺失、属性顺序和类型丢失都会影响结果。

练习 ​

  1. 预测 { a: undefined, b: NaN } 与 [undefined, NaN] 的 JSON 文本,并解释差异。

  2. 构造 obj.self = obj 的循环对象,用结构化克隆证明副本自引用指向副本,而不是原对象。

  3. reviver不处理的分支忘记return value会怎样?两个字段原来引用同一对象,JSON往返与structuredClone对副本内部身份有什么不同?

查看答案

用心记录每一步成长