JS 对象深拷贝

创建深拷贝函数

这个函数将处理:

  • 基本数据类型、对象和数组。
  • DateRegExp 对象。
  • 循环引用(防止无限递归导致栈溢出)。
  • Symbol 类型的属性。
  • 函数和 undefined
js 复制代码
/**
 * 高性能递归深拷贝函数,处理循环引用和多种数据类型
 * @param {any} obj 要拷贝的对象
 * @param {WeakMap} hash 用于处理循环引用,防止栈溢出。默认为 new WeakMap(),外部调用时无需传递。
 * @returns {any} 拷贝后的新对象
 */
function deepClone(obj, hash = new WeakMap()) {
  // 1. 如果是 null 或非对象/函数类型,直接返回
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // 2. 处理特殊对象:Date 和 RegExp
  if (obj instanceof Date) {
    return new Date(obj);
  }
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
  // [新增] 支持 Set
  if (obj instanceof Set) {
    const cloneSet = new Set();
    for (const value of obj) {
      cloneSet.add(deepClone(value, hash));
    }
    return cloneSet;
  }
  // [新增] 支持 Map
  if (obj instanceof Map) {
    const cloneMap = new Map();
    for (const [key, value] of obj) {
      cloneMap.set(deepClone(key, hash), deepClone(value, hash));
    }
    return cloneMap;
  }

  // 3. 处理循环引用:如果哈希表中已存在该对象,说明之前已经拷贝过,直接返回缓存中的拷贝结果
  if (hash.has(obj)) {
    return hash.get(obj);
  }

  // 4. 根据是数组还是对象,创建新的容器
  // 使用 obj.constructor 可以保留原型链上的属性,比 Array.isArray 或 {} 更健壮
  const cloneObj = new obj.constructor();

  // 5. 将新创建的对象和原始对象存入哈希表,表示这个原始对象已经处理过
  hash.set(obj, cloneObj);

  // 6. 遍历对象的键(包括 Symbol 类型的键)
  // 使用 Reflect.ownKeys 可以获取所有类型的键名(字符串和 Symbol)
  for (const key of Reflect.ownKeys(obj)) {
    // 递归拷贝子属性
    cloneObj[key] = deepClone(obj[key], hash);
  }

  return cloneObj;
}

// --- 使用示例 ---

// 假设你有一个复杂的对象
const originalObject = {
  number: 1,
  string: 'hello',
  isActive: true,
  date: new Date(),
  regex: /abc/ig,
  array: [1, { name: 'nested' }],
  func: () => console.log('I am a function'),
  undef: undefined,
  sym: Symbol('unique'),
  nestedObj: {
    a: 2
  }
};

// 创建一个循环引用
originalObject.self = originalObject;

// 执行深拷贝
const clonedObject = deepClone(originalObject);

// --- 验证 ---

// 1. 修改克隆对象的属性,不会影响原始对象
clonedObject.nestedObj.a = 99;
clonedObject.array[0] = 100;
console.log('原始对象的嵌套属性:', originalObject.nestedObj.a); // 输出: 2 (未被修改)
console.log('原始对象的数组元素:', originalObject.array[0]);   // 输出: 1 (未被修改)

// 2. 检查引用是否不同
console.log('顶层对象是否相同:', originalObject === clonedObject);       // 输出: false
console.log('嵌套对象是否相同:', originalObject.nestedObj === clonedObject.nestedObj); // 输出: false
console.log('数组是否相同:', originalObject.array === clonedObject.array);         // 输出: false

// 3. 检查循环引用是否被正确处理
console.log('克隆对象的循环引用是否指向自身:', clonedObject.self === clonedObject); // 输出: true

为什么要使用 WeakMap

在深拷贝函数中使用 WeakMap,其核心目的有两个:

  1. 解决循环引用问题:防止因对象相互引用而导致的无限递归和栈溢出。
  2. 防止内存泄漏 :这是选择 WeakMap 而不是普通 MapObject 的关键原因。

