循环引用的成因及解决方式

1,182 阅读4分钟

当我们需要在进行对象深拷贝时,我们可能会遇到一个常见的问题:循环引用。这个问题指的是对象中存在对自身的引用,导致深拷贝时陷入死循环,最终导致内存溢出。本文将介绍如何在深拷贝过程中解决循环引用问题。

什么是循环引用

循环引用指的是对象中存在对自身的引用

let obj1 = {};
let obj2 = {};

obj1.a = obj2;
obj2.b = obj1;

在上面的代码中,obj1obj2 互相引用,形成了一个循环引用的结构。这个结构会导致深拷贝时出现死循环,最终导致内存溢出。

deepClone

我们先来回顾一下deepClone,常见的实现方式有以下两种:

  • 直接用JSON.stringify(obj),这样的缺点是无法复制引用类型
let obj = {
  a: 1,
  b: [1, 2, 3],
}

let res = JSON.stringify(obj);
console.log(JSON.parse(res));
  • 第二种就是常见的手写题了
function deepClone(obj) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  let clone = Array.isArray(obj) ? [] : {};
  
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      clone[key] = deepClone(obj[key]);
    }
  }
  
  return clone;
}

上面两种方法都存在一个问题,就是他们没对循环引用的对象做任何处理,这也是我们今天要讨论的主题。

循环引用

let obj1 = {};
let obj2 = {};

obj1.a = obj2;
obj2.b = obj1;

console.log('obj1', obj1);
JSON.stringify(obj1); // 会直接报错

这段代码中我们做了以下步骤

  • 创建两个空对象,分别叫做obj1和obj2
  • 将obj1的a属性指向obj2,将obj2的b属性指向obj1
  • 打印obj1并展开,你会发现出现如下情况

image.png

  • 如果我们尝试JSON序列化obj1对象,此时你会发现直接报错

image.png

上面的示例中,obj1和obj2这两个对象循环引用了,这会导致JS的引擎无法进行垃圾回收,因为引擎判断obj1和obj2都被其他对象引用了。如果我们递归的便利一个被循环引用的对象,那将会直接导致爆栈(stack overflow)。

如何判断一个对象是否被循环引用

可以使用递归 + 哈希表的方式,核心思路是,当我们遍历一个obj的时候,如果map中不存在这个key,就将这对key-val放入map中,如果map中存在这个key,就说明该对象内存在循环引用的情况

function isCircular(obj, hash = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return false
  if (hash.has(obj)) return true

  hash.set(obj, true)

  for (let key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      if (isCircular(obj[key], hash)) return true
    }
  }

  return false
}

WeakMap

WeakMap是ES6中新引入的数据类型,和Map类似,它对于值的引用不计入垃圾回收机制的。所以名字中才会有个Weak,表示这是弱引用

WeakMap有以下几个特点:

  • 不可枚举:因此无法通过 for...of 或 Object.keys() 等方法遍历
  • 只接受对象作为键:key只能是Object类型,不接受原始类型的值或者其他类型的对象
  • 弱引用:key都为弱引用,即如果一个键不再被引用,那么这个键值对会被自动删除,这样就可以避免内存泄漏的问题
  • 没有 size 属性:WeakMap 没有 size 属性,因此无法知道 WeakMap 中有多少个键值对
  • 不能遍历:由于 WeakMap 中的键值对不可枚举,因此也不能遍历 WeakMap 中的所有键值对

总之,WeakMap 是一种特殊的映射表,用于存储对象之间的关系。由于它的键是弱引用的,因此可以避免内存泄漏的问题。但是,由于它的一些特殊限制,也使得它不能像普通的对象或 Map 一样被广泛地使用。但是,用于判断循环引用,WeakMap 非常适合。

const cache = new WeakMap()

function compute(obj) {
  if (cache.has(obj)) {
    console.log('Cache hit')
    return cache.get(obj)
  } else {
    console.log('Cache miss')
    const result = obj.x + obj.y
    cache.set(obj, result)
    return result
  }
}

const obj1 = { x: 1, y: 2 }
const obj2 = { x: 3, y: 4 }

console.log(compute(obj1)) // Cache miss, 3
console.log(compute(obj1)) // Cache hit, 3
console.log(compute(obj2)) // Cache miss, 7
console.log(compute(obj2)) // Cache hit, 7

解决方案

为了解决这个深拷贝中循环引用的问题,我们需要在拷贝过程中记录已经拷贝过的对象,以便在遇到循环引用时能够正确处理。一种常见的解决方法是使用一个哈希表来存储已经拷贝过的对象,这样我们可以在遇到循环引用时直接返回已经拷贝过的对象的引用。

function deepClone(obj, hash = new WeakMap()) {
  if (obj instanceof Date) return new Date(obj)
  if (obj instanceof RegExp) return new RegExp(obj)
  if (obj === null || typeof obj !== 'object') return obj
  if (hash.has(obj)) return hash.get(obj)

  const cloneObj = new obj.constructor()
  hash.set(obj, cloneObj)

  for (let key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      cloneObj[key] = deepClone(obj[key], hash)
    }
  }

  return cloneObj
}

总结

在进行对象深拷贝时,循环引用是一个常见的问题。为了解决这个问题,我们需要在深拷贝过程中判断对象是否存在循环引用,并使用哈希表来存储已经拷贝过的对象。WeakMap 是一种特殊的映射表,用于存储对象之间的关系,可以避免内存泄漏的问题。

参考文档

JavaScript 内存管理

JavaScript WeakMap

JavaScript 内存泄漏教程