JavaScript的内存泄漏详解

写在前面

当一个页应用随着使用的时间越用越卡,出现反应迟缓,高延迟甚至崩溃的时候,那就说明这个页面出了大问题,然后排除掉开发者和浏览器在应用中特意缓存的数据后还有这样的问题出现,那这应用就是出现了内存泄漏。

什么是内存泄漏

内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

我们在JavaScript中的垃圾回收机制中已经了解到,javascript引擎是有垃圾回收机制的,它每隔一段时间或者由于某种条件的触发就会开始执行,然后把用不到的变量清除,内存回收,这样才能保证系统的良好运行。但是由于各种原因,导致一些变量在建立之后就一直存在应用中,GC无法回收,导致应用越来越臃肿,内存越占越多,最后直接崩溃。这个内存无法被正确回收的情况就是我们说的内存泄漏。

常见的引发内存泄漏的方式

1. 错误的闭包使用方式

提到闭包,就会让人想到内存泄漏,提到内存泄漏,就会让人想到闭包。它们之间似乎是天生一对,你中有我,我中有你。其实并不全是这样子,闭包的使用确实可能会导致内存泄漏,但是并不是说所有的内存泄漏都是由闭包导致的或者说闭包一定会导致内存泄漏。首先我们先要明确的一点是什么样的算是闭包,你们可以阅读全面理解闭包这篇文章,了解一下闭包的概念。当然,由于不同的文献,不同的人对闭包的理解不一样,所以我们在这里可以给出一个最标准的,所有人都认同的闭包作为例子,然后我们再探讨接下来的问题。

javascript 复制代码
function showA() {
   let a = 0;
    function print() {
        console.log(a)
    }
   return print
}
let show = showA()
show() // 0

这就是我们说的闭包。理论上来说当变量show定义完,showA函数也运行完了,这个函数里面所有的变量就会被垃圾回收机制回收,但是由于show是个函数,而且它的目的就是展示showA函数里面a的值,如果showA的变量全部被回收了,那a就不存在了,很明显这是不对的,那怎么办?只能让它不被回收了,这样在调用show方法的时候才能正确显示a变量的值。那这样问题就来了,如果一直不回收,那这个内存就会一直被占着,一个,两个,三个...,越来越多,最终系统崩溃是必然的。

javascript 复制代码
function showA() {
  let a = 0;
  function print() {
    console.log(a)
  }
  return print
}

let show = showA()
show() // 0
show = null

如果我们在运行完之后告诉系统我们用不到了(show = null),这样在进行垃圾回收的时候这块代码的内 存回收掉,自然就不会出现内存泄漏的问题。所以当我们有这个意识,并且能正确使用闭包的话也是不会存在内存泄漏的问题的。

2. 隐式定义的全局变量

在javascript中,定义的全局变量是不会被回收的,所以我们在定义全句变量的时候要慎之又慎,因为如果是没有必要的变量作为了全局变量,那他就相当白白占用着系统内存,这也是一种内存泄漏。 但是光明白这点还不够,因为有时候在不经意之间就会创建出自己都不知道什么时候创建的全局变量:

javascript 复制代码
function fun() {
  a = '这是a'
  this.b= '这是b'
}
fun()

上面的这个函数,其实我们已经在不经意间创建了2个全局变量,ab现在都已经在window上了,垃圾回收机制是不会回收它的,那这样又出现了内存泄漏。

javascript 复制代码
function fun() {
  const a = '这是a'
  const b= '这是b'
}
fun()

如果我们这样定义变量,那ab就被限制在了fun函数中,函数运行完,它们自然也跟着被回收了。

3. 被遗忘的计时器

javascript 复制代码
let a = 0
function countA(){
  setInterval(() => {
    a++
  },1000)
}
countA()

上面的例子我们a做了个一秒自增一次的装置,但是用着用着我们就会发现a这个变量一直在增加,或许我们只是想让它加到5而已,但是现在他已经不受我们的控制了。我们把问题想的再严重一点,如果这个函数不是做a的自增,而是创建dom的话,那不用说了,过不了多久这个系统就会随着内存使用过多导致崩溃。所以在计时器的使用中我们也要慎重,要注意在不使用的时候及时将其删除,避免导致内存泄漏。

javascript 复制代码
let a = 0
let timer = null
function countA(){
  timer = setInterval(() => {
    a++
  },1000)
}
countA()
clearInterval(timer) // 不使用的时候将其清除

4. dom泄漏

一般来说如果通过remove方法可以将dom移除,移除之后他就不会占用内存了,但是,如果在移除的时候它还在被使用,那他就不能被移除,并且会占用着内存(和闭包相似)。

javascript 复制代码
<div id="root">
  root
  <ul id="ul">
    <li id="li1">li1</li>
    <li id="li2">li1</li>
  </ul>
</div>
<script>
  let root = document.querySelector('#root')
  let ul = document.querySelector('#ul')
  let li1 = document.querySelector('#li1')
  let li2 = document.querySelector('#li2')
  root.removeChild(ul) // 此时页面上已经没有ul和li的节点了
  console.log(ul); // 节点对象
  console.log(li1);// 节点对象
  console.log(li2);// 节点对象
  ul = null
  console.log(ul);// null
  console.log(li1);// 节点对象
  console.log(li2);// 节点对象
  li1 = null
  console.log(ul);// null
  console.log(li1);// null
  console.log(li2);// 节点对象
  li2 = null
  console.log(ul);// null
  console.log(li1);// null
  console.log(li2);// null
</script>

从上面的例子只能够我们看到,虽然已经移除了root的子节点页面上也没有显示,但是由于有变量接收了节点的数据,实际上在内存中还是有被移除的节点的信息的,如果不清除,那它一样会占用内存,造成内存泄漏。

