内存泄漏
关于Javascript内存的定义可以参考Javascript内存管理
什么是内存泄漏?
内存泄漏可以简单描述为应该被回收的内存空间因为某些原因而回收不掉。
内存泄漏是大型系统中比较容易出现的问题,原因如下:
- 代码本身没有做好处理
- 相关依赖库没有做好处理
- 浏览器bug
不过日常开发中,最有可能导致问题的还是我们自身代码没有做好处理,如果是成熟的库,不太可能出现内存问题。
分析工具
分析定位泄漏点,一般使用Chrome的memory面板来进行分析。
分析步骤
准备
在分析之前,我们必须要知道在页面做了什么操作以后会导致泄漏,比如点了某个按钮。
一般我们会有俩种分析方式:
- 录制泄漏前的快照,再录制泄漏后的快照,进行对比找到泄漏原因
- 在复现泄漏快照的过程中,使用timeline追踪回收时机找到泄漏原因
一般都是用第二种来判断是否有内存问题,然后用第一种方式来找到内存泄漏的原因。
在录制之前,有一点非常重要,录制环境。
- 避免开发环境的热更新造成的影响,因为GCRoots以及hot_map缓存的影响,我们在查看引用链时可能会链接到hot_map上,所以我们需要打包,然后本地起一个nginx,进行分析
- 避免缓存以及其他浏览器插件的影响,可以使用Chrome的无痕模式
- 避免无关变量,比如你已经知道是某个弹框中的一个文件控件有问题,那么在录制时,弹框中应该只保留该文件控件。因为其他控件保留着,在后面我们查看快照时会造成干扰
- 保持录制期间内存相对较小,最好控制在80M之内,太大了的话,录制开销会很大,影响效率
这里只描述对比分析的步骤。
对比分析
对比分析的步骤如下:
-
在录制之前先点击左侧面板的垃圾桶按钮,强制清除一次,然后点击TakeSnapshot录制一次泄漏前的快照
-
复现泄漏操作
-
再点击一次清除按钮
-
这个时候需要注意,有些变量,比如dom,在使用完后没有置为null的话可能会回收的慢一点,这与浏览器有关,所以可以等几秒再点击。
-
点击录制按钮,记录泄漏后的快照
如下图:
现在我们有俩份快照,点击第二个快照,然后查看方式选择comparison(对比),一般会去搜索Detached HTMLDivElement,意思是已分离的div元素。因为发生泄漏后,dom也会因为被泄漏源引用而回收不掉,但因为已经从当前Document上删除了,所以是已分离的。选择Div是因为div一般作为容器元素使用,而且使用频次较多,所以查看该属性会比较方便。
右侧会有一些信息,比如New、Deleted、Delta包括内存大小,这些信息主要还是帮助我们判断内存有没有泄漏,如果Delta(净增)大于0,那么很有可能发生泄漏了。
将分离的dom列表展开以后,可以去查看每一个项,如下图:
从上到下是一个引用链,需要注意的是,引用链最下方不一定是问题的核心点,这是因为GCRoots导致的,我们需要做的是在这条链上找到有用的信息,主要是以下俩点:
-
native_bind()
- 鼠标放上去会提示函数名,我们可以根据函数名去定位函数位置,需要注意的是该函数不一定是问题源,可以去查看一下该函数有没有问题、该函数的调用上下文,或者该函数是不是被事件所绑定的。如果确认该函数没有问题,我们可以尝试把函数内容给注释掉,然后再录制一遍,看看有没有其他的线索。
-
InternalNode
-
内部节点,可能是原生事件或者浏览器的一些属性,这个鼠标放上去是不会有提示的,需要编译特殊版本的Chrome才可以查看。遇到这个提示,大概可以确定是事件绑定出了问题。
-
去查看引用链的时候,每一层都可以把鼠标放上去,看看有没有什么提示,比如已分离元素的详情。在定位问题的时候,关注点尽量放在上层。
关于Memory面板的术语不在这里介绍,可以点此链接详细了解。
写代码时需要注意泄漏的点
一、全局变量引用
这种问题发生的频率一般较小, 我觉得应该没人把全局变量绑定到组件上,或者使用变量没有声明。
需要注意的是,要尽可能少地声明全局变量,因为GCRoots的原因,很容易会在查看引用链的时候链接到window上的属性,造成干扰。
二、定时器未清除
如果你不确定是不是定时器导致的问题,可以打开【开发者工具】,在Chrome中会有警告
所以养成好习惯,setTimeout使用一定要清除
const timer = setTimeOut(() => {
this.resize()
clearTimeOut(timer)
})
复制代码
setInterval也是一样的处理,不过不太建议在代码里使用setInterval,主要是不太好控制和错误捕获不到。
在平时开发时,可以留意下控制台里面Chrome发出的warning,去看看背后的原因是什么,一般和性能都是有关系的,这样会让你知道怎么样写代码对于浏览器来说是没有问题的。
三、事件未清除
这也是很常见的问题,养成好习惯。
// mounted
window.addEventListener('resize', this.resize)
// beforeDestory
widnow.removeEventListener('resize', this.resize)
复制代码
这里需要注意最好不要用on的方式去绑定事件,覆盖事件以及解绑处理起来会很麻烦。
另外,绑定事件的参数也需要注意,在解绑的时候对应上。事件参数链接
还有一点需要注意的是关于Passive参数的,Passive参数设置为true,该事件监听器会标记为被动监听器,表示会调用preventDefault函数来阻止事件的默认行为,Chrome会根据Passive参数来优化页面流畅程度。了解详情请参考该链接
如果你的控制台出现了如下警告
那么就表示你需要添加Passive参数来阻止一些事件的默认行为。
我之前在处理一次内存泄漏时就是这个问题导致的,猜测是控制台引用了一些数据导致回收不掉,不管什么原因,这种警告要尽量清除掉。
四、console
生成环境最好不要有console.log,如果有,最好是文字解释或者格式化后的JSON字符串,不要引用当前实例的数据。
五、未添加到Document的dom
function download(url) {
const a = document.createElement('a')
a.href = url
a.click()
}
如果不手动将a置为null,那么该dom会一直造成泄漏
复制代码
六、使用一些插件时,未销毁由该插件生成的dom
比较常用的如Sortable
const sortableInstance = Sortable.create(……)
sortableInstance.destroy()
复制代码
另外我们自己写的一些函数,如果生成了dom,或者引用了dom,都要记得销毁。
let loading = this.$loading({
……,
target: this.$refs.table.el
})
……
loading.close()
loading = null
复制代码
结语
内存泄漏处理起来是非常麻烦的,需要大量的验证操作,所以修改代码时最好提交一个commit,到最后确认没有泄漏时,再退回去一个一个看,该怎么解决怎么解决,基本上都是上面列举的点。
如果遇到其他问题导致的,比如浏览器导致的,虽然几率比较小,但是也有可能发送,比如MouseEvent在Chrome上的bug。
整个过程最痛苦的阶段在于根据快照的反馈来定位问题,你可能会处于持续的自我怀疑,甚至有可能自闭。不过这也取决与你处理相关问题的经验,处理的多了,就会知道哪些是核心问题。
我觉得一个开发,是否解决过内存问题是一个很不同的点,它可以印证很多东西,比如做事的思路等等,而且这件事情本身也是一个整体性的,需要你对整个体系的知识有一定的理解。
最后,文中有不对的点或者有疑问的地方,可以在评论区指出。感谢^_^