深入理解 JavaScript 深拷贝中循环引用的检测与处理机制,掌握 WeakMap 的核心优势与使用场景。
一句话概括
深拷贝时遇到对象属性引用自身或形成环形引用链,JSON 方法会直接报错,而使用 WeakMap 记录已拷贝对象则是业界标准的处理方案。
背景
在真实业务场景中,对象之间的引用关系往往非常复杂:
1
2
3
4
5
6
7
| const manager = { name: 'Alice' };
const employee = { name: 'Bob', manager };
manager.directReports = [employee]; // 形成循环引用!
// JSON 方法会报错
JSON.parse(JSON.stringify(manager));
// TypeError: Converting circular structure to JSON
|
循环引用的常见场景:
- 树形结构(父节点引用子节点,子节点反向引用父节点)
- 图结构(多对多关系)
- DOM 节点(parentElement 循环)
- 缓存数据结构(LRU Cache 中的双向链表)
概念与定义
循环引用
对象的属性直接或间接引用自身:
1
2
3
4
5
6
7
8
9
10
11
12
13
| ┌────────────────────────────┐
│ obj: { │
│ name: 'A', │
│ self: obj ──────────┼─→ (引用自身)
│ } │
└────────────────────────────┘
┌──────────────┐ ┌──────────────┐
│ parent: { │ │ child: { │
│ name: 'P', │←──────────│ name: 'C', │
│ child: ───┼─┘ │ parent: ───┼─┘
│ } │ │ } │
└──────────────┘ └──────────────┘
|
WeakMap 弱引用
WeakMap 的键是弱引用的对象,不阻止垃圾回收:
1
2
3
4
5
6
7
8
| const weakMap = new WeakMap();
let obj = { a: 1 };
weakMap.set(obj, 'value');
console.log(weakMap.get(obj)); // 'value'
obj = null; // obj 可以被垃圾回收
// weakMap 自动清除该条目
|
最小示例
使用 WeakMap 处理循环引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| function deepClone(obj) {
const hash = new WeakMap();
function clone(value) {
// 基本类型
if (value === null || typeof value !== 'object') {
return value;
}
// 循环引用检查
if (hash.has(value)) {
return hash.get(value);
}
const copy = Array.isArray(value) ? [] : {};
hash.set(value, copy); // 记录映射
for (const key in value) {
if (value.hasOwnProperty(key)) {
copy[key] = clone(value[key]);
}
}
return copy;
}
return clone(obj);
}
// 测试循环引用
const original = { name: 'Alice' };
original.self = original; // 循环引用
const copy = deepClone(original);
console.log(copy === original); // false - 是新对象
console.log(copy.self === copy); // true - 循环引用被正确处理
|
核心知识点拆解
1. 循环引用的检测机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 思路:在拷贝对象之前,先检查是否已经拷贝过
// 使用 WeakMap 存储 "原始对象 → 拷贝副本" 的映射
function deepClone(obj, hash = new WeakMap()) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 关键:检查是否在 hash 中
// 如果在,说明这个对象已经在当前拷贝链中被处理过了
// 直接返回已创建的拷贝,避免无限递归
if (hash.has(obj)) {
return hash.get(obj);
}
// ... 拷贝逻辑
}
|
2. WeakMap vs Map 在循环引用处理中的差异
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| // Map 版本:可以工作,但会阻止垃圾回收
function deepCloneWithMap(obj) {
const hash = new Map(); // ❌ 强引用
function clone(value) {
if (value === null || typeof value !== 'object') return value;
if (hash.has(value)) {
return hash.get(value);
}
const copy = Array.isArray(value) ? [] : {};
hash.set(value, copy); // 始终保持引用
for (const key in value) {
if (value.hasOwnProperty(key)) {
copy[key] = clone(value[key]);
}
}
return copy;
}
return clone(obj);
}
// 问题
let obj = { name: 'temp' };
const clone = deepCloneWithMap(obj);
obj = null; // obj 仍然被 hash Map 引用,无法被回收!
// WeakMap 版本:自动清理,不阻止垃圾回收 ✅
function deepCloneWithWeakMap(obj) {
const hash = new WeakMap(); // ✅ 弱引用
// ...
}
obj = null; // obj 可以被垃圾回收
|
3. 复杂循环引用场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // 多节点循环引用
const nodeA = { id: 'A', name: 'Node A' };
const nodeB = { id: 'B', name: 'Node B' };
const nodeC = { id: 'C', name: 'Node C' };
nodeA.next = nodeB;
nodeB.next = nodeC;
nodeC.next = nodeA; // 形成 A → B → C → A 的循环
const clone = deepClone(nodeA);
console.log(clone.next.next.next === clone); // true - 循环被正确保留
console.log(clone.id); // 'A'
console.log(clone.next.id); // 'B'
console.log(clone.next.next.id); // 'C'
// 同一对象被多次引用
const shared = { type: 'shared' };
const obj = {
first: shared,
second: shared, // 同一个对象被两个属性引用
nested: {
ref: shared
}
};
const cloned = deepClone(obj);
console.log(cloned.first === cloned.second); // true - 共享关系被保留
console.log(cloned.nested.ref === cloned.first); // true - 深层引用也被保留
|
4. 完整深拷贝实现(含循环引用处理)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| function deepClone(source, hash = new WeakMap()) {
// 基础类型
if (source === null || typeof source !== 'object') {
return source;
}
// 循环引用检测
if (hash.has(source)) {
return hash.get(source);
}
const type = Object.prototype.toString.call(source);
let clone;
switch (type) {
case '[object Date]':
return new Date(source);
case '[object RegExp]':
return new RegExp(source.source, source.flags);
case '[object Map]':
clone = new Map();
hash.set(source, clone);
source.forEach((value, key) => {
clone.set(deepClone(key, hash), deepClone(value, hash));
});
return clone;
case '[object Set]':
clone = new Set();
hash.set(source, clone);
source.forEach(value => clone.add(deepClone(value, hash)));
return clone;
case '[object Array]':
clone = [];
hash.set(source, clone);
for (let i = 0; i < source.length; i++) {
clone[i] = deepClone(source[i], hash);
}
return clone;
case '[object Object]':
default:
clone = Object.create(Object.getPrototypeOf(source));
hash.set(source, clone);
// 遍历所有自有属性(包括 Symbol)
const keys = Reflect.ownKeys(source);
for (const key of keys) {
clone[key] = deepClone(source[key], hash);
}
return clone;
}
}
|
实战案例
1. 深拷贝 DOM 节点
1
2
3
4
5
6
7
8
9
| // DOM 节点存在大量循环引用
const container = document.querySelector('.container');
const child = document.createElement('div');
container.appendChild(child);
child.parentElement = container; // 循环引用!
// 使用 WeakMap 版本可以安全深拷贝
const cloned = deepClone(container);
// 注意:DOM 节点通常用 importNode 或 cloneNode 更合适
|
2. 深拷贝 Redux 状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // Redux 状态可能包含实体关系(实体 A 引用实体 B,B 也引用 A)
const state = {
entities: {
users: {
'1': { id: '1', name: 'Alice', friendIds: ['2'] },
'2': { id: '2', name: 'Bob', friendIds: ['1'] }
}
}
};
// 深拷贝状态(保留实体间引用关系)
const clonedState = deepClone(state);
console.log(
clonedState.entities.users['1'].friendIds
);
|
3. 深拷贝带缓存的数据结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // LRU Cache 的双向链表结构天然存在循环引用
class LRUCache {
constructor(max) {
this.max = max;
this.cache = new Map();
}
// ...
}
// 深拷贝 LRU Cache
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
const clonedCache = deepClone(cache);
|
底层原理
1. WeakMap 的垃圾回收机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // WeakMap 的特殊性:
// 1. 键必须是对象(不能是基本类型)
// 2. 键是弱引用,不阻止垃圾回收
// 3. 没有 size 属性,不能遍历
const weakMap = new WeakMap();
// 强引用阻止回收
const map = new Map();
const obj = { key: 'value' };
map.set(obj, 'data');
obj = null; // Map 中的引用阻止了回收
// WeakMap 不阻止回收
const weak = new WeakMap();
let obj2 = { key: 'value' };
weak.set(obj2, 'data');
obj2 = null; // obj2 可以被垃圾回收
// WeakMap 中对应的条目也会自动消失
// 应用场景:缓存、对象追踪、循环引用检测
|
2. 递归深度与调用栈
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| // 深层嵌套可能导致栈溢出
const deeplyNested = {};
let current = deeplyNested;
for (let i = 0; i < 100000; i++) {
current = current.nested = {};
}
try {
deepClone(deeplyNested);
} catch (e) {
console.log(e); // RangeError: Maximum call stack size exceeded
}
// 解决:使用迭代版本
function deepCloneIterative(source) {
const root = Array.isArray(source) ? [] : {};
const stack = [{ source, target: root }];
const hash = new WeakMap();
hash.set(source, root);
while (stack.length > 0) {
const { source, target } = stack.pop();
for (const key in source) {
if (source.hasOwnProperty(key)) {
const value = source[key];
if (value === null || typeof value !== 'object') {
target[key] = value;
} else if (hash.has(value)) {
target[key] = hash.get(value);
} else {
const copy = Array.isArray(value) ? [] : {};
hash.set(value, copy);
target[key] = copy;
stack.push({ source: value, target: copy });
}
}
}
}
return root;
}
// 测试
const result = deepCloneIterative(deeplyNested);
console.log('成功!');
|
3. 循环引用 vs 重复引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // 循环引用:A 引用 B,B 引用 A
const objA = { name: 'A' };
const objB = { name: 'B' };
objA.ref = objB;
objB.ref = objA;
// 重复引用:多个属性指向同一个对象
const shared = { value: 'shared' };
const obj = {
a: shared,
b: shared, // 同一个对象被多次引用
};
// 两种情况的处理策略相同:WeakMap 记录 → 遇到时直接返回已创建的拷贝
|
高频面试题解析
Q1: 什么是循环引用?为什么 JSON.stringify 会报错?
答案:循环引用是指对象的属性直接或间接引用自身。JSON.stringify 在序列化时会检测对象图,发现闭环后抛出 TypeError: Converting circular structure to JSON。
1
2
3
4
5
| const obj = { name: 'test' };
obj.self = obj;
JSON.stringify(obj);
// TypeError: Converting circular structure to JSON
|
Q2: WeakMap 为什么比 Map 更适合处理循环引用?
答案:WeakMap 使用弱引用,不阻止垃圾回收。当被引用的对象不再有其他强引用时,可以被垃圾回收,WeakMap 中的条目也会自动清除,避免内存泄漏。而 Map 使用强引用,会阻止垃圾回收。
1
2
3
4
5
6
7
8
9
10
11
12
13
| // Map:强引用,内存泄漏风险
const hash = new Map();
let obj = {};
hash.set(obj, 'data');
obj = null;
// obj 仍被 Map 引用,无法回收!
// WeakMap:弱引用,无内存泄漏
const hash2 = new WeakMap();
let obj2 = {};
hash2.set(obj2, 'data');
obj2 = null;
// obj2 可以被垃圾回收
|
Q3: 如何判断对象是否已经被拷贝过?
答案:使用 WeakMap 或 Map 记录 原始对象 → 拷贝对象 的映射。在拷贝对象属性之前,先检查是否已经存在映射,存在则直接返回已有拷贝,避免无限递归。
1
2
3
4
5
| if (hash.has(obj)) {
return hash.get(obj); // 直接返回已创建的拷贝
}
// ... 继续拷贝逻辑
hash.set(obj, copy); // 记录映射
|
Q4: 如果不用 WeakMap,还能用什么方法处理循环引用?
答案:
- 数组记录法:用数组存储已拷贝对象,替代 WeakMap(需要 O(n) 查找)
- 对象记录法:用普通对象记录,但键必须是可序列化的值
- Symbol 作为 key:使用 Symbol 作为 WeakMap 的键
- JSON parse/reviver:使用 reviver 函数在解析时建立映射关系
- 标记 + 跳过:给对象添加
__cloned__ 标记(会修改原对象,不推荐)
总结与扩展
核心要点
- 循环引用检测:使用 WeakMap 在拷贝前检查对象是否已被拷贝
- WeakMap 优势:弱引用,不阻止垃圾回收,不泄漏内存
- 完整映射:同一对象的多个引用都应指向同一个拷贝副本
- 栈溢出风险:极深嵌套对象用迭代代替递归
扩展阅读
- structuredClone:浏览器原生深拷贝 API,原生支持循环引用
1
| const clone = structuredClone(obj); // 自动处理循环引用
|
- Lodash cloneDeep:生产级实现,使用 Set 配合哈希表
- glitch-copy:轻量级深拷贝库
- immer:不可变数据操作,通过共享结构处理循环引用
最佳实践
- 优先使用
structuredClone()(现代浏览器) - 需要兼容旧环境时,手写 WeakMap 版本
- 对深层嵌套对象使用迭代版本避免栈溢出
- 循环引用较多的场景考虑不可变数据结构(Immutable.js)
循环引用是深拷贝中最具挑战性的部分,理解 WeakMap 的弱引用特性是解决这个问题的关键。