1. 解决循环引用问题

想象一个对象,它的某个属性指向了它自己:

js 复制代码
const obj = { name: 'My Object' };
obj.self = obj; // 这是一个循环引用

如果一个深拷贝函数不处理这种情况,它会陷入无限循环:

  1. 开始拷贝 obj
  2. 遇到 self 属性,需要拷贝 obj.self,而 obj.self 就是 obj 本身。
  3. 于是又回到了第一步,开始拷贝 obj...
  4. 这个过程永不停止,直到耗尽调用栈内存,程序崩溃("Maximum call stack size exceeded")

为了解决这个问题,我们需要一个"备忘录"来记录已经拷贝过的对象。当我们准备拷贝一个新对象时,先查一下"备忘录":

  • 如果已经拷贝过,就直接返回之前拷贝的结果。
  • 如果没拷贝过,就进行拷贝,并把"原始对象"和"拷贝后的对象"这对关系记在"备忘录"里。

WeakMap 在这里就扮演了这个"备忘录"的角色。

  • 键 (Key) :原始对象 (obj)
  • 值 (Value) :拷贝后的新对象 (cloneObj)
js 复制代码
// 伪代码
function deepClone(obj, hash) {
  // 检查备忘录
  if (hash.has(obj)) {
    return hash.get(obj); // 已经拷贝过,直接返回
  }

  // ...创建新对象 cloneObj...

  // 记入备忘录
  hash.set(obj, cloneObj);

  // ...继续拷贝子属性...
}

