阅读 244

[外文翻译系列] 修复Web应用程序中的内存泄漏

原文地址: nolanlawson.com/2020/02/19/…

翻译前言

正值规划公司app的H5性能优化提升方案,网上有一篇关于内存分析的文章,翻译出来,大家共享。

正文

  当我们把web应用从服务端渲染变成了单页面(SPA)的时候,我们突然发现我们需要去关心ui的阻塞、 电脑的风扇旋转速率、 手机的耗电程度等等一系列的用户设备问题,而这些问题并不存在或者很少存在于服务端渲染的时代,是的,我们成功的在提升用户交互的同时又创造了其他的问题。

  这些问题的其中一个原因就是 内存泄漏, 一些代码质量较差的SPA很容易消耗MB甚至GB的内存,甚至不断的占用资源,即使应用切换到后台也是如此,最终整个应用会变的卡顿,甚至是页面崩溃.

broken

当然,ssr渲染的网站也可能会造成服务器的内存泄漏。但是,极不可能造成客户端的内存泄漏,因为每次在页面之间切换时时候,浏览器都会清除之前页面的内存。

其实,很少有web相关的文章来讲述一些内存泄漏的问题,然而,我确信大部分的SPA应用都会造成内存泄漏,除非这个团队有足够的基础设施来捕获并修复这些问题,在javascript中,很容易意外的分配一些内存但是没有及时的释放它。

那么,为什么关于web内存泄漏的文章少之又少,我猜测的原因有下面几点:

  1. 缺少用户反馈: 大多数用户都不会在使用应用的时候去查看任务管理器的,除非是应用崩溃或者是明显的卡顿,否则,你是不可能收到用户的反馈的。
  2. 缺少数据: Chrome团队并没有提供网站使用内存的数据,应用本身也不会去收集这些信息。
  3. 缺少工具: 目前的工具很难去发现和修复内存泄漏问题。
  4. 用户缺少关注: 由于浏览器很擅长杀死占用过多内存的页面,因此用户常常把页面问题归咎于浏览器而非应用本身。

在这篇文章中,我会分享一些我在解决Web应用程序中的内存泄漏方面的经验,并提供一些示例来说明如何有效地跟踪它们。

内存泄漏剖析

现在大部分的前端框架比如:React、Vue、Svelte都是基于组件这种范式来开发的,在这种模式下,常见的内存泄漏方式是这样的:

window.addEventListener('message', this.onMessage.bind(this));
复制代码

这样就引入了内存泄漏的风险,如果你在组件调用 addEventListener 一些全局对象(比如window. 或者body标签),并且忘记使用 removeEventListener 来销毁,当组件被卸载时,这些事件仍保存在内存中,这样就创造了一个内存泄漏。

更糟糕的是,由于this.onMessage绑定到了this,你在泄漏这个组件的同时, 这个组件的子组件以及相关的dom都会被泄漏出去,子组件有它的子组件,周而复始,很快就变的很糟糕了, 正确的解决办法是:

// Mount phase
this.onMessage = this.onMessage.bind(this);
window.addEventListener('message', this.onMessage);
 
// Unmount phase
window.removeEventListener('message', this.onMessage);
复制代码

其中需要注意的是,我们保存了函数的绑定来保证你传递到__addEventListener__ 和 removeEventListener 中的函数必须是一致的。

内存泄漏情况

在我的使用经验中,下面的api是比较容易造成内存泄漏的:

  1. addEventListener 就是其中常见的,需要使用 emoveEventListener 来清除。
  2. setTimeout / setInterval 如果使用定时器来循环调用方法的时候,需要使用 __clearTimeout/ clearInterval__来清空定时器,注意setTimeout如果模拟setInterval循环调用也是会造成内存泄漏的。
  3. IntersectionObserver, ResizeObserver, MutationObserver, 这些新的事件监听api方便了开发,但要注意的是,最后还是要使用disconnect来取消监听。
  4. 像Promise, rxjs的Observables, node的EventEmitters这些方法,如果没有回调或者取消监听,也会造成内存泄漏(Promise如果没有resolved或者rejected,会连同then()中的代码一起造成泄漏)。
  5. 像redux这种挂载在全局的状态管理,如果不注意内存占用就会持续增加不会被清理。
  6. 如果在没有虚拟dom的计算情况下实现了无限滚动列表,那么DOM节点的数量将无限增加。

