声明:本文为掘金首发签约文章,未经受权禁止转载。javascript
想来不少同窗看到内存泄漏,心里直接会跳出两个字:闭包!!!再让你说点其它的估计就噤声了。若是你对内存泄漏的了解仅限于闭包,那真的是应该仔细看此文了,闭包可能会形成内存泄漏,可是内存泄漏并非只有闭包,它只是内存泄漏的引子之一罢了。html
写的程序运行一段时间后慢慢变卡甚至要崩溃了?前端
如题,你的程序中可能存在内存泄漏,说到内存泄漏,建议先读 「硬核JS」你真的懂垃圾回收机制吗 一文,而后再来看此文会比较通透,毕竟垃圾回收和内存泄漏是因果关系,垃圾被回收了啥事没有,垃圾没被回收就是内存泄漏。java
此文咱们会介绍内存泄漏的相关概念和引发内存泄漏的一些问题,还会着重给你们介绍内存泄漏的排查、定位及修复方法(学到便可用到),最后还简单扩展了下前端内存三大件的其余两件内存膨胀和频繁 GC 的概念。node
引擎中有垃圾回收机制,它主要针对一些程序中再也不使用的对象,对其清理回收释放掉内存。程序员
那么垃圾回收机制会把再也不使用的对象(垃圾)全都回收掉吗?正则表达式
其实引擎虽然针对垃圾回收作了各类优化从而尽量的确保垃圾得以回收,但并非说咱们就能够彻底不用关心这块了,咱们代码中依然要主动避免一些不利于引擎作垃圾回收操做,由于不是全部无用对象内存均可以被回收的,那当再也不用到的对象内存,没有及时被回收时,咱们叫它 内存泄漏(Memory leak)
。数组
代码不规范,同事两行泪,接下来咱们看看会引发内存泄漏的一些常见案例。浏览器
闭包就是函数内部嵌套并 return 一个函数???这是大多数人认为的闭包,好吧,它确实也是,咱们来看看几本 JS 高光书中的描述:缓存
按照上面三本书中的描述,那闭包所涉及的的范围就比较广了,咱们这里暂时不去纠结闭包的定义,就以最简单、你们都承认的闭包例子来看闭包:
function fn1(){
let test = new Array(1000).fill('isboyjc')
return function(){
console.log('hahaha')
}
}
let fn1Child = fn1()
fn1Child()
复制代码
上例是闭包吗?它形成内存泄漏了吗?
显然它是一个典型闭包,可是它并无形成内存泄漏,由于返回的函数中并无对 fn1
函数内部的引用,也就是说,函数 fn1
内部的 test
变量彻底是能够被回收的,那咱们再来看:
function fn2(){
let test = new Array(1000).fill('isboyjc')
return function(){
console.log(test)
return test
}
}
let fn2Child = fn2()
fn2Child()
复制代码
上例是闭包吗?它形成内存泄漏了吗?
显然它也是闭包,而且由于 return
的函数中存在函数 fn2
中的 test
变量引用,因此 test
并不会被回收,也就形成了内存泄漏。
那么怎样解决呢?
其实在函数调用后,把外部的引用关系置空就行了,以下:
function fn2(){
let test = new Array(1000).fill('isboyjc')
return function(){
console.log(test)
return test
}
}
let fn2Child = fn2()
fn2Child()
fn2Child = null
复制代码
“ 减小使用闭包,闭包会形成内存泄漏。。。 ”
醒醒,这句话是过去式了,它的描述不许确,So,应该说不正当的使用闭包可能会形成内存泄漏。
咱们知道 JavaScript
的垃圾回收是自动执行的,垃圾回收器每隔一段时间就会找出那些再也不使用的数据,并释放其所占用的内存空间。
再来看全局变量和局部变量,函数中的局部变量在函数执行结束后这些变量已经再也不被须要,因此垃圾回收器会识别并释放它们。可是对于全局变量,垃圾回收器很难判断这些变量何时才不被须要,因此全局变量一般不会被回收,咱们使用全局变量是 OK 的,但同时咱们要避免一些额外的全局变量产生,以下:
function fn(){
// 没有声明从而制造了隐式全局变量test1
test1 = new Array(1000).fill('isboyjc1')
// 函数内部this指向window,制造了隐式全局变量test2
this.test2 = new Array(1000).fill('isboyjc2')
}
fn()
复制代码
调用函数 fn
,由于 没有声明 和 函数中this 的问题形成了两个额外的隐式全局变量,这两个变量不会被回收,这种状况咱们要尽量的避免,在开发中咱们可使用严格模式或者经过 lint
检查来避免这些状况的发生,从而下降内存成本。
除此以外,咱们在程序中也会不可避免的使用全局变量,这些全局变量除非被取消或者从新分配以外也是没法回收的,这也就须要咱们额外的关注,也就是说当咱们在使用全局变量存储数据时,要确保使用后将其置空或者从新分配,固然也很简单,在使用完将其置为 null
便可,特别是在使用全局变量作持续存储大量数据的缓存时,咱们必定要记得设置存储上限并及时清理,否则的话数据量愈来愈大,内存压力也会随之增高。
var test = new Array(10000)
// do something
test = null
复制代码
考虑到性能或代码简洁方面,咱们代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,咱们应该同步释放缓存的引用,不然游离的子树没法释放。
<div id="root">
<ul id="ul">
<li></li>
<li></li>
<li id="li3"></li>
<li></li>
</ul>
</div>
<script> let root = document.querySelector('#root') let ul = document.querySelector('#ul') let li3 = document.querySelector('#li3') // 因为ul变量存在,整个ul及其子元素都不能GC root.removeChild(ul) // 虽置空了ul变量,但因为li3变量引用ul的子节点,因此ul元素依然不能被GC ul = null // 已无变量引用,此时能够GC li3 = null </script>
复制代码
如上所示,当咱们使用变量缓存 DOM 节点引用后删除了节点,若是不将缓存引用的变量置空,依然进行不了 GC,也就会出现内存泄漏。
假如咱们将父节点置空,可是被删除的父节点其子节点引用也缓存在变量里,那么就会致使整个父 DOM 节点树下整个游离节点树均没法清理,仍是会出现内存泄漏,解决办法就是将引用子节点的变量也置空,以下图:
程序中咱们常常会用到计时器,也就是 setTimeout
和 setInterval
,先来看一个例子:
// 获取数据
let someResource = getData()
setInterval(() => {
const node = document.getElementById('Node')
if(node) {
node.innerHTML = JSON.stringify(someResource))
}
}, 1000)
复制代码
上面是我随便 copy
的一个小例子,其代码中每隔一秒就将获得的数据放入到 Node
节点中去,可是在 setInterval
没有结束前,回调函数里的变量以及回调函数自己都没法被回收。
什么才叫结束呢?也就是调用了 clearInterval
。若是没有被 clear
掉的话,就会形成内存泄漏。不只如此,若是回调函数没有被回收,那么回调函数内依赖的变量也无法被回收。因此在上例中,someResource
就无法被回收。
一样,setTiemout
也会有一样的问题,因此,当不须要 interval
或者 timeout
时,最好调用 clearInterval
或者 clearTimeout
来清除,另外,浏览器中的 requestAnimationFrame
也存在这个问题,咱们须要在不须要的时候用 cancelAnimationFrame
API 来取消使用。
当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是须要的而不会进行回收,若是内部引用的变量存储了大量数据,可能会引发页面占用内存太高,这样就形成意外的内存泄漏。
咱们就拿 Vue 组件来举例子,React 里也是同样的:
<template>
<div></div>
</template>
<script> export default { created() { window.addEventListener("resize", this.doSomething) }, beforeDestroy(){ window.removeEventListener("resize", this.doSomething) }, methods: { doSomething() { // do something } } } </script>
复制代码
监听者模式想必咱们都知道,无论是 Vue 、 React 亦或是其余,对于目前的前端开发框架来讲,监听者模式实现一些消息通讯都是很是常见的,好比 EventBus
. . .
当咱们实现了监听者模式并在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是须要的而不会进行回收,若是内部引用的变量存储了大量数据,可能会引发页面占用内存太高,这样也会形成意外的内存泄漏。
仍是用 Vue 组件举例子,由于比较简单:
<template>
<div></div>
</template>
<script> export default { created() { eventBus.on("test", this.doSomething) }, beforeDestroy(){ eventBus.off("test", this.doSomething) }, methods: { doSomething() { // do something } } } </script>
复制代码
如上,咱们只需在 beforeDestroy
组件销毁生命周期里将其清除便可。
当使用 Map
或 Set
存储对象时,同 Object
一致都是强引用,若是不将其主动清除引用,其一样会形成内存不自动进行回收。
若是使用 Map
,对于键为对象的状况,能够采用 WeakMap
,WeakMap
对象一样用来保存键值对,对于键是弱引用(注:WeakMap
只对于键是弱引用),且必须为一个对象,而值能够是任意的对象或者原始值,因为是对于对象的弱引用,不会干扰 Js
的垃圾回收。
若是须要使用 Set
引用对象,能够采用 WeakSet
,WeakSet
对象容许存储对象弱引用的惟一值,WeakSet
对象中的值一样不会重复,且只能保存对象的弱引用,一样因为是对于对象的弱引用,不会干扰 Js
的垃圾回收。
这里可能须要简单介绍下,谈弱引用,咱们先来讲强引用,以前咱们说 JS 的垃圾回收机制是若是咱们持有对一个对象的引用,那么这个对象就不会被垃圾回收,这里的引用,指的就是 强引用
,而弱引用就是一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,所以可能在任什么时候刻被回收。
不明白?来看例子就晓得了:
// obj是一个强引用,对象存于内存,可用
let obj = {id: 1}
// 重写obj引用
obj = null
// 对象从内存移除,回收 {id: 1} 对象
复制代码
上面是一个简单的经过重写引用来清除对象引用,使其可回收。
再看下面这个:
let obj = {id: 1}
let user = {info: obj}
let set = new Set([obj])
let map = new Map([[obj, 'hahaha']])
// 重写obj
obj = null
console.log(user.info) // {id: 1}
console.log(set)
console.log(map)
复制代码
此例咱们重写 obj
之后,{id: 1}
依然会存在于内存中,由于 user
对象以及后面的 set/map
都强引用了它,Set/Map、对象、数组对象等都是强引用,因此咱们仍然能够获取到 {id: 1}
,咱们想要清除那就只能重写全部引用将其置空了。
接下来咱们看 WeakMap
以及 WeakSet
:
let obj = {id: 1}
let weakSet = new WeakSet([obj])
let weakMap = new WeakMap([[obj, 'hahaha']])
// 重写obj引用
obj = null
// {id: 1} 将在下一次 GC 中从内存中删除
复制代码
如上所示,使用了 WeakMap
以及 WeakSet
即为弱引用,将 obj
引用置为 null
后,对象 {id: 1}
将在下一次 GC 中被清理出内存。
写代码的过程当中,确定避免不了一些输出,在一些小团队中可能项目上线也不清理这些 console
,却不知这些 console
也是隐患,同时也是容易被忽略的,咱们之因此在控制台能看到数据输出,是由于浏览器保存了咱们输出对象的信息数据引用,也正是所以未清理的 console
若是输出了对象也会形成内存泄漏。
因此,开发环境下咱们可使用控制台输出来便于咱们调试,可是在生产环境下,必定要及时清理掉输出。
可能有同窗会以为难以想象,甚至不相信,这里咱们留一个例子,你们看完文章恰好能够本身测试一下,能够先保存这段代码哦!(如何测试看完下文就明白啦)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
</head>
<body>
<button id="click">click</button>
<script> !function () { function Test() { this.init() } Test.prototype.init = function () { this.a = new Array(10000).fill('isboyjc') console.log(this) } document.querySelector('#click').onclick = function () { new Test(); } }() </script>
</body>
</html>
复制代码
正如开头所说,程序运行一段时间后慢慢变卡甚至要崩溃了,不知道是什么缘由,那咱们就经过一个例子来走一遍排查、定位以及修复内存泄漏的整个流程,敲黑板,这是你们真正可以用上的。
既然上面咱们说了几个会形成内存泄漏的案例,那咱们就用这些案例写个 Demo
来从浏览器的角度反推排查是否存在内存泄漏,存在的话定位泄漏源并给予修复。
首先,咱们来捏造一个内存泄漏例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
</head>
<body>
<button id="click">click</button>
<h1 id="content"></h1>
<script> let click = document.querySelector("#click"); let content = document.querySelector("#content") let arr = [] function closures() { let test = new Array(1000).fill('isboyjc') return function () { return test } } click.addEventListener("click", function () { arr.push(closures()) arr.push(closures()) content.innerHTML = arr.length }); </script>
</body>
</html>
复制代码
如上所示,这是一个很是简单的由不正当使用闭包构成的内存泄漏例子。
咱们先来简单介绍下,只看 script
中的 JS 代码便可,首先,咱们有一个 closures
函数,这是一个闭包函数,最简单的闭包函数想必不用向你们介绍了吧,而后咱们为页面中的 button
元素绑定了一个点击事件,每次点击都将执行 2 次闭包函数并将其执行结果 push
到全局数组 arr
中,因为闭包函数执行结果也是一个函数而且存在对原闭包函数内部数组 test
的引用,因此 arr
数组中每一项元素都使得其引用的闭包内部 test
数组对象没法回收,arr
数组有多少元素,也就表明着咱们存在多少次闭包引用,因此此程序点击次数越多,push
的越多,内存消耗越大,页面也会愈来愈卡。
那为了便于后期观察,程序中咱们在每次点击按钮后,都把全局数组 arr
的长度数据更新到了页面上,即从 0 开始,每点击一次,页面数值加 2。
固然,这是咱们本身写的例子,做为上帝的咱们知道是什么缘由致使的,那如今,忘掉这些,假设这是咱们的一个项目程序,开发完成交付给测试,测试小姐姐发如今程序中不断点击按钮后页面愈来愈迟钝了,随即提了BUG。
做为程序员的咱们确定是:“刷新下页面不就行了,卡了就刷新刷新!!!”
嗯。。。产品和测试确定都不会答应,一句用户至上就让咱们改。。
行吧,那就改,首先第一步就要排查是哪里出了问题、是什么引发的,那此环节咱们就叫排查问题阶段好了。
Chrome
的开发者工具也就是咱们所说的浏览器控制台(Chrome Devtool
)功能其实十分强大,经过它能够帮助咱们分析程序中像性能、安全、网络等各类东西,也可让咱们快速定位到问题源,只是大多数人并不熟悉其使用而已。
因为此文咱们之内存泄漏为主,那咱们就默认上述程序已经排查了除内存以外全部项且都没问题,接下来开始排查内存这块。
首先咱们开启浏览器的无痕模式,接着打开要检查的网页程序代码,而后打开控制台,整个程序界面很是简单,以下图:
接着咱们找到 Performance
这一面板,以前叫 Timeline
,它是 Chrome Devtool
用来监控性能指标的一个利器,能够记录并分析在网站的生命周期内所发生的各种事件,咱们就能够经过它监控咱们程序中的各类性能状况并分析,其中就包括内存,以下图:
接下来开始操做,在开始以前必定要确认勾选了 Memory
选项也就是上图标记 5 ,这样咱们才能够看到内存相关的分析。
点击开始录制(标记 1)进入录制状态,随后先清理一下GC,也就是点击小垃圾桶(标记 6)。
接着疯狂点击页面中 click
按钮 100 次,这时页面上的数值应该是 200,咱们再点击一下小垃圾桶,手动触发一次 GC。
再次疯狂点击页面中 click
按钮 100 次,这时页面上的数值应该是 400,而后中止录制。
咱们来观察控制台生成的数据面板,以下图:
上面圈红的两块,也就是 Heap
对应的部分表示内存在周期性的回落,简单说就是咱们的内存状况。
咱们能够很明显的看到,内存数据呈现出一个不断上涨的趋势,可能有人会说这段时间内是否是还没执行 GC 呢?别急,还记得咱们在 200 的时候点击了一下小垃圾桶吗,也就是咱们中间手动触发垃圾回收一次,咱们就能够经过上面的页面快照找出当页面值为 200 的那一刻在哪里,很简单,鼠标移到内存快照上找就好了,以下图:
能够看到,即便咱们中间手动作了一次垃圾回收操做,但清理后的内存并无减小不少,由此咱们推断,此程序的点击操做可能存在内存泄漏。
OK,排查到问题了,那接下来就是定位泄漏源在哪了。
你可能会说,既然已经找到问题所在就是点击事件了,直接去改不就完了?
要知道,这是咱们写的一个简单的例子,咱们一会儿就能够看出问题在哪,可是真实项目中一个点击事件里就可能存在大量操做,而咱们只知道点击事件可能致使了内存泄漏,但不知道具体问题是在点击事件的哪一步骤上,更加细粒度的引发缘由和位置咱们也不知,这些都还须要咱们进一步分析去定位。
接下来咱们开始分析定位泄漏源
Chrome Devtool
还为咱们提供了 Memory
面板,它能够为咱们提供更多详细信息,好比记录 JS CPU 执行时间细节、显示 JS 对象和相关的DOM节点的内存消耗、记录内存的分配细节等。
其中的 Heap Profiling
能够记录当前的堆内存 heap
的快照,并生成对象的描述文件,该描述文件给出了当下 JS 运行所用的全部对象,以及这些对象所占用的内存大小、引用的层级关系等等,用它就能够定位出引发问题的具体缘由以及位置。
注意,可不是 Performance
面板下那个 Memory
,而是与 Performance
面板同级的 Memory
面板,以下图:
如今页面值为 400,固然也能够刷新一下页面从 0 开始,这里咱们选择继续操做
首先点击一下小垃圾桶(标记 3),触发一下 GC,把没用的东西从内存中干掉
点击开始生成快照(标记 1),生成第一次快照并选中,以下图:
简单介绍小上图大概表示的什么意思:
左侧列表中的 Snapshot 1
表明了咱们生成的快照1,也就是刚刚那一刻的内存状态
选中 Snapshot 1
后就是右侧视图表格了,表格左上方有一个下拉框,它有四个值
该下拉默认会为咱们选择 Summary
,因此下方表格展现的就是快照1中数据的内存摘要,简单理解就是快照1生成的那一刻,内存中都存了什么,包括占用内存的信息等等。
来简单了解下 Summary
选项数据表格的列都表示什么
OK,暂时知道这么多就能够了,咱们继续操做,先点击小垃圾桶手动执行一次GC,而后点击 1 下页面的 click
按钮,最后再次点击生成快照按钮,生成咱们的第二次快照。
为了准确无误,咱们多来几回操做,以下:
先点击小垃圾桶手动执行一次 GC,而后点击 2 下页面的 click
按钮,最后再次点击生成快照按钮,生成咱们的第三次快照
先点击小垃圾桶手动执行一次 GC,而后点击 3 下页面的 click
按钮,最后再次点击生成快照按钮,生成咱们的第四次快照
随后,咱们选中快照2,并将其上面的下拉框由默认的 Summary
选项切换为 comparison
选项,也就是对比当前快照与以前一次快照的内存区别,以下图:
咱们再来看看选择 Comparison
下拉后,下方的表格列表明着什么,这里介绍几个重要的
诶,到这咱们就有点那味儿了,咱们须要重点关注 Delta
,只要它是正数就可能存在问题,贴心的控制台都已经给咱们排好序了,最上面的几个咱们依次看就能够。
固然,咱们还须要知道这每一行的数据都表明的是什么,注意力转移到 Constructor
这一列,咱们也说过,此列是构造函数,每个构造函数点击均可以查看由该构造函数建立的全部对象,仍是要先介绍下此列中常见的构造函数大体表明什么
诶,又清晰了不少,那接下来咱们就能够依次对比 1->2 / 2->3 / 3->4
来看到底哪里有问题了。
别着急,想一下如今的咱们要怎么作?须要单独的点击一个快照再选中 comparison
,而后看 Delta
列为正数的项再进行分析,这样的操做须要进行 3 次,由于咱们有 4 个快照,须要对比分析 3 次,甚至有时候可能生成的快照更多以此来确保准确性。
有没有更简单的方式呢?有的,咱们能够直接选中要对比的快照,右侧表格上还有一个弹框咱们能够直接选择快照进行对比,而且还会贴心的为咱们过滤掉一些没用的信息:
咱们来进行实际操做,左侧选中快照2,选择 快照1
与 快照2
进行对比分析,结果以下:
能够看到,列表中只剩下对比过滤后的 4 项差别
system/Context
咱们无需关心。
closure
上面也说过表明闭包引用,咱们点击此项看一下具体的信息:
能够看到, closure
下有两个引用,还贴心的为咱们指出了在代码的 21
行,点击选中其中一个引用,下方还有更详细的信息展现。
为何展开后是两个引用?还记得咱们在生成 快照2
时的操做吗,手动执行了一次 GC 并点击了一次 click
按钮,触发了一次点击事件,点击事件中咱们执行并 push
了两次闭包函数,因此就是 2 条记录。
最后咱们看 array
,这里存在数组的引用是彻底由于咱们案例代码中那个全局数组变量 arr
的存在,毕竟每次点击都 push
数据呢,这也是咱们上面提到的为何要额外关注全局变量的使用、要将它及时清理什么的,就是由于像这种状况你不清理的话这些全局变量在页面关闭前就一直在内存里,可能你们对构造函数列中有 2 项都是数组有疑问,其实没毛病,一项表明的是 arr
自己,一项表明的是闭包内部引用的数组变量 test
(忘了的话回顾一下上面案例代码),这点也能够经过 Distance
列中表示的引用层级来 GET,一个层级是 7,一个层级是 8。至于数组引发泄漏的代码位置咱们也能够点击展开并选中其引用条目,详情里就能够看到代码位置,同上面闭包同样的操做,这里就不演示了。
诶,那好像就知道具体的泄漏源了,咱们再次证明一下,左侧选中快照4,选择 快照3
与快照4
进行对比分析,快照4 前咱们作的操做是手动执行了一次 GC 并点击了三次 click
按钮,若是上述结论正确的话,应该同咱们上面 快照1
和快照2
对比结果的数据项一致都是 4 项,可是每项的内部引用会是 6 条,由于此次快照前咱们点击了三次按钮,每次执行并 push
了两次闭包函数,来看结果:
嗯,到这里一切好像变得清晰明朗了,问题一共有 2 个,一是代码 21 行的闭包引用数组形成的内存泄漏,二是全局变量 arr
的元素不断增多形成的内存泄漏。
分析定位成功,进入下一步骤,修复并再次验证。
因为这是临时写的一个案例,没有具体的场景,因此也就没有办法使用针对性的方式来修复,So,此步骤暂时忽略,不过在项目中咱们仍是要解决的。
好比全局对象一直增大这个问题,全局对象咱们没法避免,可是能够限制一下全局对象的大小,根据场景能够超出就清理一部分。
好比闭包引用的问题,不让它引用,或者执行完置空,这都是上面说过的。
总之,一切都须要根据具体场景选择解决方案,解决以后重复上面排查流程看内存便可。
其实前端关于内存方面主要有三个问题,我把它们亲切的称做内存三大件:
内存泄漏
咱们说好久了,对象已经再也不使用但没有被回收,内存没有被释放,即内存泄漏,那想要避免就避免让无用数据还存在引用关系,也就是多注意咱们上面说的常见的几种内存泄漏的状况。
内存膨胀
即在短期内内存占用极速上升到达一个峰值,想要避免须要使用技术手段减小对内存的占用。
频繁 GC
同这个名字,就是 GC 执行的特别频繁,通常出如今频繁使用大的临时变量致使新生代空间被装满的速度极快,而每次新生代装满时就会触发 GC,频繁 GC 一样会致使页面卡顿,想要避免的话就不要搞太多的临时变量,由于临时变量不用了就会被回收,这和咱们内存泄漏中说避免使用全局变量冲突,其实,只要把握好其中的度,不太过度就 OK。
若是你的程序运行出现卡顿却找不到缘由,那不妨试试本文的排查方法吧,说不定就是内存泄漏引发的,同时,它也是咱们在作页面优化时须要额外注意的一个点。
今天就到这里了,你 GET 到了吗?欢迎指误堪错!写做不易,动动小手点个赞吧,收藏吃灰是大忌 👊
也欢迎关注 「硬核JS」 专栏,一文深刻介绍一个 JS 小知识,让你知道你不知道的 JavaScript!!!