2. 防止内存泄漏(为什么用 WeakMap 而不是 Map

既然是"备忘录",用普通的 Map 或者 Object 也可以实现,为什么偏偏要用 WeakMap 呢?答案在于它们的垃圾回收机制不同。

  • Map (强引用) : 如果你用 map.set(key, value),那么只要这个 map 实例还存在,它就会一直"抓住" keyvalue,阻止垃圾回收器回收它们,即使程序中其他地方已经没有任何对 key 的引用了。这就造成了内存泄漏
  • WeakMap (弱引用)WeakMap 的键是"弱引用"的。这意味着,如果一个对象作为 WeakMap 的键,而程序中除了 WeakMap 之外没有其他地方引用这个对象了,那么垃圾回收器就会自动回收这个对象 ,并且 WeakMap 中对应的键值对也会被自动移除
举例说明:

假设你的深拷贝函数被调用了很多次:

js 复制代码
function someFunction() {
  const largeObject = { /* ... 包含大量数据 ... */ };
  const cloned = deepClone(largeObject); // deepClone 内部使用了 Map
  // 当 someFunction 执行完毕后,largeObject 和 cloned 都应该被销毁
}

for (let i = 0; i < 1000; i++) {
  someFunction();
}
  • 如果 deepClone 使用 Map : 每次调用 someFunctionlargeObject 都会被作为键存入 deepClone 内部的那个 Map 中。即使 someFunction 执行完毕,largeObject 因为被 Map 强引用着,所以无法被垃圾回收 。循环 1000 次后,这个 Map 会持有 1000 个不再需要的 largeObject 的引用,造成严重的内存泄漏。
  • 如果 deepClone 使用 WeakMap : 每次调用 someFunctionlargeObject 被存入 WeakMap。当 someFunction 执行完毕,外部不再有对 largeObject 的引用时,垃圾回收器会发现这个对象可以被回收了。WeakMap 的弱引用特性不会阻止这个过程,并且会自动清理掉这个键值对。内存被正常释放,没有泄漏。

总结

在深拷贝的场景下:

  • 我们需要一个"备忘录"来处理循环引用。
  • 这个"备忘录"的生命周期应该和深拷贝函数的单次调用相关,不应该影响到被拷贝对象的垃圾回收。

WeakMap 完美地满足了这两个需求:它既能通过键值对解决循环引用,又能通过弱引用机制确保当原始对象被废弃时,不会因"备忘录"的存在而导致内存泄漏。这就是为什么在高质量的深拷贝实现中,WeakMap 是不二之选。

为什么要用 Reflect.ownKeys

deepClone 函数中使用 Reflect.ownKeys(obj)for...in 循环,主要是为了实现一个更完整、更健壮的深拷贝。

Reflect.ownKeys(obj) 的核心优势在于它能获取对象自身的所有类型的键

我们来对比一下几种常见的获取对象键名的方法:

1. for...in 循环

js 复制代码
for (const key of obj) {
    // ...
}
  • 优点:无。在现代 JavaScript 中很少直接用于遍历对象自身的属性。
  • 缺点:
    • 会遍历对象原型链 上所有可枚举的属性,这在拷贝对象时通常是我们不希望的。你需要配合 obj.hasOwnProperty(key) 来过滤掉原型链上的属性。
    • 无法获取 Symbol 类型的键。

2. Object.keys(obj)

js 复制代码
Object.keys(obj).forEach(key => {
  // ...
});
  • 优点:
    • 只返回对象自身的属性键名,不会包含原型链上的。
    • 返回的是一个数组,可以方便地使用 forEach, map 等数组方法。
  • 缺点:
    • 只返回可枚举的字符串类型的键。
    • 无法获取 Symbol 类型的键。
    • 无法获取不可枚举的属性。

3. Object.getOwnPropertyNames(obj)

js 复制代码
Object.getOwnPropertyNames(obj).forEach(key => {
  // ...
});
  • 优点:
    • 返回对象自身 的所有字符串类型的键,包括不可枚举的
  • 缺点:
    • 无法获取 Symbol 类型的键。

4. Object.getOwnPropertySymbols(obj)

js 复制代码
Object.getOwnPropertySymbols(obj).forEach(symbolKey => {
  // ...
});
  • 优点:
    • 专门用于返回对象自身 的所有 Symbol 类型的键。
  • 缺点:
    • 无法获取字符串类型的键。

5. Reflect.ownKeys(obj) (最佳选择*)

js 复制代码
for (const key of Reflect.ownKeys(obj)) {
  // ...
}
  • 优点:
    • 最全面 :它返回一个包含对象自身 所有键的数组,不分类型(字符串或 Symbol)、不分是否可枚举
    • 它的返回值等价于 Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj))
  • 缺点:无明显缺点,是为元编程场景设计的现代化 API。

总结

在实现一个高质量的 deepClone 函数时,我们的目标是尽可能完整地复制一个对象的所有状态。Symbol 作为 ES6 引入的新原始类型,常常被用作对象的唯一属性键,以避免命名冲突(例如,在一些库的内部实现中)。

  • 如果使用 Object.keys(),你将会丢失所有 Symbol 类型的属性,导致拷贝不完整。
  • 如果使用 for...in,你可能会错误地拷贝原型链上的属性,并且同样会丢失 Symbol 属性。

因此,Reflect.ownKeys() 是唯一能够一次性、可靠地获取对象自身全部键(包括普通字符串键、不可枚举键和 Symbol 键)的标准方法。这确保了你的深拷贝函数不会遗漏任何类型的属性,从而变得更加健壮和可靠。

为什么要 new obj.constructor()

使用 new obj.constructor() 的核心目的是:创建一个与原始对象 obj 类型完全相同的新实例,同时正确地处理子类等继承情况。

让我们来对比一下它和更常见的写法 Array.isArray(obj) ? [] : {} 的区别。

1. 基础情况:处理普通对象和数组

在大多数情况下,这两种写法的效果是一样的:

  • 如果 obj 是一个数组 []:
    • obj.constructor 就是 Array
    • new obj.constructor() 就等同于 new Array(),会创建一个新的空数组 []
  • 如果 obj 是一个普通对象 {}:
    • obj.constructor 就是 Object
    • new obj.constructor() 就等同于 new Object(),会创建一个新的空对象 {}

