本篇文章参考以下博文
- 《页面卡顿?内存泄漏?一文详解如何排查》–魔术师卡颂
文章目录
前言
我们页面白屏了,大部分情况下是某个报错导致,只要打开开发者工具,查看报错提示就可以了。。如果 webpack 配置了 source-map ,那么就更容易定位了,可以直接精确定位到哪一行,哪一列的代码出现了问题。
但是有时候白屏问题的出现,是因为页面的内存泄漏,那这就都有无从下手的感觉了,今天我们就来看看,这个内存泄漏,该怎么办?
1.定义
内从泄露作为一名开发肯定经常听到,那什么叫做内存泄漏?有大佬给过定义,它是指由于疏忽或者程序的某些错误,造成未能释放已经不再使用的内存情况。
通俗一点讲,占着茅坑不拉屎,就是内存泄漏。
2.JS数据存储
JS 的内存空间可以分为 栈内存和堆内存,前者用来存放一些简单数据类型,后者用来存放复杂数据类型。
简单数据类型: String Number Boolean null undefined Symbol 等。
复杂数据类型: Object Array Function 等。
3.垃圾回收
JS 的内存是可以自动回收的,这样做的好处就是,我们一开始不用初始化给预设一个内存值,而是随用随取,我们只需要定义变量就可以了,至于用多少内存,不用了怎么回收, JS 会帮我们处理。
但是 JS 毕竟要按规则办事,只有确定不用的数据,它才会回收内存,那我们如何能帮助 JS 确定要不要回收这部分内存呢?请看下面的例子。
function fn1 () {
let one = {
name: 1
};
let two = 2;
function fn2 () {
let three = [3]
};
fn2();
return one;
}
let res = fn1();
以上代码的调用栈如下图所示:
图中左侧为栈空间,用于存放一些执行上下文和基本数据类型;右侧为堆空间,用于存放一些复杂数据对象。
当代码执行到 fn2() 时,栈空间内的执行上下文从上往下依次是 fn2 函数执行上下文 => fn1 函数执行上下文 => 全局执行上下文。
待 fn2 函数内部执行完毕后,就该退出 fn2 函数执行上下文了,即箭头向下移动,此时 fn2 函数执行上下文 会被清除并释放栈内存空间,如图所示:
待 fn1 函数内部执行完毕以后,就该退出 fn1 函数执行上下文了,即箭头再向下移动,此时 fn1 函数执行上下文 会被清除并释放相应的栈内存空间,如图所示:
此时处于全局的执行上下文中, JavaScript 的垃圾回收器会每隔一段时间遍历调用栈,假设此时触发了垃圾回收机制,在遍历调用栈时发现变量 b 和变量 c 没有被任何变量所引用,所以认定他们是垃圾数据,并给他们打上标记。
因为 fn1 函数执行完后将变量返回出去了,并存储在全局变量 res 中,所以认定其为 活动数据 并打上相应标记。待空闲时刻就会将标记上垃圾数据的变量给全部清除掉,释放相应内存,如图所示:
从这里我们可以得出几点结论:
1. JavaScript 的垃圾回收机制是自动执行的,并且会通过标记来识别并清除垃圾数据。
2.在离开局部作用于后,若该作用域的变量没有被外部作用域所引用,则在后续会被清除
补充: JS 的垃圾回收机制有着很多的步骤,上述只讲到了 标记-清除,其实还有其他过程,比如:
标记-整理:在清空部分垃圾数据,释放了一定的内存空间后,可能会留下大面积的不连续内存片段,导致后续可能无法为某些对象分配连续内存,此时需要整理一下内存空间;
交替执行:因为 JavaScript 是运行在主线程上的,所以执行垃圾回收机制的时候会暂停 js 的运行,若垃圾回收机制执行时间过长,则会带来卡顿的现象。所以垃圾回收机制会被分成一个个的小任务,穿插在 js 任务中,交替执行,尽可能的不带来卡顿。
4.Chrome devTools 查看内存情况
在了解一些常见的内存泄漏场景之前,先简单介绍一下如何使用 Chrome 的开发者工具开查看 js 的内存情况。
首先我们需要打开 Chrome 的无痕模式,这个目的是屏蔽到我们安装的 Chrome 插件,避免对我们的测试造成影响。
然后打开开发者工具,打开 Performance 这一栏,可以看到一些功能按钮,效果如下:
我们拿百度举个例子。点击录制,然后刷新一下百度首页,点结束,会出现以下面板。
从上面可以看到, JS Heap ( js 内存)、 documents (文档)、 Nodes ( dom 节点)、 Listener (监听器)、 GPU memory ( GPU 内存)的最低值、最高值以及随时间的走势曲线,这也是我们的主要关注点。
接下来我们再来看看 Memory 面板,其主要用于记录页面堆内存的具体情况以及 js 堆内存随加载时间线动态的分布情况。
堆快照就像照相机一样,能记录当前页面的内存情况,每快照一次就会产生一条快照记录,如图:
上面图片显示,第一次没有搜索前,内存是 2.7MB ,当搜索“啦啦啦”后,内存变化到了 4.4MB 。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构,占总内存的百分比…)。
然后我们还可以看一下页面动态的内存变化情况。
当我们启动动态动态内存监听的时候,面板上方会出现一些蓝色和灰色的柱状图,蓝色代表当前时间线下占用的内存;灰色表示之前占用的内存空间已经被清除释放了。
当我们在页面上进行操作的时候,之前的部分蓝色柱体会变灰,一些新的蓝色柱体会出现,表示原占用空间得到释放。
5.内存泄漏场景
那么哪些情况会引起内存泄漏的情况呢?下面列举几个例子:
1.闭包使用不当,引起内存泄漏。
2.全局变量
3.分离的 DOM 节点。
4.控制台的打印
5.遗忘的定时器
针对上面的情况,我们分别用两种定位方式来解决一下。
5.1 闭包使用不当
文章开头的时候,在退出 fn1 函数执行上下文后,该上下文中的变量 a 本应被当作垃圾数据处理掉,但因 fn1 函数最终将变量 a 返回,并赋值给全局变量 res 。产生了对变量 a 的引用。所以变量 a 被标记为活动变量,并一直占用着内存,假设变量 res 后续用不到了,这就是一种闭包使用不当的例子。
下面我们用 Performance 和 Memory 来查一下闭包导致的内存泄漏。为了使效果明显,我们改一下开头的代码
<button onclick="myClick()">执行 fn1 函数</button>
<script>
function fn1 () {
let a = new Array(10000);
let b = 3;
function fn2() {
let c = [1, 2, 3];
}
fn2();
return a;
}
let res = [];
function myClick() {
res.push(fn1());
}
</script>
设置一个按钮,每次执行就会将 fn1 函数的返回值添加到全局数组变量 res 中,是为了能在 performance 的曲线图中看出效果。如图所示:
我们录制开始后,先点击一次垃圾回收,然后执行三次点击操作,最后再次点击垃圾回收,发现最后时刻的基准线比一开始要高,说明可能是内存泄漏问题。
在怀疑是内存泄漏的情况下,我们可以用 Memory 面板来明确问题和定位问题。如下图所示:
Memory 面板动态录制后蓝色柱体没有变成灰色,说明分配的内存没有被清除。此时能明确确实存在内存泄漏了。接下来就是精确定位到哪里泄露的。
上图中为什么不查看那个内存占用 10% 的数组,是因为那个是内部数组,属于 JS 本身的内存占用,如果你点进去查看详情的话,还会提示预览不可用。
以上就是判断闭包导致内存泄漏的简单定位过程了。
5.2 全局变量
全局变量一般是不会被垃圾回收掉的,在文章开头的时候提到过,但是这并不是说变量都不能存在全局变量里,只是有时候会因为疏忽导致某些变量流失到全局里。例如没有声明的变量,就直接赋值:
function fn1() {
//此处 name 未被声明
name = new Array(9999);
}
这种情况下就会在全局声明一个 name ,并存一个很大的数组。
解决方法的话,需要自己平时多注意,不要再未声明前就是用,还有就是可以开启严格模式,这样可以在不知情时收到报错警告。
function fn1() {
'use strict'
name = new Array(9999);
}
fn1();
5.3 分离的 DOM 节点
什么叫 DOM 节点?假设你手动移除了某个 DOM 节点,本应释放该 dom 节点的所有内存,但却因为疏忽导致某处代码仍对该被移除的节点有引用,最终导致该节点无法被释放。通常情况如下:
<div id="root">
<div class="child">我是子元素</div>
<button>移除</button>
</div>
<script>
let btn = document.querySelector('button');
let child = document.querySelector('.child');
let root = document.querySelector('#root');
btn.addEventListener('click', function() {
root.removeChild(child);
})
</script>
上面代码的功能是点击按钮后移除 .child 节点,虽然点击后该节点确实从 dom 被移除了,但全局变量 child 仍对该节点有引用,所以导致该节点的内存一直无法被释放,可以尝试用 Memory 的快照功能来检测一下,如下如:
点击快照后,由于单一 dom 节点的内存太小,无法看出变化,我们可以再搜索框输入 detached ,则会展示出所有脱离了却又未被清除的节点对象。解决办法如下:
<div id="root">
<div class="child">我是子元素</div>
<button>移除</button>
</div>
<script>
let btn = document.querySelector('button');
btn.addEventListener('click', function() {
let child = document.querySelector('.child');
let root = document.querySelector('#root');
root.removeChild(child);
})
</script>
改动很简单,将 .child 节点的引用移动到 click 事件的回调中,那么当移除节点并对出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了。
我们验证一下:
可以看到,搜索结果为空。
5.4 控制台打印
控制台的打印也能引起内存泄漏嘛???没错,如果浏览器没有一直保存着我们的打印信息,我们为何能在每一打来控制台 Console 的时候看到呢?看一段测试代码:
<button>按钮</button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(100000);
console.log(obj);
})
</script>
我们在按钮的回调中创建一个很大的数组对象并打印,用 performance 来验证一下:
可以看到经过三次点击后,内存阶梯性的增长,说明每次都保存了比较大的一个对象,在点击垃圾回收后,内存水准没有降低到开始值,说明 console 打印的日志,是一直被引用的。比较有意思的是,在第二次和第三次点击的时候,内存会先降低,后增加,说明浏览器在检测到相同元素被打印的时候,会自动释放一部分内存,不过这个功能并不是在所有版本都有。
如果把代码中的 console 注释掉,效果如下:
可以看到,最终结果是回归到点击前的内存水平的。
我们还可以通过 Memory 面板来查看
未注释 console.log :
注释 console.log :
总结一下:在开发环境下,可以使用控制台打印,便于调试,但是在生产环境下,我们尽量不要在控制台打印数据。这就造成一种现象,比如下面:
if(idDev) {
console.log(obj);
}
这样可以避免生产环境下的内存占用,同理, console.error,console.info,console.dir 等都不要使用。
5.5 遗忘的定时器
定时器也是我们经常犯的一种内存泄漏问题,示例如下:
<button>开启定时器</button>
<script>
function fn1() {
let largeObj = new Array(100000);
setInterval(() => {
let myObj = largeObj;
}, 1000);
}
document.querySelector('button').addEventListener('click', function() {
fn1();
})
</script>
这段代码实在点击后,执行 fn1 函数,函数内部创建一个很大的数组对象,同时创建一个定时器,定时器的回调函数只是简单引用一下变量 largeObj ,我们一起来看下内存分配情况。
按理来说,点击按钮执行 fn1 后悔退出该函数的执行上下文,紧跟着函数体内的局部变量应该被清除,但图中 performance 的结果显示似乎存在内存泄漏,即最终曲线高于初始曲线。我们用 Memory 再试一次
上图可以看到,当点击按钮后,出现蓝色柱状,但是之后,却没有被回收,而是一直存在。原因就是应为 setInterval 的回调函数内对变量 largeObj 有一个引用关系,而定时器一直未被清除,所以变量 largeObj 的内存也自然不会被释放。
那么我们如何解决这个问题呢? 假设我们只需要让定时器执行三次就可以了,那我们可以改一下代码。
<button>开启定时器</button>
<script>
function fn1() {
let largeObj = new Array(100000);
let index = 0;
let timer = setInterval(() => {
if(index ===3) clearInterval(timer);
let myObj = largeObj;
index++;
}, 1000);
}
document.querySelector('button').addEventListener('click', function() {
fn1();
})
</script>
设定一个哨兵 index 让他来监督定时器执行次数,到达限制后,清除定时器。修改之后我们来看面板变化:
Performance 面板:
Memory 面板:
箭头指的地方在点击按钮后是蓝色,三秒钟后,变为灰色。
总结一下,大家平时用的定时器,一定记得要清除,否则就会出现上述情况,除了 setTimeout 和 setInterval ,浏览器还提供了一个 API 也会有这样的问题 requestAnimationFrame ;
6.总结
项目中如果遇到了某些性能问题可能和内存泄漏有关的时候,可以按照本文的 5 种方式排查,一定能找到问题关键所在。
虽然 JavaScript 的垃圾回收是组懂得,但我们有时候也需要考虑要不要手动清除某些变量的内存占用,例如某个变量占用比较大,你确定不再需要的时候,可以直接赋值 null 提前给他清空掉。