手写支持循环引用的 JS 深拷贝函数

作者:青柠

时间:2026-11-06

浏览:343

深入解析JavaScript对象循环引用的本质与递归深拷贝调用栈溢出问题,详解通过WeakMap记录引用地址避免死循环的原理,并给出涵盖数组、对象、Symbol及特殊内置类型的生产级深拷贝实现。

文章目录
JavaScript 核心技术深入

在 JavaScript 中实现深拷贝时,如果目标对象的属性直接或间接引用了该对象本身,朴素的递归调用就会陷入无限循环,最终抛出 RangeError: Maximum call stack size exceeded 错误。解决这一问题的标准工程方案,是在递归过程中使用 WeakMap 维护一个“源对象与新克隆对象”的哈希映射缓存:每次遇到引用类型前先查表,若已存在则直接返回对应的新对象引用,从而在阻断无限递归的同时精确还原对象的引用拓扑。

手写支持循环引用的 JS 深拷贝函数
图 1:通过 WeakMap 缓存切断无限递归并还原内部自引用的整体架构

一、循环引用与深拷贝栈溢出的成因

深拷贝的核心目标是递归复制一个对象及其所有的嵌套属性,使得克隆后的对象与原对象在内存堆(Heap)中完全独立,互不影响。然而,JavaScript 中的复杂数据结构并不总是树状的,它常常呈现为包含环状路径的有向图。

最典型的场景便是对象自引用或两个对象互相引用。例如以下代码:

const objA = { name: '节点A' };
const objB = { name: '节点B' };
objA.neighbor = objB;
objB.neighbor = objA; // 形成 A ➔ B ➔ A 的闭环

如果使用传统的递归函数遍历属性,执行流程将进入死胡同:处理 objA 时去克隆 objB,处理 objB 时又去克隆 objA。JavaScript 引擎的调用栈(Call Stack)深度有限,随着帧不断压栈,很快便会耗尽内存并崩溃。同理,使用常用的序列化技巧 JSON.parse(JSON.stringify(obj)) 也会立即抛出 TypeError: Converting circular structure to JSON 异常。

手写支持循环引用的 JS 深拷贝函数
图 2:循环引用在未拦截场景下导致 JS 引擎递归栈溢出的物理过程

二、核心机制:基于 WeakMap 的哈希缓存

要打破无限递归,核心在于记忆已经克隆过的对象。在进入新一轮递归前,程序需要向缓存询问:“这个对象此前是否已经建立过克隆副本?”

  • 若存在:表明当前处于环路遍历中,立刻将缓存中早已生成的新对象引用返回,终止本分支向下递归。
  • 若不存在:立即为该对象创建新的容器,并将 原对象 ➔ 新对象 的对应关系存入缓存,随后再执行属性的递归复制。
为什么选择 WeakMap 而非普通 Map 或 Array?
普通 Map 的键对对象是强引用(Strong Reference)。在深拷贝大型复杂对象图时,如果将原对象作为 Map 的键,即便原对象在业务上下文中已被丢弃,只要深拷贝函数的闭包或映射表未被释放,垃圾回收器(Garbage Collector)就无法回收这些对象,极易造成隐蔽的内存泄漏。而 WeakMap 的键仅持有弱引用,当外界不再持有原对象时,GC 会自动清理对应条目,是构建临时引用字典的最佳工程选择。
手写支持循环引用的 JS 深拷贝函数
图 3:支持循环引用的深拷贝算法单步推导与执行工序

三、分步代码实现与细节推敲

依据上述步骤,我们手写出完整且健壮的 deepClone 函数。该实现不仅支持基础对象与数组的循环引用,还兼顾了 Symbol 属性键、Date、RegExp、Map 与 Set 等常见类型的处理。

/**
 * 支持循环引用的通用深拷贝函数
 * @param {*} target 需要深拷贝的目标值
 * @param {WeakMap} map 用于存储已克隆对象的哈希表,防止死循环
 * @returns {*} 深拷贝后的新对象副本
 */