到这里为止,new obj.constructor() 看起来和 Array.isArray(obj) ? [] : {} 没什么区别。但它的真正威力体现在处理继承上。

2. 进阶情况:正确处理子类(关键优势)

假设你创建了一个 Array 的子类,这个子类有一些自定义的方法:

现在,我们用两种不同的方式来克隆 originalArray

方式一:使用 Array.isArray(obj) ? [] : {}
  • 问题clonedArray1 是一个普通的 Array ,而不是 MyCoolArray。它丢失了原始对象的类型信息。
  • clonedArray1 instanceof MyCoolArray 将会是 false
  • 在后续拷贝完元素后,你将无法调用 clonedArray1.sum(),因为这个方法在普通的 Array 上不存在。
方式二:使用 new obj.constructor() (你代码中的写法)
  • 优势clonedArray2 是一个全新的 MyCoolArray 实例。它完美地保留了原始对象的类型。
  • clonedArray2 instanceof MyCoolArray 将会是 true
  • 在后续拷贝完元素后,你可以 调用 clonedArray2.sum()

总结

new obj.constructor() 是一种更通用、更面向对象、更健壮的创建新容器的方式。它不仅仅是判断"是不是数组",而是去问"你是由哪个构造函数创建的?那我就用同一个构造函数再创建一个新的"。

这确保了深拷贝函数不仅能处理标准的 ObjectArray,还能正确地处理它们的子类实例,保留其原型链和类型信息,从而生成一个更加忠实于原始对象的克隆体。这正是高质量库和健壮代码所追求的目标。

其他深拷贝方式

深拷贝会递归地复制一个对象的所有层级的属性,创建一个全新的、完全独立的对象。新旧对象之间没有任何共享的引用。

  1. JSON.parse(JSON.stringify(obj))

    这是最简单快捷的深拷贝方法,但存在很多局限性,不推荐在生产环境中滥用

    js 复制代码
    const original = { a: 1, b: new Date() };
    const copy = JSON.parse(JSON.stringify(original));
    
    console.log(typeof copy.b); // "string" (Date 对象变成了字符串)
    • 优点:简单,一行代码搞定。
    • 缺点:
      • 会忽略 undefinedSymbol 和函数。
      • 不能处理循环引用(会报错)。
      • 会将 Date 对象转换为字符串。
      • 会丢失 RegExp 等对象的类型。
  2. 使用第三方库

    在实际项目中,最可靠、最省事的方法是使用成熟的第三方库,比如 lodash

    bash 复制代码
    npm install lodash
    js 复制代码
    import _ from 'lodash';
    
    const original = { a: 1, b: { c: 2 } };
    const copy = _.cloneDeep(original);
    
    copy.b.c = 200;
    console.log(original.b.c); // 2 (未被改变,深拷贝成功)

    lodash.cloneDeep 处理了大量的边界情况,非常稳定可靠。

相关推荐
新晨4371 小时前
JavaScript map() 方法:从工具到编程哲学的升华
前端·javascript
码途进化论1 小时前
Vue3 + Vite 系统中 SVG 图标和 Element Plus 图标的整合实战
前端·javascript·vue.js
新晨4371 小时前
JavaScript Array map() 方法详解
前端·javascript
Nayana1 小时前
webWorker 初步体验
前端·javascript
吃饺子不吃馅1 小时前
【开源】create-web-app:多引擎可插拔的前端脚手架
前端·javascript·架构
Amy_yang1 小时前
从随机排序到公平洗牌:JavaScript随机抽取问题的优化之路
javascript·性能优化
apollo_qwe2 小时前
在 JavaScript(包括 ES 规范)开发中,常用的方法可以按数组、对象、字符串、循环 / 迭代等类别整理
javascript
w***37512 小时前
SpringMVC 请求参数接收
前端·javascript·算法