先记住这个答案
循环引用是指对象的某个属性绕一圈又指回了自己,比如树节点的 parent 指向父节点,父节点的 children 里又有它。基础版深拷贝遇到这种数据会一直递归,最后抛 RangeError。解决办法是给递归加一张表,用 WeakMap 记录「原对象对应哪个副本」:每进入一个对象,先查表,有记录就直接返回那个副本;没有就新建空副本,马上存进表,再去复制属性。这样绕回来的时候表里已经有记录,递归就停下了。
- 用
WeakMap记录原对象和它的副本 - 必须先把空副本存进表,再递归复制属性
- 再遇到同一个对象,直接返回已有副本
- 同一对象被引用多次,副本里也只有一份
带缓存的深拷贝怎么写
缓存作为第二个参数一路传下去,所有递归调用共用同一张表。
function deepClone(obj, cache = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj
if (cache.has(obj)) return cache.get(obj) // 拷过了,直接用那个副本
const copy = Array.isArray(obj) ? [] : {}
cache.set(obj, copy) // 先登记,再递归
for (const key of Object.keys(obj)) {
copy[key] = deepClone(obj[key], cache)
}
return copy
}
const root = { name: 'root', children: [] }
const child = { name: 'child', parent: root }
root.children.push(child)
root.self = root
const copy = deepClone(root)
console.log(copy.self === copy) // 自引用指向副本自己
console.log(copy.children[0].parent === copy) // 子节点的 parent 指向新的根
console.log(copy !== root && copy.children[0] !== child)查看输出与解释
true
true
true副本不但没有无限递归,还保留了原来的引用关系:原来 root.self 指向 root,副本里 copy.self 指向 copy;原来子节点的 parent 是 root,副本里是新的根。这是因为所有指向 root 的地方,拿到的都是表里同一个副本。
为什么必须先存表再递归
把 cache.set 挪到 for 循环后面,看起来只是顺序不同,实际上缓存完全失效了。
- 顺序反了会发生什么
- 拷贝
root时进入循环,复制children,再复制child,child.parent又指回root。这时root的副本还在循环里没建完,表里还没有它,于是又从头拷贝一遍root,一直重复下去。所以副本必须在复制属性之前就放进表里,哪怕它现在还是空的。后面绕回来的引用拿到的是这个空副本的引用,等外层循环跑完,它自然就被填满了。 - 同一个对象被引用多次
- 不只是环,同一个对象被两个属性引用(比如
a.x和a.y都指向同一个配置对象),带缓存的版本拷贝后copy.x === copy.y仍然成立。基础版会把它拷成两份独立的对象,引用关系就变了。
WeakMap 和 Map 在这里差在哪
WeakMap 的键只能是对象,而且对键是弱引用:一个对象除了作为 WeakMap 的键之外没有别的地方再用它,垃圾回收时就可以把它连同这一项一起回收。Map 是强引用,只要 Map 还在,键对象就一直活着。
在上面的写法里,缓存是每次调用 deepClone 时新建的,拷贝结束后没有任何地方引用它,整张表会一起被回收,所以换成 Map 结果和内存表现基本一样。WeakMap 真正有优势的场景是缓存被长期保存,比如把它放在模块顶层反复使用,这时用 Map 会让所有拷过的原对象永远释放不掉。用 WeakMap 更多是一种稳妥的习惯,面试时把这一层说清楚比只说「WeakMap 不会内存泄漏」更有说服力。
容易答错的地方
- 缓存存的是
true,而不是副本 - 有人用
WeakSet或者cache.set(obj, true)只记录「处理过」。这样能防止无限递归,但遇到环时拿不到副本,只能返回原对象或者undefined,副本里又出现了指向原数据的引用,深拷贝就不彻底了。表里必须存副本本身。 - 以为有了
WeakMap就不会爆栈 WeakMap只防环,不防深。一条很长但没有环的链表,递归照样会一层层压栈,层数够多仍然抛RangeError。要彻底解决只能改成用栈的迭代写法。
面试官还会怎么问?
WeakMap 的键为什么不能是字符串?
弱引用的意义是跟着对象的生命周期走,对象不再被使用就能回收。字符串、数字这些原始值没有独立的生命周期,谈不上「被回收」,所以不允许作为键。深拷贝里原始值直接返回,本来也不需要进表。
原生有能处理循环引用的深拷贝吗?
有,structuredClone 能正确处理循环引用和重复引用,结果和上面手写版一致。它不支持函数和 DOM 节点,遇到会抛 DataCloneError,类实例会丢掉原型。JSON.parse(JSON.stringify(obj)) 遇到循环引用会直接抛 TypeError。
能不能给原对象打个标记来代替 WeakMap?
能做到,比如给处理过的对象加一个隐藏属性存副本,但会修改原数据:冻结的对象加不上属性会报错,标记还可能被其它代码遍历到,拷贝完还得清理。WeakMap 在外面单独维护对应关系,不碰原对象,这是它更合适的原因。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。