7

万恶的前端内存泄漏及万善的解决方案「详解」

 3 years ago
source link: https://my.oschina.net/u/4811659/blog/4881032
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

最近收到测试人员的反馈说我们开发的页面偶现卡死,点击无反应的情况,特别是打开页面较久的时候发生概率较高。打开任务管理器,看到内存占有率已经很高了,初步判断可能存在内存泄漏的情况。下面排查内存泄漏的原因。

系统进程不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。当内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。Chrome 限制了浏览器所能使用的内存极限(64 位为 1.4GB,32 位为 1.0GB)

引起内存泄漏的原因

1、意外的全局变量

由于 js 对未声明变量的处理方式是在全局对象上创建该变量的引用。如果在浏览器中,全局对象就是 window 对象。变量在窗口关闭或重新刷新页面之前都不会被释放,如果未声明的变量缓存大量的数据,就会导致内存泄露。

  • 未声明变量
function fn() {
  a = 'global variable'
}
fn()
复制代码
  • 使用 this 创建的变量(this 的指向是 window)。
function fn() {
  this.a = 'global variable'
}
fn()
复制代码

解决方法:

  • 避免创建全局变量
  • 使用严格模式,在 JavaScript 文件头部或者函数的顶部加上 use strict

2、闭包引起的内存泄漏

原因:闭包可以读取函数内部的变量,然后让这些变量始终保存在内存中。如果在使用结束后没有将局部变量清除,就可能导致内存泄露。

function fn () {
  var a = "I'm a";
  return function () {
    console.log(a);
  };
}
复制代码

解决:将事件处理函数定义在外部,解除闭包,或者在定义事件处理函数的外部函数中。

比如:在循环中的函数表达式,能复用最好放到循环外面。

// bad
for (var k = 0; k < 10; k++) {
  var t = function (a) {
    // 创建了10次  函数对象。
    console.log(a)
  }
  t(k)
}

// good
function t(a) {
  console.log(a)
}
for (var k = 0; k < 10; k++) {
  t(k)
}
t = null
复制代码

3、没有清理的 DOM 元素引用

原因:虽然别的地方删除了,但是对象中还存在对 dom 的引用。

// 在对象中引用DOM
var elements = {
  btn: document.getElementById('btn'),
}
function doSomeThing() {
  elements.btn.click()
}

function removeBtn() {
  // 将body中的btn移除, 也就是移除 DOM树中的btn
  document.body.removeChild(document.getElementById('button'))
  // 但是此时全局变量elements还是保留了对btn的引用, btn还是存在于内存中,不能被GC回收
}
复制代码

解决方法:手动删除,elements.btn = null

4、被遗忘的定时器或者回调

定时器中有 dom 的引用即使 dom 删除了,但是定时器还在,所以内存中还是有这个 dom。

// 定时器
var serverData = loadData()
setInterval(function () {
  var renderer = document.getElementById('renderer')
  if (renderer) {
    renderer.innerHTML = JSON.stringify(serverData)
  }
}, 5000)

// 观察者模式
var btn = document.getElementById('btn')
function onClick(element) {
  element.innerHTMl = "I'm innerHTML"
}
btn.addEventListener('click', onClick)
复制代码

解决方法:

  • 手动删除定时器和 dom。
  • removeEventListener 移除事件监听

vue 中容易出现内存泄露的几种情况

在 Vue SPA 开发应用,那么就更要当心内存泄漏的问题。因为在 SPA 的设计中,用户使用它时是不需要刷新浏览器的,所以 JavaScript 应用需要自行清理组件来确保垃圾回收以预期的方式生效。因此开发过程中,你需要时刻警惕内存泄漏的问题。

1、全局变量造成的内存泄露

声明的全局变量在切换页面的时候没有清空

<template>
  <div id="home">这里是首页</div>
</template>

<script>
  export default {
    mounted() {
      window.test = {
        // 此处在全局window对象中引用了本页面的dom对象
        name: 'home',
        node: document.getElementById('home'),
      }
    },
  }
</script>
复制代码

解决方案:在页面卸载的时候顺便处理掉该引用。

destroyed () {
  window.test = null // 页面卸载的时候解除引用
 }
复制代码

2、监听在 window/body 等事件没有解绑

特别注意 window.addEventListener 之类的时间监听

<template>
<div id="home">这里是首页</div>
</template>

<script>
export default {
mounted () {
  window.addEventListener('resize', this.func) // window对象引用了home页面的方法
}
}
</script>
复制代码

解决方法:在页面销毁的时候,顺便解除引用,释放内存

mounted () {
  window.addEventListener('resize', this.func)
},
beforeDestroy () {
  window.removeEventListener('resize', this.func)
}
复制代码

3、绑在 EventBus 的事件没有解绑

<template>
  <div id="home">这里是首页</div>
</template>

<script>
export default {
  mounted () {
   this.$EventBus.$on('homeTask', res => this.func(res))
  }
}
</script>
复制代码

解决方法:在页面卸载的时候也可以考虑解除引用

