创建深拷贝函数
这个函数将处理:
- 基本数据类型、对象和数组。
Date和RegExp对象。- 循环引用(防止无限递归导致栈溢出)。
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,其核心目的有两个:
- 解决循环引用问题:防止因对象相互引用而导致的无限递归和栈溢出。
- 防止内存泄漏 :这是选择
WeakMap而不是普通Map或Object的关键原因。
1. 解决循环引用问题
想象一个对象,它的某个属性指向了它自己:
js
const obj = { name: 'My Object' };
obj.self = obj; // 这是一个循环引用
如果一个深拷贝函数不处理这种情况,它会陷入无限循环:
- 开始拷贝
obj。 - 遇到
self属性,需要拷贝obj.self,而obj.self就是obj本身。 - 于是又回到了第一步,开始拷贝
obj... - 这个过程永不停止,直到耗尽调用栈内存,程序崩溃
("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实例还存在,它就会一直"抓住"key和value,阻止垃圾回收器回收它们,即使程序中其他地方已经没有任何对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: 每次调用someFunction,largeObject都会被作为键存入deepClone内部的那个Map中。即使someFunction执行完毕,largeObject因为被Map强引用着,所以无法被垃圾回收 。循环 1000 次后,这个Map会持有 1000 个不再需要的largeObject的引用,造成严重的内存泄漏。 - 如果
deepClone使用WeakMap: 每次调用someFunction,largeObject被存入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() 是一种更通用、更面向对象、更健壮的创建新容器的方式。它不仅仅是判断"是不是数组",而是去问"你是由哪个构造函数创建的?那我就用同一个构造函数再创建一个新的"。
这确保了深拷贝函数不仅能处理标准的 Object 和 Array,还能正确地处理它们的子类实例,保留其原型链和类型信息,从而生成一个更加忠实于原始对象的克隆体。这正是高质量库和健壮代码所追求的目标。
其他深拷贝方式
深拷贝会递归地复制一个对象的所有层级的属性,创建一个全新的、完全独立的对象。新旧对象之间没有任何共享的引用。
-
JSON.parse(JSON.stringify(obj))这是最简单快捷的深拷贝方法,但存在很多局限性,不推荐在生产环境中滥用。
jsconst original = { a: 1, b: new Date() }; const copy = JSON.parse(JSON.stringify(original)); console.log(typeof copy.b); // "string" (Date 对象变成了字符串)- 优点:简单,一行代码搞定。
- 缺点:
- 会忽略
undefined、Symbol和函数。 - 不能处理循环引用(会报错)。
- 会将
Date对象转换为字符串。 - 会丢失
RegExp等对象的类型。
- 会忽略
-
使用第三方库
在实际项目中,最可靠、最省事的方法是使用成熟的第三方库,比如
lodash。bashnpm install lodashjsimport _ 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处理了大量的边界情况,非常稳定可靠。