先记住这个答案
手写递归深拷贝时,每进入一个对象先创建空副本,并把“源对象→空副本”存入 WeakMap,之后遍历属性。若属性值回到任意已处理的源对象,WeakMap 命中后直接返回该副本,不会再次开辟新路径。选 WeakMap 而不选 Map,是因为对象作为键时可随原对象生命周期被回收,Map 默认强引用会让缓存长期占用内存。
- 必须先把空副本存入 WeakMap 再递归
- WeakMap 键用对象引用,不阻止回收
- 循环引用时直接返回已记录副本
先登记后递归——WeakMap 在闭环中的位置
深拷贝的递归进入对象前,先创建一个空副本并用 WeakMap 记录“原对象→副本”。随后递归复制各属性;一旦子值回到某个已处理对象,WeakMap 命中,函数直接返回旧副本而不再进入。这样自环、互相引用都会合入到同一份副本,不会因再次 create 而触发无限栈扩展。
Map 同样能存出这种对应关系,但它对键是强引用,深拷贝缓存会与源对象存续同样长的时间;若拷贝后原对象不再使用,Map 的键仍使其无法被回收。WeakMap 的键是弱引用,对象失去外部引用后,该缓存项也可被回收,因此适合只存活在拷贝流程中的已处理对象名单。
function deepClone(obj, cache = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj;
if (cache.has(obj)) return cache.get(obj);
const clone = Array.isArray(obj) ? [] : {};
cache.set(obj, clone);
for (const key of Object.keys(obj)) {
clone[key] = deepClone(obj[key], cache);
}
return clone;
}
const solo = { text: 'self' };
solo.self = solo;
const copySolo = deepClone(solo);
console.log('self-cycle resolved:', copySolo.self === copySolo);
const left = { name: 'left' };
const right = { name: 'right' };
left.toRight = right;
right.toLeft = left;
const copyLeft = deepClone(left);
console.log('mutual-cycle resolved:', copyLeft.toRight.toLeft === copyLeft);
const nestedObj = { users: [{ id: 1 }, { id: 2 }] };
const nestedCopy = deepClone(nestedObj);
nestedCopy.users[0].id = 9;
console.log('nested copied:', nestedObj.users[0].id === 1 && nestedCopy.users[0].id === 9);查看输出与解释
self-cycle resolved: true
mutual-cycle resolved: true
nested copied: true时间复杂度 O(n),每个对象属性在缓存命中后只会访问一次;空间复杂度 O(n),用于 WeakMap 和递归辅助栈。三个输入分别验证自引用、互相引用和深层嵌套数组的独立性。
工程错误:递归完成才登记副本的现场
假设拷贝一棵按引用连成全环的树:根节点挂左子树,左子树的 parent 指回根。若代码先递归赋值再执行 cache.set,第一次进入左子树时根节点还未存入缓存,根节点又会构造第二份副本,导致递归出不去。最终爆栈或尾部无限增长;解决这类结构化数据需要先 set 后拷贝,副本先落位再填充属性。
真实场景常常是表单数据模型具备 parent、referrer 这类反向指针。WeakMap 的键必须是对象,因此实例、数组都天然满足;数组需要判成 Array.isArray 得到新的数组容器,否则缓存的值类型会错误,处理指针时会看到结构仍是对象,但对循环闭环本身无影响。测试用例分别用自环、互链确认了克隆闭环与原对象不共享。
WeakMap 不解决深度,也不解决特殊类型
本解法最明显的失效条件是用于非普通对象:Date、RegExp、Map、Set 会被当作普通对象,创建空对象后得到没有内置方法的假副本。题目不要求叠加这些类型,若实际使用要在 deepClone 入口先处理特殊构造函数,或者在调用前验收允许复制的载荷中不含有这些实体。
另一个失效边界是超长链而非环:WeakMap 只防无限递归,不防调用栈增长,5000 层以上嵌套依然可能超限。代价小的办法是改用显式栈迭代深拷贝;代价稍大但稳定的是使用 structuredClone,或将数据交给 Worker 做结构化克隆。
容易答错的地方
- 功能相同所以可随意选 Map
- 两者都能记映射,但 Map 强引用使源对象不能回收,WeakMap 弱引用源对象在拷贝后被移除时可随缓存一起去掉。只做缓存防环选 WeakMap,需要遍历或 size 统计时才转 Map。
- 先 set 或后 set 无所谓
- 顺序会影响能否跳出循环。进入对象时必须先存空副本再处理属性;若先递归,环内第二次遇到相同对象时缓存里还没有记录,会重新创建节点,递归无法结束。
面试官还会怎么问?
WeakMap 的键为什么不能是字符串或数字?
WeakMap 为弱引用而设计,只有对象或非全局 Symbol 才有可被 GC 跟踪的生命周期。原始值没有独立的生命期,无法由垃圾回收决定清理时机,深拷贝中检测到原始值直接返回即可。
如果不使用 WeakMap,用 Map 会出现什么副作用?
Map 会对 key 形成强引用,让已拷贝对象在缓存存在期间持续占用内存;若源对象不再需要,无法被回收。拷贝完成后若不主动 clear,长时间驻留的模块可能累积不必要的内存。WeakMap 不存在这一问题,但也因此不能枚举或查看 size。
能否通过给对象添加标记属性来替代 WeakMap?
可行但副作用明显:给对象写额外属性会污染源数据,冻结或不可扩展对象会抛错,且可能与原型链逻辑纠缠。WeakMap 从外部维护映射,对用户不可见,代码更干净。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。