解决深拷贝循环引用痛点:一篇看懂 WeakMap 实现方案
在 JavaScript 开发中,深拷贝是高频需求------无论是处理复杂的业务数据,还是封装工具函数,我们都需要确保拷贝后的对象与原对象完全独立,不会互相干扰。但深拷贝藏着一个容易踩坑的痛点:循环引用。
比如一个对象的属性引用了自身,或者引用了父级对象,用普通的递归深拷贝实现会直接触发死循环,最终导致栈溢出错误。今天就带大家彻底搞懂循环引用的问题根源,以及如何用 WeakMap 优雅解决,最终实现一个健壮的深拷贝函数。
一、先搞懂:什么是循环引用?
循环引用就是对象内部存在"自引用"或"互引用"的情况,简单说就是对象自己指向了自己(或关联对象形成闭环)。举个最直观的例子:
ini
// 自引用:obj 的 self 属性引用了自身
const obj = { a: 1 };
obj.self = obj;
// 互引用:obj1 和 obj2 互相引用
const obj1 = { b: 2 };
const obj2 = { c: 3 };
obj1.target = obj2;
obj2.target = obj1;
这种结构很常见,比如 DOM 元素的 parentNode 属性(子元素引用父元素,父元素又包含子元素)、链表数据结构等。如果用普通递归深拷贝处理这类对象,递归会无限循环下去,直到浏览器触发 Maximum call stack size exceeded 栈溢出错误。
二、普通递归深拷贝的问题所在
先看一个简单的递归深拷贝实现(未处理循环引用):
javascript
// 普通递归深拷贝(存在循环引用漏洞)
function simpleDeepClone(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
const cloneObj = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
// 递归拷贝属性
cloneObj[key] = simpleDeepClone(obj[key]);
}
}
return cloneObj;
}
当我们用这个函数拷贝前面的循环引用对象 obj 时:
ini
const obj = { a: 1 };
obj.self = obj;
simpleDeepClone(obj); // 直接栈溢出!
原因很简单:递归过程中,会不断解析 obj.self,而 obj.self 又指向 obj,导致递归永远无法终止,栈内存被持续占用直到溢出。
三、核心思路:用"缓存"记录已拷贝对象
要解决循环引用,关键在于避免对同一个对象重复递归拷贝。我们需要一个"缓存容器",在每次拷贝对象前,先检查这个对象是否已经被拷贝过:
- 如果没拷贝过,就正常拷贝,同时把"原对象"和"拷贝后的新对象"存入缓存;
- 如果已经拷贝过,直接从缓存中取出新对象返回,不再重复递归。
这里的核心是「缓存键值对」------键存原对象,值存对应的拷贝对象,这样才能精准找到已拷贝的结果。那用什么数据结构做缓存最合适呢?
推荐用 WeakMap,而不是 Set 或 Map:
- WeakMap 的键只能是对象,正好匹配我们"存原对象"的需求;
- WeakMap 是弱引用,当原对象没有其他引用时,会被垃圾回收机制回收,不会造成内存泄漏(如果用 Map 或 Set,会一直强引用原对象,导致内存无法释放)。
四、最终实现:带循环引用检测的深拷贝
结合上面的思路,实现代码如下,每一步都加了详细注释,新手也能看懂:
javascript
function deepClone(obj, seen = new WeakMap()) {
// 1. 基本类型或函数直接返回(基本类型按值传递,函数一般不需要拷贝)
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 2. 检查缓存:如果已拷贝过该对象,直接返回缓存中的新对象
if (seen.has(obj)) {
return seen.get(obj);
}
// 3. 创建新容器:根据原对象类型(数组/对象)创建对应的空容器
const cloneObj = Array.isArray(obj) ? [] : {};
// 4. 存入缓存:将原对象和新对象的映射关系存入 WeakMap
seen.set(obj, cloneObj);
// 5. 递归拷贝属性:遍历原对象的自有属性,递归拷贝到新对象
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
cloneObj[key] = deepClone(obj[key], seen); // 传递缓存容器
}
}
// 6. 返回拷贝后的新对象
return cloneObj;
}
五、测试验证:循环引用是否被正确处理?
我们用前面的循环引用案例测试一下,看看效果:
ini
// 测试1:自引用对象
const obj1 = { a: 1, b: [1, 2, 3] };
obj1.self = obj1; // 自引用
const cloned1 = deepClone(obj1);
console.log(cloned1);
// 输出:{ a: 1, b: [1,2,3], self: [Circular] }([Circular] 表示循环引用)
console.log(cloned1.self === cloned1); // true(新对象的 self 引用自身,正确)
console.log(cloned1.b === obj1.b); // false(数组被深拷贝,完全独立)
// 测试2:互引用对象
const obj2 = { x: 'hello' };
const obj3 = { y: 'world' };
obj2.target = obj3;
obj3.target = obj2; // 互引用
const cloned2 = deepClone(obj2);
const cloned3 = deepClone(obj3);
console.log(cloned2.target === cloned3); // true(拷贝后的互引用关系正确)
console.log(cloned3.target === cloned2); // true
从测试结果可以看出:
- 循环引用被正确识别,没有出现栈溢出;
- 拷贝后的对象保持了原有的循环引用关系;
- 普通属性和数组都被深拷贝,与原对象完全独立。
六、总结
深拷贝的核心难点是循环引用,解决的关键是「缓存已拷贝对象」,而 WeakMap 是实现缓存的最优选择(弱引用+键为对象)。