5. 事件监听和观察者模式

在进行页面开发的过程中我们免不了需要监听对dom的一些操作,在用现在的前端框架时,有些人会选择使用EventBus去进行状态的传递,这些操作都是要我们去手动开启监听的,我们以vue举例:

javascript 复制代码
<template>
  <div id="dom"></div>
</template>

<script>
export default {
  created() {
    document.getElementById('dom')
      .addEventListener("click", ()=>{}) // 这种方式在vue里一般不会用
    window.addEventListener("resize", ()=>{})
    eventBus.on("event", ()=>{})
  },
}
</script>

当我们进入组件,开启时间监听之后就可以根据这些事件的变化然后去进行一些相应的处理,但是当组件被销毁之后,它们还是存在的,这就不合理了,组件都不在了,它们还在监听,还在占用着内存,这不就造成了内存泄漏吗?

所以我们在不使用这些事件之后(例如组件销毁)应该及时将其关闭:

javascript 复制代码
<template>
  <div id="dom"></div>
</template>

<script>
export default {
  created() {
    document.getElementById('dom')
      .addEventListener("click", ()=>{}) // 这种方式在vue里一般不会用
    window.addEventListener("resize", ()=>{})
    eventBus.on("event", ()=>{})
  },
  beforeDestroy(){
    document.getElementById('dom')
      .removeEventListener("click", ()=>{})
    window.removeEventListener("resize", ()=>{})
    eventBus.off("event", this.doSomething)
  },
}
</script>

6. 意想不到的console.log

写前端代码时我们总免不了要去做一些调试和跟踪数据,这些时候一般都会用到console.log输出变量的内容到控制台来帮我们调试,但是传递给console.log的对象是不能被垃圾回收的,所以在开发环境最好不要使用console.log不然又会出现内存泄露的情况

javascript 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <button id='runBtn'>运行输出内容</button>
  <script>
    runBtn.onclick = () => {
      runBtn.remove();
      addString();
    }
    const addString = (i=1)=> {
      const  logContent = { i, a: 'X'.repeat(2000000) }
      console.log(logContent);
        if(i<500) addString(++i) // 限制了500次
    }
  </script>
</body>
</html>

上面的例子就是很明显console.log导致内存泄露使系统崩溃的例子,如果我们把那个限制去掉,执行次数到几千次的时候系统就撑不住了,但是如果我们去掉console.log就不会出现这样的问题。

7. Map和Set

Map和Set是ES6新添加的数据结构,他们的key可以是任意类型,既能是基本类型,也能是引用类型。那么问题就来了,如果以对象作为key值的话如果这个对象用不到了,垃圾回收机制会回收它么?答案是不会的,因为虽然代码中没有再用这个对象做什么事情,但是由于MapSet他们将这个对象当做key了,所以还是不能回收的。这样在无形中就造成了内存泄露。

javascript 复制代码
let map= new Map();
let value = { test: "map"};
map.set(value,'map结构');
value= null;
// value虽然为null,但是之前的引用还没被回收,此时已经造成内存泄露
for (let [key, value] of map) {
  console.log(key + " = " + value); // [object Object] = map结构
}

如果想要解决MapSet内存泄露的问题,那就应该在value置null之前先将他们移除

javascript 复制代码
let map= new Map();
let value = { test: "map"};
map.set(value,'map结构');
map.delete(value )
value= null;
// map无值,此时无法执行for... of
for (let [key, value] of map) {
  console.log(key + " = " + value);
}

基于这样的原因,所以与他们同时出现的还有WeakMapWeakSet,由于他们只能以对象作为key而且他们的键都是弱引用,也就是说如果这个对象用不到了,垃圾回收机制就会被回收。

javascript 复制代码
let map= new WeakMap();
let value = { test: "map"};
map.set(value,'map结构'); // 由于是弱引用,map的key是不可枚举的
value= null; // value之前的引用会被垃圾回收机制回收而不用手动移除

内存泄漏的排查

了解了内存泄露的常见方式想,现在我们就需要知道怎么通过去排查内存泄露的问题,看看是不是真的如上文所说。现在我们以console.log为例开始进行排查:

  1. 浏览器F12打开检查窗口,并且切换到【Performance】标签
  2. 清空所有内容并且进行一次CG,防止其他干扰项影响
  3. 开启记录
  4. 并执行页面内容
  5. 停止记录,查看内存使用状态

从上面的内存使用状态中我们可以看到,使用了console.log方法后内存一直在涨,直到执行完500次不再执行之后才停止,而且内存一直被占用着。

接下来我们把console.log(logContent)注释掉再看看内存的占用情况:

可以很容易得出结论console.log确实是会造成内存泄露的。

相关推荐
李明卫杭州25 分钟前
浅谈JavaScript中Blob对象
前端·javascript
meng半颗糖28 分钟前
vue3 双容器自动扩展布局 根据 内容的多少 动态定义宽度
前端·javascript·css·vue.js·elementui·vue3
ZJ_1 小时前
Electron自动更新详解—包教会版
前端·javascript·electron
哆啦美玲1 小时前
Callback 🥊 Promise 🥊 Async/Await:谁才是异步之王?
前端·javascript·面试
万能的小裴同学1 小时前
让没有小窗播放的视频网站的视频小窗播放
前端·javascript
今禾2 小时前
# 深入理解JavaScript闭包与柯里化:函数式编程的核心利器
javascript
三脚猫的喵2 小时前
微信小程序使用画布实现飘落泡泡功能
前端·javascript·微信小程序·小程序
海的诗篇_2 小时前
前端开发面试题总结-vue2框架篇(三)
前端·javascript·css·面试·vue·html
comelong3 小时前
还有人不知道IntersectionObserver也可以实现懒加载吗
前端·javascript·面试