Javascript如何避免内存泄漏

一、如何监控内存状况

这里借用一下林三心大佬的图文。

浏览器任务管理器

打开方式:在浏览器顶部右键,打开任务管理器:

image-20220310164329157

image-20220310164734409

打开后,可以看到内存和JavaScript内存:

  • 内存:页面里的原始内存,也就是DOM节点的总占用内存
  • JavaScript内存(括号里):是该页面中所有可达对象的总占用内存

那什么是可达对象呢?简单说就是:就是从初始的根对象(window或者global)的指针开始,向下搜索子节点,子节点被搜索到了,说明该子节点的引用对象可达,搜不到,说明该子节点对象不可达。举个例子:

1
2
3
4
5
6
7
// 可达,可以通过window.name访问
var name = 'Zh'

function fn () {
// 不可达,访问不了
var name = 'Zh'
}

回到我们的任务管理,此时我们在页面中编写一段代码:

1
2
3
4
5
6
<button id="btn">点击</button>
<script>
document.getElementById('btn').onclick = function () {
list = new Array(1000000)
}
</script>

点击前:
image-20220310165143724
点击后,发现内存瞬间上升:
image-20220310165024376

Performance

使用Chrome浏览器的无痕模式,是为了避免很多其他因素,影响咱们查看内存:

image-20220310165336577

按F12打开调试窗口,选择Performance

image-20220310165416413

咱们就以掘金首页为例吧!点击录制 -> 刷新掘金 -> 点击stop,可以看到以下指标随着时间的上下波动

  • JS Heap:JS堆

  • Documents: 文档

  • Nodes: DOM节点

  • Listeners: 监听器

  • GPU Memory: GPU内存

    juejinperf.gif

堆快照

堆快照,顾名思义,就是将当前某一个页面的堆内存拍下照片存起来,同一个页面,执行某个操作前,录制堆快照是一个样,有可能执行完后,录制的堆快照又是另外一个样。

image-20220310165725251

还是以掘金首页为例,可以看到当前页面内存为13.3M,咱们可以选择Statistics,查看数组,对象,字符串等所占内存。

掘金堆快照.gif

二、内存泄漏的场景

下面列举了可能会造成内存泄漏的情况:

  1. 闭包使用不当引起内存泄漏
  2. 全局变量
  3. 分离的DOM节
  4. 控制台的打印
  5. 未清除的定时器
    接下来我们一一来介绍这些情况。

1.闭包使用不当

1
2
3
4
5
6
7
8
9
function fn1() {
let arr = new Array(100000)

return arr
}
let a = []
document.getElementById('btn').onclick = function () {
a.push(fn1())
}

上述代码中f1被调用后,从可达性的角度来说,arr应该被回收,但实际上并不是这样的。f1arrreturn之后,arrpush进了数组a,而数组a是一个全局变量,并不会被回收,这就导致了arr不会被回收。

全局变量

全局变量一般不会被垃圾回收机制回收。当然,这并不意味着完全禁止我们定义全局变量,只是有时候会因为疏忽而导致某些变量流失到全局,例如未声明变量,却直接对某变量进行赋值,就会导致该变量在全局创建,如下所示:

1
2
3
4
5
function fn1() {
// 此处变量arr未被声明
arr = new Array(100000)
}
fn1()

上述代码会自动在全局创建一个变量arr,并将数组赋值给arr,由于是全局变量,所以arr的内存一直不会释放。

因此,我们平时需多加注意,不要在变量未声明前赋值,或者也可以开启严格模式,这样就会在不知情犯错时,收到报错警告,例如:

1
2
3
4
5
6
function fn1() {
'use strict';
name = new Array(100000)
}

fn1()

3.分离的dom节点

让我们用代码来解释一下什么为分离的dom节点:

1
2
3
4
<button id="btn">点击</button>

let btn = document.getElementById('btn')
document.body.removeChild(btn)

如上所示,虽然最后把button给删除了,但是因为全局变量btn对此DOM对象引用着,导致此DOM对象一直没有被回收,这个DOM对象就称为分离DOM

这个问题很好解决,删除button后,顺便把btn设置成null就行了:

1
2
3
4
5
<button id="btn">点击</button>

let btn = document.getElementById('btn')
document.body.removeChild(btn)
btn = null

4.控制台的打印

控制台的打印也会造成内存泄漏吗???是的呀,如果浏览器不一直保存着我们打印对象的信息,我们为何能在每次打开控制的Console时看到具体的数据呢?先来看一段测试代码:

1
2
3
4
5
6
7
<button>按钮</button>
<script>
document.querySelector('button').addEventListener('click',function({
let arr = new Array(100000)
console.log(obj);
})
</script>

当我们点击按钮时,这个arr会被控制台打印下来,浏览器一直保存着这个arr的信息,并不会被垃圾回收机制回收。

虽然console.log便于调试,但是我们在生产环境,我们尽可能不要在控制台打印数据,所以我们经常会在代码中看到类似如下的操作:

1
2
3
4
// 如果在开发环境下,打印变量obj
if(isDev) {
console.log(obj)
}

这样就避免了生产环境下无用的变量打印占用一定的内存空间,同样的除了console.log之外,console.errorconsole.infoconsole.dir等等都不要在生产环境下使用

5.未清除的定时器

下面这段代码中,执行完fn1函数,按理说arr数组会被回收,但是他却回收不了。为什么呢?因为定时器里的a引用着arr,并且定时器不清除的话,a就不会被回收,a不回收就会一直引用着arr,那么arr肯定也回收不了了。

1
2
3
4
5
6
7
8
9
function fn() {
let arr = new Array(1000000).fill('Sunshine_Lin')
setInterval(() => {
let a = arr
}, 1000)
}
document.getElementById('btn').onclick = function () {
fn()
}

处理的方法也很简单,只要我们清除定时器就行了:

1
2
3
4
5
6
7
8
9
10
11
12
function fn() {
let arr = new Array(1000000).fill('Sunshine_Lin')
let i = 0
let timer = setInterval(() => {
if (i > 5) clearInterval(timer)
let a = arr
i++
}, 1000)
}
document.getElementById('btn').onclick = function () {
fn()
}

三、总结

在项目过程中,如果遇到了某些性能问题可能跟内存泄漏有关时,就可以参照以上列举的5种情况去排查。

虽然JavaScript的垃圾回收是自动的,但我们有时也是需要考虑要不要手动清除某些变量的内存占用的,例如你明确某个变量在一定条件下再也不需要,但是还会被外部变量引用导致内存无法得到释放时,你可以用null对该变量重新赋值就可以在后续垃圾回收阶段释放该变量的内存了。

参考资料

赠你13张图,助你20分钟打败了「V8垃圾回收机制」!!!
哪是大神?只是用他人七夕约会时间,整理「JS避免内存泄漏」罢了
一文带你了解如何排查内存泄漏导致的页面卡顿现象