慎用 `JSON.parse(JSON.stringify(obj))` 进行深拷贝:一个充满陷阱的捷径

269 阅读7分钟

前言

JSON.parse(JSON.stringify(obj)) 实现深拷贝的方式虽然看起来简单,但在很多情况下不够好,并且存在明显的局限性潜在风险。理解这些限制对于编写健壮的 JavaScript 代码至关重要。

它是如何工作的?

这个方法利用了 JSON 的两个核心操作:

  1. JSON.stringify(obj): 将 JavaScript 对象 obj 转换为一个 JSON 格式的字符串。这个过程会递归地处理对象内部的属性。
  2. JSON.parse(...): 将上一步生成的 JSON 字符串解析(反序列化)成一个新的 JavaScript 对象。

由于数据经过了“对象 -> 字符串 -> 对象”的转换,新生成的对象与原始对象在内存中是完全独立的副本,修改新对象不会影响旧对象,从而实现了深拷贝。


优点:

  • 写法简单: 代码非常简洁,容易理解其意图。
  • 无需外部库: 不依赖像 Lodash 这样的第三方库,是 JavaScript 的原生功能。
  • 适用于纯粹的 JSON 安全数据: 对于只包含字符串、数字、布尔值、null 以及由这些类型构成的嵌套对象和数组的数据结构,它能很好地工作。

缺点:

它的主要问题在于 JSON 本身的局限性以及序列化/反序列化的过程特性:

我们来详细讲解一下 JSON.parse(JSON.stringify(obj)) 深拷贝方法的三个主要缺点。

缺点 1: 数据类型丢失或转换 (最核心的问题)

JSON (JavaScript Object Notation) 设计的初衷是作为一种轻量级的数据交换格式。它的类型系统比 JavaScript 的类型系统要简单得多JSON.stringify() 的作用是将 JavaScript 值转换为 JSON 字符串,这个过程必须遵守 JSON 的格式规范,这就导致了信息丢失或类型变化。

以下是具体类型在转换过程中的变化:

  1. undefined (未定义值)

    • 丢失场景:undefined 作为对象的属性值时,在 JSON.stringify() 过程中,该键值对会完全丢失

    • 数组场景:undefined 作为数组的元素时,它会被转换成 null

    • 直接转换: JSON.stringify(undefined) 本身会返回 undefined (而不是字符串 "undefined"),这使得 JSON.parse(JSON.stringify(undefined)) 会直接报错,因为 undefined 不是有效的 JSON。

    • 影响: 如果你的原始对象依赖于某个属性存在但值为 undefined 这种状态,拷贝后的对象会失去这个属性,可能导致后续逻辑错误。

    • 示例:

      const obj = { a: 1, b: undefined, c: [10, undefined, 20] };
      const jsonString = JSON.stringify(obj); // '{"a":1,"c":[10,null,20]}'
      const copiedObj = JSON.parse(jsonString);
      console.log(copiedObj); // 输出: { a: 1, c: [ 10, null, 20 ] }
      // 注意:属性 'b' 消失了,数组中的 undefined 变成了 null
      console.log(copiedObj.hasOwnProperty('b')); // false
      
  2. Function (函数)

    • 丢失: 函数不是 JSON 支持的数据类型。当 JSON.stringify() 遇到函数类型的属性值时,会完全忽略该键值对。

    • 影响: 如果你的对象包含方法(函数),这些方法在拷贝后的对象中会丢失。拷贝后的对象只是一个纯数据对象。

    • 示例:

      const obj = {
        name: "Example",
        sayHello: function() { console.log("Hello!"); },
        value: 10
      };
      const jsonString = JSON.stringify(obj); // '{"name":"Example","value":10}'
      const copiedObj = JSON.parse(jsonString);
      console.log(copiedObj); // 输出: { name: 'Example', value: 10 }
      // 'sayHello' 方法丢失了
      // copiedObj.sayHello(); // 这会报错 TypeError: copiedObj.sayHello is not a function
      
  3. Date 对象 (日期)

    • 转换: Date 对象在 JSON.stringify() 时会自动调用其 toJSON() 方法,这个方法返回一个表示该日期的 ISO 8601 格式的字符串 (例如: "2023-10-27T12:30:00.000Z")。

    • 不恢复: JSON.parse() 在解析这个字符串时,不会自动将其转换回 Date 对象。它仍然是一个字符串。

    • 影响: 拷贝后的对象中,原本是 Date 对象的属性现在变成了字符串,如果你需要对其进行日期运算,会出错。

    • 示例:

      const obj = { eventName: "Meeting", time: new Date() };
      const jsonString = JSON.stringify(obj); // '{"eventName":"Meeting","time":"2023-10-27T...Z"}' (具体时间字符串)
      const copiedObj = JSON.parse(jsonString);
      console.log(copiedObj); // 输出: { eventName: 'Meeting', time: '2023-10-27T...Z' }
      console.log(typeof copiedObj.time); // 'string'
      // copiedObj.time.getFullYear(); // 报错 TypeError: copiedObj.time.getFullYear is not a function
      
  4. RegExp (正则表达式)

    • 转换: 正则表达式对象在 JSON.stringify() 时会被转换成一个空对象 {}

    • 影响: 拷贝后的对象丢失了正则表达式的模式和标志,无法再用于匹配。

    • 示例:

      const obj = { pattern: /ab+c/i, value: 5 };
      const jsonString = JSON.stringify(obj); // '{"pattern":{},"value":5}'
      const copiedObj = JSON.parse(jsonString);
      console.log(copiedObj); // 输出: { pattern: {}, value: 5 }
      // 正则表达式信息丢失
      // "abc".match(copiedObj.pattern); // 无法按预期工作
      
  5. Map, Set (集合类型)

    • 转换: MapSet 这两种 ES6 新增的数据结构,在 JSON.stringify() 时同样会被转换成空对象 {} 。它们包含的元素会全部丢失。

    • 影响: 无法正确拷贝 MapSet 结构。

    • 示例:

      const obj = {
        myMap: new Map([['a', 1], ['b', 2]]),
        mySet: new Set([1, 2, 3])
      };
      const jsonString = JSON.stringify(obj); // '{"myMap":{},"mySet":{}}'
      const copiedObj = JSON.parse(jsonString);
      console.log(copiedObj); // 输出: { myMap: {}, mySet: {} }
      // Map 和 Set 的内容丢失
      
  6. FormData (表单数据)

    • 转换: FormData 对象在 JSON.stringify() 时同样会被转换成一个空对象 {} 的字符串。它内部包含的键值对(包括文件、字段等)会在序列化过程中全部丢失。

    • 原因: 因为 FormData 实例上没有表示其内容的、可枚举的自身属性,所以当 JSON.stringify() 尝试序列化一个 FormData 对象时,它找不到任何可以包含在 JSON 输出中的内容。因此,它只能返回一个表示空对象的字符串:"{}"

    • 影响: 无法正确拷贝 FormData 结构及其包含的数据。

    • 示例:

      const formData = new FormData();
      formData.append('file', new File(['content'], 'test.txt'));
      formData.append('username', 'test');
      const obj = { myForm: formData, value: 123 };
      const jsonString = JSON.stringify(obj); // '{"myForm":{},"value":123}'
      const copiedObj = JSON.parse(jsonString);
      console.log(copiedObj); // 输出: { myForm: {}, value: 123 }
      // FormData 内容丢失
      
  7. NaN, Infinity, -Infinity (特殊数值)

    • 转换: JavaScript 中的特殊数值 NaN (Not a Number)、Infinity (正无穷) 和 -Infinity (负无穷) 在 JSON.stringify() 时会被转换成 null

    • 影响: 拷贝后的对象丢失了这些特殊的数值信息,变成了 null

    • 示例:

      const obj = { a: NaN, b: Infinity, c: -Infinity, d: 10 };
      const jsonString = JSON.stringify(obj); // '{"a":null,"b":null,"c":null,"d":10}'
      const copiedObj = JSON.parse(jsonString);
      console.log(copiedObj); // 输出: { a: null, b: null, c: null, d: 10 }
      