function deepClone(target, map = new WeakMap()) {
  // 步骤 1:处理原始类型(number, string, boolean, undefined, symbol, bigint)及 null
  if (target === null || typeof target !== 'object') {
    return target;
  }

  // 步骤 2:查验是否已存在于 WeakMap 中(命中循环引用)
  if (map.has(target)) {
    return map.get(target);
  }

  // 步骤 3:处理不可遍历的特定对象类型(Date、RegExp)
  const Constructor = target.constructor;
  if (target instanceof Date) {
    return new Date(target);
  }
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }

  // 步骤 4:处理 Map 与 Set 集合类型
  if (target instanceof Map) {
    const cloneMap = new Map();
    map.set(target, cloneMap); // 遍历子元素前必须先登记
    target.forEach((val, key) => {
      cloneMap.set(deepClone(key, map), deepClone(val, map));
    });
    return cloneMap;
  }

  if (target instanceof Set) {
    const cloneSet = new Set();
    map.set(target, cloneSet); // 遍历子元素前必须先登记
    target.forEach(val => {
      cloneSet.add(deepClone(val, map));
    });
    return cloneSet;
  }

  // 步骤 5:初始化普通对象或数组,继承原型链
  const cloneTarget = Array.isArray(target)
    ? []
    : Object.create(Object.getPrototypeOf(target));

  // 步骤 6:关键节点 —— 先存入 WeakMap,再递归子属性
  map.set(target, cloneTarget);

  // 步骤 7:使用 Reflect.ownKeys 获取所有属性名(包含不可枚举与 Symbol)
  const keys = Reflect.ownKeys(target);
  for (const key of keys) {
    cloneTarget[key] = deepClone(target[key], map);
  }

  return cloneTarget;
}

关键实现要点解析

  1. 原型链继承:如果直接使用 cloneTarget = {},会导致自定义类实例的原型链断裂。通过 Object.create(Object.getPrototypeOf(target)) 可以保留实例的原型方法。
  2. Symbol 键兼容:标准的 Object.keys() 或 for...in 无法枚举以 Symbol() 作为键的属性。改用 Reflect.ownKeys() 可以一次性返回常规字符串键与 Symbol 键。
  3. 严格的注册时序:必须在 for (const key of keys) 执行之前调用 map.set(target, cloneTarget)。如果滞后到子属性递归完成之后才存入,深层自引用依然会在查表阶段落空,无法打破循环。
手写支持循环引用的 JS 深拷贝函数
图 4:源堆、WeakMap 查重缓存与目标堆引用拓扑映射关系

四、测试与验证

为了验证深拷贝函数能否有效处理自引用与跨对象相互引用,并确认深层修改不会污染源对象,执行以下测试脚本:

// 构造测试对象:包含自身引用和嵌套属性
const source = {
  id: 101,
  tags: ['js', 'clone'],
  createdAt: new Date('2026-01-01'),
  pattern: /^[a-z]+$/i
};

// 创建循环引用
source.circularSelf = source;

// 执行深拷贝
const copy = deepClone(source);

// 验证 1:是否成功拷贝且未发生调用栈溢出
console.log(copy.id === 101); // true

// 验证 2:引用的闭合性是否指向克隆副本自身,而非源对象
console.log(copy.circularSelf === copy);   // true(指向新对象闭环)
console.log(copy.circularSelf === source); // false(与源对象彻底隔离)

// 验证 3:修改克隆副本的数组,原数组不受影响
copy.tags.push('es6');
console.log(source.tags.length); // 2(源数据未被篡改)
console.log(copy.tags.length);   // 3

五、深拷贝方案对比与技术选型

在实际项目开发中,除了手动编写基于 WeakMap 的深拷贝函数,还可以使用现代浏览器与 Node.js 17+ 原生支持的 structuredClone(),或在简单无环场景使用 JSON 序列化。不同方案在功能覆盖与适用场景上有着清晰的界限:

手写支持循环引用的 JS 深拷贝函数
图 5:常见 JavaScript 深拷贝策略能力矩阵与工程决策树

六、总结

手写支持循环引用的深拷贝函数,核心逻辑可以浓缩为一个根本原则:以空间换确定性,在递归深入之前完成内存登记。通过 WeakMap 建立原对象与新对象的弱引用字典,并在遍历子属性之前提前执行 map.set(target, cloneTarget),即可在任意复杂的环状有向图中,将每一次循环引用安全导流至已经生成的副本引用上。掌握这一机制不仅能写出无惧栈溢出的健壮拷贝工具,更能深化对 JavaScript 堆内存拓扑与垃圾回收机制的理解。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章