当然,还有其他的方式导致内存泄漏,但是这些是最常见的。

找出内存泄漏

这是最困难的部分,首先我想说明的是我认为没有什么工具能很好的解决这个问题,我已经尝试了Firefox的内存工具,Edge和IE的内存工具,甚至是Windows Performance Analyzer,相比之下,还是Chrome的DevTools更好用。

打开开发者工具,选择Memory标签的heap snapshot选项,在Chrome中有其他的内存工具,单我发现这些对找出内存泄漏没有很大的帮助。

当你点击“Take snapshot” 按钮的时候,你就能捕获到javaScript VM 中包括window和定时器回调引用的对象环境,可以把这个快照看成那一瞬间的内存静止状态。

接下来的操作就是重现一些你认为会造成内存泄漏的场景-比如对话框模态层的开关,当对话框关闭的时候,你的期望应该是内存前后一致,因此,我们需要拍摄另一个快照,然后将其与没有打开对话框的快照进行比较。不得不说,这正是很棒的功能。

当然,你需要清楚这个工具的一些限制:

  1. 你需要多生成几个快照(我的经验是三个)来确保内存真正的稳定的大小(检查每个快照,你应该能拿到最终的稳定状态)。
  2. 如果你的程序中有web workers, service workers, iframes, shared workers这些特殊的环境,Memory是无法为这些提供快照功能的,因为这些环境有自己的javaScript VM,你需要明确你要分析的是哪个,然后选择对应的进行快照
  3. 有时候快照工具会卡顿甚至是崩溃,你只需要重启即可。

消除干扰

  1. 我发现最好解决干扰因素的方法是多次复现场景,比如你可以切换弹窗7次(7是比较明显的数值,或者14、21)来代替一次,这样,你就能发现哪些对象是7次都有出现内存泄漏的情况了。(另外一点有用的使用技术是整体运行应用一次,尤其是当你使用代码拆分来异步加载代码的时候,因为加载这些代码需要消耗一定的内存,我们需要排除这种因素)。
    上面的快照是3和6的对比,中间的快照是为了让垃圾回收的更彻底, 注意有些对象泄漏了7次

此时,你可能会想知道为什么我们要按对象数量来排序而不是内存占用总量,直观的来说,我们正在尝试减少内存泄漏的数量,所以,我们难道不应该更关注内存占用总量吗,原因下面会说到。

一篇来自Joe Armstrong的文章《你只是想要一个香蕉,但你却得到一只拿着香蕉的大猩猩》向我们说明了,你想要某一部分,却得到了整体,这样并不正确和可靠。让我们回到上面addEventListener这个示例来说明,泄漏的来源是事件监听器,该事件监听器引用一个函数,这个函数又引用一个组件,该组件可能引用大量的东西,比如数组,字符串和其他对象。如果你按整个内存占用量来排序,那么你会找到一堆数组,字符串和对象,这其中大多数可能与泄漏宿主无关,你真正想要找到的是事件监听器,但是与它所引用的内容相比,它占用的内存很小。要修复泄漏,你想要的是香蕉,而不是丛林(意思是找到直接对象,而不是其他无关的东西,避免干扰)。

因此,我们按泄漏的数量来排序,我们会看到7个事件监听器,7个组件,亦或是14个子组件等等,7是个不同寻常的数值,因此会很突出。而且,无论你复现该场景多少次,你都应该做到确切看到泄漏的对象数量。这样可以快速找到泄漏源。

使用固定链

快照工具提供了一种固定链来显示哪些对象指向哪些其他对象,从而使内存保持活动状态,这样你就能清楚泄漏对象的分配位置

固定链会显示哪个对象正在引用泄漏的对象。查看的方式就是上面的对象是由下面的对象引用的 在上面的图片中, 一个由于闭包(环境上下文)所引用的变量 someObject ,被onMessage的监听器引用着,如果你点击旁边的资源链接,你就会跳到改对象位置,很easy

class SomeObject () { /* ... */ }
 
