深拷贝
基础知识
在 JavaScript 中,对象的赋值默认是“浅拷贝”。理解深拷贝前,必须先区分它与浅拷贝的区别。
浅拷贝 (Shallow Clone): 只复制对象或数组的第一层属性。如果属性值是基本类型,就拷贝值;如果属性值是引用类型(如另一个对象或数组),则只拷贝其内存地址(引用)。因此,新旧对象共享同一个内部的引用类型数据。
Object.assign()
和展开语法{...obj}
都属于浅拷贝。const obj1 = { a: 1, b: { c: 2 } }; const obj2 = { ...obj1 }; // 浅拷贝 obj2.a = 100; obj2.b.c = 200; console.log(obj1); // { a: 1, b: { c: 200 } } -> obj1 的内部对象被修改了!
深拷贝 (Deep Clone): 完全创建一个新的、独立的副本。它会递归地复制一个对象及其所有子对象,所以新旧对象之间没有任何共享的引用。修改新对象不会对原始对象产生任何影响。
一个简单但不完美的深拷贝:
JSON.parse(JSON.stringify(obj))
是最广为人知的深拷贝技巧。它简单易用,但有诸多限制,无法处理复杂的场景:- 会忽略
undefined
、Symbol
类型的属性和函数。 - 无法处理
Date
对象(会转换成字符串)。 - 无法处理
RegExp
、Set
、Map
等类型。 - 最致命的是,无法处理循环引用(对象内部有属性指向自身),会直接抛出错误。
- 会忽略
因此,要实现一个功能完备的深拷贝,我们需要手动编写一个更强大的函数。
核心思路
一个功能完善的深拷贝,其核心思想是递归,并在此基础上处理各种复杂情况。
递归遍历:
- 定义一个函数,接收要拷贝的目标
target
。 - 判断
target
的类型。如果是基本类型或null
,它们无需拷贝,直接返回自身。这是递归的出口。 - 如果是引用类型(对象、数组等),则创建一个新的空容器(新对象或新数组)。
- 遍历
target
的所有属性,对每个属性值都递归调用深拷贝函数自身。 - 将递归返回的结果赋值给新容器的对应属性。
- 最终返回这个填充完毕的新容器。
- 定义一个函数,接收要拷贝的目标
处理特殊情况: 在上述递归的基础上,必须额外考虑并解决以下问题:
- 循环引用: 如何防止因对象相互引用导致的无限递归?
- 多种数据类型: 如何正确拷贝
Date
,RegExp
,Set
,Map
等内置对象? - 属性全面性: 如何确保对象的
Symbol
属性或不可枚举属性也能被拷贝?
关键点 (处理复杂场景)
循环引用处理: 这是深拷贝中最关键的挑战。解决方案是使用一个缓存。
- 我们可以使用
Map
或WeakMap
作为缓存容器。在开始拷贝一个对象前,先检查缓存中是否已有该对象的记录。 - 如果有,说明之前已经拷贝过,直接返回缓存中的副本,中断递归。
- 如果没有,则为该对象创建一个新副本,并立即将
[原始对象, 新副本]
的映射关系存入缓存,然后再进行递归。 - 推荐使用
WeakMap
,因为它对键是弱引用,当原始对象在外部被垃圾回收时,WeakMap
中的对应条目会自动消失,能有效防止内存泄漏。
- 我们可以使用
多种数据类型处理: 需要通过
instanceof
对不同的内置对象类型进行判断,并采用各自特定的方式进行克隆。例如,new Date(originalDate)
。保持原型: 为了让克隆体的类型与源对象一致(如数组克隆后还是数组),我们可以通过
new target.constructor()
来创建新的容器,这可以继承原始对象的原型。完整的属性遍历: 为了拷贝包括
Symbol
键在内的所有自身属性,应使用Reflect.ownKeys()
,它比Object.keys()
或for...in
更为全面。
代码实现
下面的代码实现了一个考虑了上述所有关键点的 deepClone
函数。
/**
* 实现一个功能全面的深拷贝函数
*
* @param {any} target - 需要被深拷贝的目标值
* @param {WeakMap<object, any>} [cache=new WeakMap()] - 用于缓存对象,解决循环引用问题
* @returns {any} - 返回深拷贝后的新值
*/
function deepClone(target, cache = new WeakMap()) {
// 1. 对于基本类型和 null,直接返回
if (target === null || typeof target !== 'object') {
return target;
}
// 2. 处理特殊引用类型:Date 和 RegExp
if (target instanceof Date) {
return new Date(target);
}
if (target instanceof RegExp) {
return new RegExp(target.source, target.flags);
}
// 3. 解决循环引用:如果缓存中已存在,则直接返回缓存的副本
if (cache.has(target)) {
return cache.get(target);
}
// 4. 初始化拷贝后的容器(对象、数组、Set、Map等)
const copy = new target.constructor();
// 5. 将新创建的拷贝放入缓存,防止后续循环引用 (这一步必须在递归之前)
cache.set(target, copy);
// 6. 处理 Map
if (target instanceof Map) {
target.forEach((value, key) => {
copy.set(deepClone(key, cache), deepClone(value, cache));
});
return copy;
}
// 7. 处理 Set
if (target instanceof Set) {
target.forEach((value) => {
copy.add(deepClone(value, cache));
});
return copy;
}
// 8. 遍历对象的自身属性(包括 Symbol 属性),并进行递归
Reflect.ownKeys(target).forEach((key) => {
copy[key] = deepClone(target[key], cache);
});
return copy;
}
代码解析
- 处理基本类型和 null:这是递归的出口。当
target
为null
或基本类型(如number
、string
、boolean
等)时,直接返回自身,无需进一步拷贝。因为基本类型在 JavaScript 中是按值传递的,无需创建副本。 - 处理 Date 和 RegExp:
- 若
target
是Date
实例,通过new Date(target)
创建新日期对象,确保时间值一致。 - 若为
RegExp
实例,使用new RegExp(target.source, target.flags)
复制正则表达式的模式和标志。
- 若
- 检查缓存(解决循环引用):
- 利用
WeakMap
作为缓存容器,键为原始对象,值为对应的副本。 - 当
cache.has(target)
为true
时,说明当前对象已被拷贝过(存在循环引用),直接返回缓存中的副本以中断递归。
- 利用
- 创建新容器:通过
new target.constructor()
生成与原始对象同类型的新容器(如数组、普通对象等),确保原型链正确继承(例如数组克隆后仍为数组)。 - 预置缓存(关键步骤):在递归处理对象属性前,先将新创建的空副本存入缓存
cache.set(target, copy)
。这样即使对象内部属性引用自身,后续递归时也能通过缓存直接获取副本,避免无限递归。 - 处理 Map 类型:
- 遍历原始
Map
的键值对,对每个键和值分别进行深拷贝(deepClone(key, cache)
和deepClone(value, cache)
)。 - 通过
copy.set()
将拷贝后的键值对存入新Map
。
- 遍历原始
- 处理 Set 类型:
- 遍历原始
Set
的值,对每个值进行深拷贝(deepClone(value, cache)
)。 - 通过
copy.add()
将拷贝后的值存入新Set
。
- 遍历原始
- 遍历对象属性并递归拷贝:
- 使用
Reflect.ownKeys(target)
获取对象所有自身属性键(包括Symbol
类型和不可枚举属性)。 - 对每个属性值
target[key]
递归调用deepClone
,并将结果赋值给新对象copy[key]
。 cache
参数透传确保整个拷贝过程共享同一缓存,避免重复拷贝和循环引用。
- 使用