打开网易新闻 查看更多图片

作者 | 零一0101 责编 | 欧阳姝黎

不知道在座的各位有没有被问到过这样一个问题:如果页面卡顿,你觉得可能是什么原因造成的?有什么办法锁定原因并解决吗?

这是一个非常宽泛而又有深度的问题,他涉及到很多的页面性能优化问题,我依稀还记得当初面试被问到这个问题时我是这么回答的:

  1. 先会检查是否是网络请求太多,导致数据返回较慢,可以适当做一些缓存

  2. 也有可能是某块资源的 bundle 太大,可以考虑拆分一下

  3. 然后排查一下 js 代码,是不是某处有过多循环导致占用主线程时间过长

  4. 浏览器某帧渲染的东西太多,导致的卡顿

  5. 在页面渲染过程中,可能有很多重复的重排重绘

  6. emmmmmm....不知道了

后来了解到了,感官上的长时间运行页面卡顿也有可能是因为内存泄漏引起的

内存泄漏的定义

那什么是内存泄漏呢?借助别的大佬给出的定义,内存泄漏就是指由于疏忽或者程序的某些错误造成未能释放已经不再使用的内存的情况。简单来讲就是假设某个变量占用 100M 的内存,而你又用不到这个变量,但是这个变量没有被手动的回收或自动回收,即仍然占用 100M 的内存空间,这就是一种内存的浪费,即内存泄漏

JS 的数据存储

JavaScript 的内存空间分为栈内存堆内存,前者用来存放一些简单变量,后者用来存放复杂对象

  • 简单变量指的是 JS 的基本数据类型,例如:String、Number、Boolean、null、undefined、Symbol、BigInt

  • 复杂对象指的是 JS 的引用数据类型,例如:Object、Array、Function...

JS 垃圾回收机制

根据内存泄漏的定义,有些变量或数据不再被使用或不需要了,那么它就是垃圾变量或垃圾数据,如果其一直保存在内存中,最终可能会导致内存占用过多的情况。那么此时就需要对这些垃圾数据进行回收,这里引入了垃圾回收机制的概念

垃圾回收的机制分为手动自动两种

例如 C/C++ 采用的就是手动回收的机制,即先用代码为某个变量分配一定的内存,然后在不需要了后,再用代码手动释放掉内存

而 JavaScript 采用的则是自动回收的机制,即我们不需要关心何时为变量分配多大的内存,也不需要关心何时去释放内存,因为这一切都是自动的。但这不表示我们不需要关心内存的管理!!!!否则也不会有本文讨论的内存泄露了

接下来就讲一下 JavaScript 的垃圾回收机制

通常全局状态(window)下的变量是不会被自动回收的,所以我们来讨论一下局部作用域下的内存回收情况

function fn1 () {
let a = {
name: '零一'
}

let b = 3

function fn2() {
let c = [1, 2, 3]
}

fn2()

return a
}

let res = fn1()

以上代码的调用栈如下图所示:

图中左侧为栈空间,用于存放一些执行上下文和基本类型数据;右侧为堆空间,用于存放一些复杂对象数据

当代码执行到 fn2() 时,栈空间内的执行上下文从上往下依次是 fn2 函数执行上下文 => fn1 函数执行上下文 => 全局执行上下文

待 fn2 函数内部执行完毕以后,就该退出 fn2 函数执行上下文了,即箭头向下移动,此时 fn2 函数执行上下文会被清除并释放栈内存空间,如图所示:

打开网易新闻 查看更多图片

待 fn1函数内部执行完毕以后,就该退出 fn1函数执行上下文了,即箭头再向下移动,此时 fn1函数执行上下文会被清除并释放相应的栈内存空间,如图所示:

此时处于全局的执行上下文中。JavaScript 的垃圾回收器会每隔一段时间遍历调用栈,假设此时触发了垃圾回收机制,当遍历调用栈时发现变量 b 和变量 c 没有被任何变量所引用,所以认定它们是垃圾数据并给它们打上标记。因为fn1函数执行完后将变量 a 返回了出去,并存储在全局变量 res 中,所以认定其为活动数据并打上相应标记。待空闲时刻就会将标记上垃圾数据的变量给全部清除掉,释放相应的内存,如图所示:

从这我们得出几点结论:

JavaScript 的垃圾回收机制是自动执行的,并且会通过标记来识别并清除垃圾数据

  • 在离开局部作用域后,若该作用域内的变量没有被外部作用域所引用,则在后续会被清除

  • 补充:JavaScript 的垃圾回收机制有着很多的步骤,上述只讲到了标记-清除,其实还有其它的过程,这里简单介绍一下就不展开讨论了。例如:标记-整理,在清空部分垃圾数据后释放了一定的内存空间后会可能会留下大面积的不连续内存片段,导致后续可能无法为某些对象分配连续内存,此时需要整理一下内存空间;交替执行,因为 JavaScript 是运行在主线程上的,所以执行垃圾回收机制时会暂停 js 的运行,若垃圾回收执行时间过长,则会给用户带来明显的卡顿现象,所以垃圾回收机制会被分成一个个的小任务,穿插在js任务之中,即交替执行,尽可能得保证不会带来明显的卡顿感

    Chrome devTools 查看内存情况

    在了解一些常见的内存泄漏的场景之前,先简单介绍一下如何使用 Chrome 的开发者工具来查看js内存情况

    首先打开 Chrome 的无痕模式,这样做的目的是为了屏蔽掉 Chrome 插件对我们之后测试内存占用情况的影响

    打开网易新闻 查看更多图片

    然后打开开发者工具,找到 Performance 这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化js内存、节点、事件监听器按钮;触发垃圾回收机制按钮等等

    简单录制一下百度页面,看看我们能获得什么,如下动图所示:

    从上图中我们可以看到,在页面从零到加载完成这个过程中 JS Heap(js堆内存)、documents(文档)、Nodes(DOM节点)、Listeners(监听器)、GPU memory(GPU内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点

    再来看看开发者工具中的 Memory 一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况

    堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录,如图所示:

    打开网易新闻 查看更多图片

    如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为 13.9MB,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为 13.4MB。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)

    然后我们还可以看一下页面动态的内存变化情况,如图所示:

    在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中蓝色表示当前时间线下占用着的内存;灰色表示之前占用的内存空间已被清除释放。

    从上图过程来看,我们可以看到刚开始处于的 tab 所对应显示的页面中占用了一定的堆内存空间,成蓝色柱形,在点击别的 tab 后,原 tab 对应的内容消失,并且原来蓝色的柱形变成灰色(表示原占用的内存空间得到了释放),同时新 tab 所对应显示的页面也占用了一定的堆内存空间。因此后续我们就可以针对这个图来查看内存的占用与清除情况

    内存泄漏的场景

    那么到底有哪些情况会出现内存泄漏的情况呢?这里列举了常见的几种:

    1. 闭包使用不当引起内存泄漏

    2. 全局变量

    3. 分离的 DOM 节点

    4. 控制台的打印

    5. 遗忘的定时器

    接下来介绍一下各种情况,并尝试用刚才讲到的两种方法来捕捉问题所在

    5.1 闭包使用不当

    文章开头的例子中,在退出 fn1函数执行上下文后,该上下文中的变量 a 本应被当作垃圾数据给回收掉,但因 fn1函数最终将变量 a 返回并赋值给全局变量res,其产生了对变量 a 的引用,所以变量 a 被标记为活动变量并一直占用着相应的内存,假设变量 res 后续用不到,这就算是一种闭包使用不当的例子

    接下来尝试使用 Performance和Memory 来查看一下闭包导致的内存泄漏问题,为了使内存泄漏的结果更加明显,我们稍微改动一下文章开头的例子,代码如下:

    执行fn1函数button>