const someObject = new SomeObject();
const onMessage = () => { /* ... */ };
window.addEventListener('message', onMessage);
复制代码

在上面的示例中,onMessage这个函数利用闭包引用someObject变量。(这是一个人为的示例;实际内存泄漏可能不那么明显!)

但是快照工具在固定链上有几个限制:

  1. 如果你通过导出内堆文件然后导入查看,你将丢失对分配对象位置引用的所有文件,所以保存文件没什么卵用。
  2. 如果你使用了WeakMap,chrome会展示这些引用,即使这些对象没有问题,因为其他引用被清理了,WeakMap也会消失,所以这是个干扰项。
  3. chrome是通过原型来分类这些对象的,因此尽量多的使用具名类和函数而少用匿名类和函数,有助于查找真正的泄漏问题,举例来说,假设我们的泄漏是由object而不是监听器引起的。由于object具有极高的通用性,我们可能看不到其中有7个泄漏。

这是我识别内存泄漏的基本策略,在过去我已经成功地使用了这种技术来发现许多内存泄漏。当然,这只是个开始,除此之外,你仍需要打点,记录日志,然后尝试修复这些泄漏,这无疑是一个费时费力的过程。

内存泄漏分析自动化

在这之前,我想说明的是并没有一个好的方式来实现自动化,chrome有一个非标准的api - performance.memory ,但出于用户隐私原因,它并不是很准确,因此无法作为生产环境使用,W3C Web性能工作组也曾讨论内存分析的方案,但尚未达成一致。

在实验室环境中,您可以使用Chrome标志 --enable-precise-memory-info 来增加此API的精确度。你还可以通过调用专有的Chromedriver命令来创建内存快照文件:takeHeapSnapshot,但尽管如此,还是会存在上面的限制。

由于事件侦听器是最常见的内存泄漏源,因此我使用的另一种技术就是对addEventListener和removeEventListenerAPI进行monkey-patch(猴子补丁)修复,通过引用计数来确保它们最终返回的是零。有个示例供大家参考

在Chrome DevTools中,你还可以使用getEventListeners()这个方法来查看附加到特定元素的事件监听器。需要注意的是这只能在DevTools中使用。

有更新:

Mathias Bynens向我介绍了另一个有用的DevTools API:queryObjects(),它可以向你显示使用特定构造函数创建的所有对象。Christoph Guttandin也有一篇有趣的博客文章,讲的是如何在Puppeteer中使用此API进行自动内存泄漏检测的。

摘要

目前在web应用中查找并修复内存泄漏仍然是很初级的阶段,这篇文章展示了一些对我比较有帮助的技术点,但是这仍旧是既费时又耗力的过程。

和大部分的性能问题一样,这种做法是值得的,你会发现,做一些上线前的综合测试要比等出现了问题才开始调试要值得的多,尤其是当页面上有多个泄漏,则可能会变成洋葱剥皮练习-当你修复一个泄漏,然后开始查找另一个泄漏,然后重复(整个过程可以虐你到哭)。当然,code review还是可以帮助你发现一些内存泄漏的常见模式。

JavaScript本质上是一种内存安全的语言,但是讽刺的是我们可以轻而易举的造成内存泄漏。这一部分的原因还是要归咎于UI交互设计,我们由于业务逻辑会监听用户的鼠标,滚轮,键盘等等的交互行为,这些直接埋下了内存泄漏的隐患。但是,通过尝试降低Web应用的内存使用量,我们可以提高运行时性能,避免崩溃,充分发挥用户设备性能。

感谢Jake Archibald和Yang Guo对本文的意见反馈。感谢Dinko Bajric发明了“选择质数”方法,该方法对内存泄漏分析有很大帮助。

翻译感悟后记

其实,对于内存的分析不仅仅是找出内存泄漏,更应该发现由于内存占用问题导致的用户体验问题,现在市面上很多app都是hybrid的,h5页面占了大头,如果容器没有做好容错机制,h5的崩溃很容易造成容器的出错, 由于手机客户端的性能瓶颈,无法达到pc端的性能,因此做好性能优化也极为重要,当然这个话题的宽泛超出了内存分析,但我想说的是内存分析也是性能优化的重要一环,有条件的可以看看godp