有哪些“手段”会造成Javascript的内存泄漏

458 阅读5分钟

1. 什么是内存泄漏

引擎中有垃圾回收机制,它主要针对一些程序中不再使用的对象,对其清理回收释放掉内存。 那么垃圾回收机制会把不再使用的对象(垃圾)全都回收掉吗?

其实引擎虽然针对垃圾回收做了各种优化从而尽可能的确保垃圾得以回收,但并不是说我们就可以完全不用关心这块了,我们代码中依然要主动避免一些不利于引擎做垃圾回收操作,因为不是所有无用对象内存都可以被回收的,那当不再用到的对象内存,没有及时被回收时,这种场景称之为内存泄漏(Memory leak)。

2. 常见的内存泄漏

不正当的闭包

function fn2(){
  let test = new Array(1000).fill('test')
  return function(){
    console.log(test)
    return test    // test 会造成内存泄漏,无法回收 
  }
}
let fn2Child = fn2()
fn2Child()
fn2Child = null     // 为了解决内存泄漏问题,在执行结束后手动把test指向null

隐式全局变量

JS的垃圾回收时自动执行的,垃圾回收器每隔一段时间就会找出那些不再使用的数据,并释放其所占用的内存空间。

再来看全局变量和局部变量,函数中的局部变量在函数执行结束后这些变量已经不再被需要,所以垃圾回收器会识别并释放他们。但是对于全局变量,垃圾回收器很难判断这些变量什么时候不被需要,所以全局变量通常不会被回收,我们使用全局变量是可以的,但同时我们要避免一些额外的全局变量产生,如下:

function fn(){
  // 没有声明从而制造了隐式全局变量test1
  test1 = new Array(1000).fill('test')
  
  // 函数内部this指向window,制造了隐式全局变量test2
  this.test2 = new Array(1000).fill('test2')
}

所以,我们应该避免这些隐式的全局变量。

游离DOM引用

考虑到性能或代码简洁方面,我们代码中进行DOM时会使用变量缓存DOM节点引用,但移除节点时,我们应该同步释放缓存的引用,否则游离的子树无法释放:

<div id="root">
  <ul id="ul">
    <li></li>
    <li></li>
    <li id="li3"></li>
    <li></li>
  </ul>
</div>
<script>
  let root = document.querySelector('#root')
  let ul = document.querySelector('#ul')
  let li3 = document.querySelector('#li3')
  
  // 由于ul变量存在,整个ul及其子元素都不能GC
  root.removeChild(ul)
  
  // 虽置空了ul变量,但由于li3变量引用ul的子节点,所以ul元素依然不能被GC
  ul = null
  
  // 已无变量引用,此时可以GC
  li3 = null
</script>

如上所示,当我们使用变量缓存DOM节点引用后删除了节点,如果不将引用的变量置空,依然无法进行回收,也就会出现内存漏洞。

假如我们将父节点置空,但是被删除的父节点其子节点引用也缓存在变量里,那么就会导致整个父DOM节点树下整个游离节点树均无法清理,还是会出现内存泄漏,解决办法就是将子节点的变量也置空,如下图:

定时器

// 获取数据
let someResource = getData()
setInterval(() => {
  const node = document.getElementById('Node')
	if(node) {
    node.innerHTML = JSON.stringify(someResource))
	}
}, 1000)

代码中每隔一秒就将得到的数据放入到Node节点中去,但是在setInterval没有结束前,回调函数前,回调函数里的变量以及回调函数本身都无法被回收。

什么才叫结束呢?也就是调用了clearInterval。如果没有被claer掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也无法被回收。所以在上例中someResource就没法被回收。

同样,setTimeout也会有同样问题,所以,当不需要interval或者timrout时,最好调用clearInterval或者clearTimeout来清除,另外,浏览器中的requestAnimationFrame也存在这个问题,我们需要在不需要的时候用cancelAnimationFrame API来取消使用。

事件监听器

当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样就造成意外的内存泄漏。

Vue组件的例子:

<template>
  <div></div>
</template>

<script>
export default {
  created() {
    // 添加监听器
    window.addEventListener("resize", this.doSomething)
  },
  beforeDestroy(){
    // 移除监听器
    window.removeEventListener("resize", this.doSomething)
  },
  methods: {
    doSomething() {
      // do something
    }
  }
}
</script>

Map、Set对象

当使用Map或Set存储对象时,同Object一致都是强引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收。

如果使用Map,对于键为对象的情况,可以采用WeakMap,WeakMap对象同样用来保存键值对,对于键是弱引用(注:WeakMap只对于键是弱引用),且必须为一个对象,而值可以是任意的对象或者原始值,由于是对于对象的弱引用,不会干扰JS的垃圾回收。

如果需要使用Set引用对象,可以采用WeakSet,WeakSet对象允许存储对象弱引用的唯一值,WeakSet对象中的值同样不会重复,且只能保存对象的弱引用,同样由于是对于对象的弱引用,不会干扰JS的垃圾回收。

let obj = {id: 1}
let user = {info: obj}
let set = new Set([obj])
let map = new Map([[obj, 'xianzao']])

// 重写obj
obj = null 

console.log(user.info) // {id: 1}
console.log(set)
console.log(map)

上述代码中重写了obj以后,{id:1} 依然会存在于内存中,因为user 对象以及后面的 set/map 都强引用了它,Set/Map、对象、数组对象等都是强引用,所以我们仍然可以获取到 {id:1} ,我们想要清除那就只能重写所有引用将其置空了。

let obj = {id: 1}
let weakSet = new WeakSet([obj])
let weakMap = new WeakMap([[obj, 'xianzao']])

// 重写obj引用
obj = null

// {id: 1} 将在下一次 GC 中从内存中删除

上述代码中使用了WeakMap以及WeakSet 即为弱引用,将obj引用置为null后,对象 {id:1} 将在下一次GC中被清理出内存。