文章

深拷贝循环引用的处理深度解析

深拷贝循环引用的处理深度解析

深入理解 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,还能用什么方法处理循环引用?

答案

  1. 数组记录法:用数组存储已拷贝对象,替代 WeakMap(需要 O(n) 查找)
  2. 对象记录法:用普通对象记录,但键必须是可序列化的值
  3. Symbol 作为 key:使用 Symbol 作为 WeakMap 的键
  4. JSON parse/reviver:使用 reviver 函数在解析时建立映射关系
  5. 标记 + 跳过:给对象添加 __cloned__ 标记(会修改原对象,不推荐)

总结与扩展

核心要点

  1. 循环引用检测:使用 WeakMap 在拷贝前检查对象是否已被拷贝
  2. WeakMap 优势:弱引用,不阻止垃圾回收,不泄漏内存
  3. 完整映射:同一对象的多个引用都应指向同一个拷贝副本
  4. 栈溢出风险:极深嵌套对象用迭代代替递归

扩展阅读

  • structuredClone:浏览器原生深拷贝 API,原生支持循环引用
    1
    
    const clone = structuredClone(obj);  // 自动处理循环引用
    
  • Lodash cloneDeep:生产级实现,使用 Set 配合哈希表
  • glitch-copy:轻量级深拷贝库
  • immer:不可变数据操作,通过共享结构处理循环引用

最佳实践

  1. 优先使用 structuredClone()(现代浏览器)
  2. 需要兼容旧环境时,手写 WeakMap 版本
  3. 对深层嵌套对象使用迭代版本避免栈溢出
  4. 循环引用较多的场景考虑不可变数据结构(Immutable.js)

循环引用是深拷贝中最具挑战性的部分,理解 WeakMap 的弱引用特性是解决这个问题的关键。

本文由作者按照 CC BY 4.0 进行授权