mounted () {
 this.$EventBus.$on('homeTask', res => this.func(res))
},
destroyed () {
 this.$EventBus.$off()
}
复制代码

4、Echarts

每一个图例在没有数据的时候它会创建一个定时器去渲染气泡,页面切换后,echarts 图例是销毁了,但是这个 echarts 的实例还在内存当中,同时它的气泡渲染定时器还在运行。这就导致 Echarts 占用 CPU 高,导致浏览器卡顿,当数据量比较大时甚至浏览器崩溃。

解决方法:加一个 beforeDestroy()方法释放该页面的 chart 资源,我也试过使用 dispose()方法,但是 dispose 销毁这个图例,图例是不存在了,但图例的 resize()方法会启动,则会报没有 resize 这个方法,而 clear()方法则是清空图例数据,不影响图例的 resize,而且能够释放内存,切换的时候就很顺畅了。

beforeDestroy () {
  this.chart.clear()
}
复制代码

5、v-if 指令产生的内存泄露

v-if 绑定到 false 的值,但是实际上 dom 元素在隐藏的时候没有被真实的释放掉。

比如下面的示例中,我们加载了一个带有非常多选项的选择框,然后我们用到了一个显示/隐藏按钮,通过一个 v-if 指令从虚拟 DOM 中添加或移除它。这个示例的问题在于这个 v-if 指令会从 DOM 中移除父级元素,但是我们并没有清除由 Choices.js 新添加的 DOM 片段,从而导致了内存泄漏。

<div id="app">
  <button v-if="showChoices" @click="hide">Hide</button>
  <button v-if="!showChoices" @click="show">Show</button>
  <div v-if="showChoices">
    <select id="choices-single-default"></select>
  </div>
</div>

<script>
  export default {
    data() {
      return {
        showChoices: true,
      }
    },
    mounted: function () {
      this.initializeChoices()
    },
    methods: {
      initializeChoices: function () {
        let list = []
        // 我们来为选择框载入很多选项,这样的话它会占用大量的内存
        for (let i = 0; i < 1000; i++) {
          list.push({
            label: 'Item ' + i,
            value: i,
          })
        }
        new Choices('#choices-single-default', {
          searchEnabled: true,
          removeItemButton: true,
          choices: list,
        })
      },
      show: function () {
        this.showChoices = true
        this.$nextTick(() => {
          this.initializeChoices()
        })
      },
      hide: function () {
        this.showChoices = false
      },
    },
  }
</script>
复制代码

在上述的示例中,我们可以用 hide() 方法在将选择框从 DOM 中移除之前做一些清理工作,来解决内存泄露问题。为了做到这一点,我们会在 Vue 实例的数据对象中保留一个属性,并会使用 Choices API 中的 destroy() 方法将其清除。

<div id="app">
  <button v-if="showChoices" @click="hide">Hide</button>
  <button v-if="!showChoices" @click="show">Show</button>
  <div v-if="showChoices">
    <select id="choices-single-default"></select>
  </div>
</div>

<script>
  export default {
    data() {
      return {
        showChoices: true,
        choicesSelect: null
      }
    },
    mounted: function () {
      this.initializeChoices()
    },
    methods: {
      initializeChoices: function () {
        let list = []
        for (let i = 0; i < 1000; i++) {
          list.push({
            label: 'Item ' + i,
            value: i,
          })
        }
         // 在我们的 Vue 实例的数据对象中设置一个 `choicesSelect` 的引用
        this.choicesSelect = new Choices("#choices-single-default", {
          searchEnabled: true,
          removeItemButton: true,
          choices: list,
        })
      },
      show: function () {
        this.showChoices = true
        this.$nextTick(() => {
          this.initializeChoices()
        })
      },
      hide: function () {
        // 现在我们可以让 Choices 使用这个引用,从 DOM 中移除这些元素之前进行清理工作
        this.choicesSelect.destroy()
        this.showChoices = false
      },
    },
  }
</script>
复制代码

ES6 防止内存泄漏

前面说过,及时清除引用非常重要。但是,你不可能记得那么多,有时候一疏忽就忘了,所以才有那么多内存泄漏。

ES6 考虑到这点,推出了两种新的数据结构: weakset 和 weakmap 。他们对值的引用都是不计入垃圾回收机制的,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存。

const wm = new WeakMap()
const element = document.getElementById('example')
vm.set(element, 'something')
vm.get(element)
复制代码

上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对 element 的引用就是弱引用,不会被计入垃圾回收机制。

注册监听事件的 listener 对象很适合用 WeakMap 来实现。

// 代码1
ele.addEventListener('click', handler, false)

// 代码2
const listener = new WeakMap()
listener.set(ele, handler)
ele.addEventListener('click', listener.get(ele), false)
复制代码

代码 2 比起代码 1 的好处是:由于监听函数是放在 WeakMap 里面,一旦 dom 对象 ele 消失,与它绑定的监听函数 handler 也会自动消失。

作者:lzg9527

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK