一、背景
桌面应用的前端场景不同于传统前端,具有使用者停留时间长,功能复杂且高度聚集在单一页面等特征,因此带来了不同的技术挑战,其中很重要的一点是内存泄漏问题。
1)什么是内存泄漏?
内存泄漏[1](Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。
2)JavaScript的内存管理
像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()。相反,JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理[2]。
3)案例
以携程的IM+项目为例:IM+将多种沟通渠道整合于一体,使客服人员能够全方位地触达用户,提供便捷、全面的服务,进而实现优质的用户体验。所以,在IM+的主页面当中,同时聚集了IM、电话和邮件三大块功能,为了提升坐席的效率和服务质量,还有众多辅助信息模块、回复超时提示模块,也就导致主页面功能非常复杂。
因此,主页面的功能复杂度、代码复杂度都很高,在大量需求的快速迭代期间,一些细节点考虑不够或者某些API使用方式不正确,就会比较容易发生内存泄漏问题。另外,又因为使用者长时间不关闭应用,一旦发生该问题,将会随着时间的推移,泄漏的内存量越积越多,最终影响整个电脑的资源使用情况,造成诸如应用崩溃、电脑卡顿等较为严重的后果。
综上所述,桌面应用的前端开发同学需要额外注意内存的问题,而这个场景在用户停留时间短、功能不重度集中的传统前端页面上基本不存在,所以网络上鲜有这个问题的处理方法。本文提出了一套完整的解决方案,包括:内存占用分析、内存的优化与验证、如何在功能迭代中维持低内存占用,以及线上的内存使用监控。
二、内存占用分析
在此提出两种内存占用分析方法,分别是使用谷歌浏览器的Memory插件分析方法和简单粗暴的单一变量实验分析法。
2.1 使用谷歌浏览器Memory插件分析内存占用
打开谷歌浏览器的调试页面,选择Memory Tab,然后点击Take snapshot获取内存快照,执行一段时间页面操作后,再次Take snapshot,然后对比,可以找到触发内存泄漏的组件(如下图)和独立的dom节点。
使用这个组件的时候,需要注意以下三点:
1)Network的请求、控制台里的日志也会占用Chrome的内存,所以在测试之前,最好把它们清理掉。
2)由于JavaScript的内存管理在语言之内,所以无法确定在获取内存快照之前是否有即将被释放掉的内存,这时可以点击Memory Tab左上角的垃圾回收按钮,手动触发一次垃圾回收,可以确保两次内存快照中都没有即将被清除掉的内存占用。
3)查找detached DOM节点
DOM节点的垃圾回收机制是:当页面的DOM树和JavaScript代码都没有对某个DOM节点的引用时,才可以对其进行垃圾回收。如果一个DOM节点已经被从DOM树中删除,但某些JavaScript变量仍引用该节点,则该节点被称为detached DOM节点,不会被回收。它是内存泄漏的常见原因。
在上图的Memory插件中,可以使用筛选器,输入关键字“Detached”查找分离的DOM树,然后点击DOM可以查看引用它的变量位置。找到之后,可以使用ES6的 WeakSet/WeakMap去解决这个问题。
2.2 二分法查找组件的内存泄漏
上面的方法虽然行之有效,但是对于极其复杂的项目,通过上述方法获取到的内存快照也极其复杂,比较难读,有的时候很难找到各个内存泄漏点,或者即便找到了内存泄漏的组件,也不清楚具体泄漏在了组件的哪一个功能点,哪一行代码上。所以针对这个问题,我们提出了二分法的思路。
首先,针对功能页面,整理总结出高频操作的功能列表,转换成自动化脚本,然后先执行脚本,记录内存占用。之后,在不影响主体功能的情况下,把组件分为两部分,轮流注释掉,分别执行脚本,记录内存占用。最后,对比两批组件的内存占用变化情况,判断内存泄漏主要集中在哪一批组件里。以此类推,可以在确定到组件之后,将二分法降级到功能维度,甚至代码维度,最终找到内存泄漏点。
在实际使用当中,我们综合这两种方法,逐步分块查找,最终解决了内存泄漏的问题。
三、内存优化与验证
3.1 内存的优化
1)可能导致内存泄漏的写法
i. 事件监听未正确移除:采用观察者模式,在组件内部注册监听,或是在一些DOM上注册事件后,需要在组件卸载生命周期中移除监听,否则可能造成内存泄漏。
ii. 组件初始化前/销毁后设置State:组件中存在异步调用,调用完成后触发状态设置,但是在调用完成前组件已销毁,就会产生内存泄漏(控制台会提示:Can’t perform a React state update on an unmounted component. Thisis a no-op, but it indicates a memory lead in your application.)。解决方案:在组件卸载声明周期中将setState置为空函数,或撤销异步调用。
iii. 组件的引用:比如我们的UI确认组件A 在使用完毕后,要释放对来自调用方组件B内部回调函数的引用,因为组件A跟B没有父子关系,所以使用完毕后如果没有释放引用,就会导致组件B不能被销毁,从而导致内存泄漏。
iv. 高频刷新功能集成在大组件中:一些高频刷新的功能,比如说时间显示,最好写在小组件里,不要放出来让它触发大组件的刷新,因为所有的内存泄漏都是积小成多的,如果有内存泄漏,刷新次数越多积攒越多,而大组件因为功能多逻辑复杂,容易内存泄漏,所以高频刷新的功能最好单独写成小组件。
v. 异常处理:未捕获的异常会造成内存泄漏,console.error也会。其实很好理解,异常随便什么时候开调试页面都能看到,就是因为存储在内存里了,所以我们要处理好异常逻辑。
2)React的shouldComponentUpdate生命周期和Immutable、PureRender:存在内存泄漏的时候,减少渲染次数也可以降低内存泄漏的影响。所以针对减少渲染次数的问题,在React框架下,可以采用这样几种方法:
首先,React的shouldComponentUpdate生命周期暴露了钩子,允许用户判断是否需要重新渲染;然后,Immutable可以支持在数据变化的情况下,基于字典序在新地址上复用原有的数据,减少内存占用;最后,PureRender则可以用浅比较自动计算shouldComponentUpdate的结果。
3.2 优化后的验证
1)通过功能埋点分析整理出主要的高频功能。
IM+使用了携程的前端埋点框架,可以分析各个DOM的点击情况,基于点击数据和对业务逻辑的理解,可以获知用户使用的高频功能。
2)基于Selenium实现主流程的自动化测试。
四、在功能迭代中维持低内存占用
1)制定避免内存泄漏的代码规范,在代码审核流程中予以检验。
2)每次发布版本前,长时间循环执行主流程自动化测试,对比测试前后的内存开销。
五、内存使用线上监控
1)调用系统api获取IM+进程的内存开销、总CPU开销、网络延迟等。
2)上报内存、CPU等信息,汇总到ES中。
3)在监控面板中,展示内存、CPU的占用情况。
通过上述优化步骤,IM+桌面应用的内存占用,从之前的随着使用时间快速增长,动辄占用数G,降低到了稳定不变的150M左右。