文章

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

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

一句话概括

深拷贝遇到对象属性指回自身时,用 WeakMap 建立「原对象→副本」的映射表,既能检测循环防止无限递归,又不会因为强引用阻止垃圾回收。

核心知识点

1. 为什么 JSON 方案直接挂掉?

因为 JSON.stringify 内部遍历对象图时发现自己回到了起点,V8 直接抛错——它没有「记录已路过节点」的能力。

1
2
3
4
5
const obj = { name: 'Alice' };
obj.self = obj;

JSON.parse(JSON.stringify(obj));
// ❌ TypeError: Converting circular structure to JSON

2. WeakMap 的检测逻辑:先查表再克隆

在递归进入一个对象之前,先查 WeakMap 里有没有「已克隆版本」。有就直接返回,没有才新建并登记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function deepClone(obj, hash = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj;

  if (hash.has(obj)) return hash.get(obj);   // 👈 灵魂一行

  const copy = Array.isArray(obj) ? [] : {};
  hash.set(obj, copy);

  for (const key of Reflect.ownKeys(obj)) {
    copy[key] = deepClone(obj[key], hash);
  }
  return copy;
}

// 验证
const a = { v: 1 }; a.self = a;
const b = deepClone(a);
console.log(b.self === b); // true,循环结构被保留

3. WeakMap vs Map:面试杀招

Map 是强引用,用完深拷贝函数后,hash 里仍然拽着原对象不让 GC 回收。WeakMap 的键是弱引用,函数执行完、外部没有引用原对象时,GC 可以正常干活。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let obj = { huge: new Array(1e7) };

// --- Map 版本(内存泄漏)---
(function () {
  const hash = new Map();
  hash.set(obj, deepClone(obj));
})(); // hash 消失了,但 obj 被 Map 强引用的期间 GC 无法回收

// --- WeakMap 版本(安全)---
let obj2 = { huge: new Array(1e7) };
(function () {
  const hash = new WeakMap();
  hash.set(obj2, deepClone(obj2));
})(); // WeakMap 不阻止 GC,函数结束意味着 obj 的外部强引用只有 obj2 自身
obj2 = null; // 此时可以回收

问自己:如果只做一次拷贝就扔掉 hash,Map 也没泄漏?——对,但面试问的是「为什么标准做法是 WeakMap」。答案:WeakMap 的语义就是「关联数据但别挡 GC」,用它天然正确。

4. 完整版:Date / RegExp / Map / Set 一个不漏

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
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((v, k) => clone.set(deepClone(k, hash), deepClone(v, hash)));
      return clone;
    case '[object Set]':
      clone = new Set(); hash.set(source, clone);
      source.forEach(v => clone.add(deepClone(v, hash)));
      return clone;
    case '[object Array]':
      clone = []; hash.set(source, clone);
      source.forEach((v, i) => (clone[i] = deepClone(v, hash)));
      return clone;
    default:
      clone = Object.create(Object.getPrototypeOf(source));
      hash.set(source, clone);
      Reflect.ownKeys(source).forEach(k => (clone[k] = deepClone(source[k], hash)));
      return clone;
  }
}

5. 小心递归爆栈:迭代 + 栈模拟

万层级嵌套时递归版直接 RangeError,改成显式栈即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function deepCloneIterative(source) {
  const root = Array.isArray(source) ? [] : {};
  const stack = [{ source, target: root }];
  const hash = new WeakMap().set(source, root);

  while (stack.length) {
    const { source, target } = stack.pop();
    for (const key of Reflect.ownKeys(source)) {
      const val = source[key];
      if (val === null || typeof val !== 'object') {
        target[key] = val;
      } else if (hash.has(val)) {
        target[key] = hash.get(val);
      } else {
        const copy = Array.isArray(val) ? [] : {};
        hash.set(val, copy);
        target[key] = copy;
        stack.push({ source: val, target: copy });
      }
    }
  }
  return root;
}

其实你每天都在用

  • Redux / Zustand 状态更新:reducer 必须返回新对象,immer 内部就在做「记录已拷贝节点」防止重复克隆共享结构。
  • Vue / React 的 undo / redo:每次保存状态快照都是深拷贝。如果状态里有 parent↔child 的双向引用,WeakMap 机制就在背后兜底。
  • 表单草稿暂存:编辑到一半点击「暂存」,把当前 formData 深拷贝存到 localStorage。structuredClone 就是浏览器自带的循环引用安全拷贝。
  • 组件树可视化:递归组件渲染用到的 tree 数据里,常常有 node.children + child.parent 的双向引用。
  • PostMessage / Web Worker 传数据structuredClone 做序列化,天然支持循环引用,面试官问你用它还是 JSON——这就是考点。

常见误解

❌ 误区1:”用 try-catch 包住 JSON.stringify 就行”

try-catch 只会让你「不崩溃」,但丢失了整个对象。面试官要的是「正确拷贝循环结构」,而不是「绕过报错」。

❌ 误区2:”Map 和 WeakMap 效果一样,面试这么写也没事”

语法上能跑,但深拷贝函数执行完后 hash 本该被 GC,Map 却会因为强引用让原对象活得更久。尤其在大对象或循环拷贝频繁的场景里,这是真实的内存泄漏点。

❌ 误区3:”我自己写的对象里不可能有循环引用”

DOM 节点的 parentNode / childNodes、双向链表、树形菜单的 children + parent、关系型数据(A 引用 B,B 引用 C,C 引用 A)——这些在生产代码里到处都是,不是你「刻意写」才会出现。

❌ 误区4:”用 lodash.cloneDeep 就高枕无忧了”

lodash 内部用 WeakMap 吗?老版本用的是 Map + 自定义 hash,仍有强引用问题。知道原理才能评估工具链,而不是无脑 npm install。

一句话总结

循环引用的深拷贝本质是「图的遍历 + 访问记录」问题——WeakMap 就是那张记录表,弱引用是它不拖累 GC 的精髓。

面试官想听的不是你背出 if (hash.has(obj)),而是你能说清楚:「哈希表记录映射 → 弱引用不挡 GC → 图遍历里保留共享关系」这条链路。

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