缺点 2: 性能问题

虽然 JSON.parse(JSON.stringify()) 写起来简单,但在处理大型或层级很深的对象时,它的性能可能不是最优的。

  • 序列化开销: JSON.stringify() 需要遍历整个对象(包括所有嵌套的对象和数组),并将其转换成一个长字符串。这个过程涉及类型检查、字符串拼接等操作,会消耗 CPU 和内存。
  • 反序列化开销: JSON.parse() 需要读取这个长字符串,根据 JSON 语法规则解析它,并重新构建 JavaScript 对象。这个过程同样需要消耗计算资源。

对于非常大的数据结构,这个“先变字符串再变回来”的过程可能比专门优化的深拷贝库(如 Lodash 的 _.cloneDeep)或原生 API(如 structuredClone)要慢。虽然对于中小型对象差异不明显,但在性能敏感的场景下需要考虑。


缺点 3: 无法处理循环引用

循环引用是指对象内部的属性直接或间接引用了对象自身。

  • 例如:

    const obj = { name: 'A' };
    obj.self = obj; // obj 引用了自身,形成循环
    

    或者

    const a = { name: 'A' };
    const b = { name: 'B' };
    a.link = b;
    b.link = a; // a 和 b 相互引用,形成循环
    
  • JSON.stringify() 的行为:JSON.stringify() 检测到循环引用时,它无法将这个无限结构转换成有限的 JSON 字符串,因此会直接抛出 TypeError 错误 (通常是类似 "Converting circular structure to JSON" 的错误信息)。

  • 影响: 如果你的数据结构中可能存在循环引用(这在复杂应用中并不罕见,例如双向链表、某些树结构或 DOM 对象),使用 JSON.parse(JSON.stringify()) 会直接导致程序崩溃。

选择更优的深拷贝方法:

  • structuredClone() (现代首选):

    • 优点: 内置于现代浏览器和 Node.js (v17+),性能较好,支持更多数据类型(Date, RegExp, Map, Set, Blob, File, ArrayBuffer, ImageData 等,包括 FormData!),能处理循环引用。
    • 缺点: 仍不支持拷贝函数 (Function),会丢失原型链和不可枚举属性。
    • 用法: let form = structuredClone(obj);
  • Lodash _.cloneDeep():

    • 优点: 功能非常强大和成熟,由广泛使用的库提供,对各种数据类型和边界情况(包括函数、原型链、循环引用等)处理得很好。
    • 缺点: 需要引入 Lodash 库,可能比原生方法略慢。
    • 用法: import _ from 'lodash'; let form = _.cloneDeep(obj);

总结

JSON.parse(JSON.stringify()) 是一个看似简单实则充满陷阱的深拷贝“快捷方式”。它只适用于非常受限的、只包含 JSON 安全类型(字符串、数字、布尔值、null)且无特殊对象(如 Date, RegExp, Map, Set, FormData 等)和无循环引用的简单数据结构。在大多数需要可靠深拷贝的场景下,强烈推荐使用 structuredClone() 或 Lodash 的 _.cloneDeep() ,并始终记得在使用拷贝后的对象属性前进行必要的检查(如空值检查或使用可选链 ?.)。