内存泄露与事件移除的必要性

1. 问题

在实际项目中"有幸"遇到一次内存泄露的问题,事情起源于实时数据的世界地图

该地图展示实时的订单数据,当某城市产生订单时就会在该城市的位置产生动画。实时展示数据其实是每一分钟拉取接口数据然后做动画。

有一天,领导突然给我发了张图:

DOM Nodes137513, CPU使用率为 98.6%,领导告诉我页面有内存泄露,要处理一下。

2. 排查问题

先给个链接看下 Chrome 官方的 解决内存问题。里面提到了 dom nodes: 只有页面的 DOM 树或 JavaScript 代码不再引用 DOM 节点时,DOM 节点才会被作为垃圾进行回收。 如果某个节点已从 DOM 树移除,但某些 JavaScript 仍然引用它,我们称此节点为“已分离”。已分离的 DOM 节点是内存泄漏的常见原因。

第一反应是实时地图页面的问题: 首先这个页面是前一天加上的,第二天就报内存泄漏了。其次原先的百度 Echarts 迁徙地图,动画的自定义性不强,查询资料之后发现 D3.js 可以实现自定义动画,于是使用 这种方式来做动画,还依赖了 snapeve,在使用中已经发现在切换页面的时候,body下面会自动 append 不定数量的 svg 元素。当时非常 hack 的在切换页面的时候判断是否有多余的 svg 元素,有则remove掉。看来可能是实时地图的锅。

然后开始对 snapeve 进行调试,慢慢的找到了一点点头绪,在引入 snapeve 的时候,snapeve 会执行一些初始化代码,eve负责自定义事件。定时(200ms)执行一次 动画代码 doAnimation 会产生svg元素作为动画元素,而在切换页面时,doAnimation会因为eve中的事件,继续执行一会,导致产生若干个 svg 元素。

那想想是不是可以在组件willUnmount 的时候关闭 eve 的事件?果然,有个eve.off()函数,加上之后从实时地图页面 切换到 B页面 不会产生多余的 svg 元素了。 但是当我再次切回 实时地图页面 时发现动画也不动了。调试之后发现,当使用 eve.off() 之后,原本引入evesnap 组件时候的初始化代码 (包含设置eve的事件) 设置的事件,也被移除了。 那怎么在页面切回的时候再次重新引入 snapeve 完成事件的设置呢?

后面我就没研究了snap和eve了,因为很快就发现内存泄露的关键不是这个,算是先留了个坑,以后有机会再填上

后来发现,即使我把实时地图页面移除,依然会在点击菜单来回切换各个页面的时候增加 dom nodes的数量,并且不会减少 (不会减少是关键,后面会提到)。于是按照 chrome 推荐的方法进行 抓取快照, 分析,发现并不能找出问题...,可能是我的使用方式不对(再吐槽下那 React的性能优化涉及到DevTools Timeline 分析,我也没用成功...),但是就是没帮助我排查出问题。然后我把这情况告诉同事请求帮助了。

我投降,以下是同事的排查过程

  1. 回退代码: 把 git 代码回退 -1,测试,回退 -2,测试... 结果发现到很久之前的某一个版本是没有问题的,那么肯定是后续有一些代码导致的。

  2. 注释代码: 现在在A,B页面切换会产生dom nodes增加,不减少的情况。那么就依次注释A,B页面的代码。在这过程中,发现一个很重要的元素: 事件!,发现注释掉组件关于事件监听的代码,就恢复正常了。

3. 解决

在组件的 ComponentWillUnmount 中移除 addEventListenr 添加的事件,同时把涉及到监听的代码都在组件移除时销毁。

示例

constructor() {
    this.redraw = this.redraw.bind(this)  // 先 bind,把处理函数绑定在this上
    this.visibilityChange = this.visibilityChange.bind(this)
}
componentDidMount () {
    // 用this上的处理函数,保证可以被移除,不能使用匿名函数
    // visibilitychange: 浏览器标签页被隐藏或显示的时候会触发visibilitychange事件
    // 监听visibilitychange是因为定时器会在浏览器切换标签页时减缓非当前页的定时器以提升性能。
    document.addEventListener('visibilitychange', this.visibilityChange)

    window.addEventListener('resize', this.redraw)

    // 还有监听原生是否 "可视" 的代码
    this.observer = new window.IntersectionObserver(entries => {
        const {intersectionRatio} = (entries[0] || {})
        if (!intersectionObserverMark && intersectionRatio === 0) {
            intersectionObserverMark = true
       }, {
         threshold: [0, 1]
       })
       this.observerTarget = document.querySelector('.chart-ins')
       this.observer.observe(this.observerTarget)
    }
}

visibilityChange () {
    const { visibilityState } = document
    if (visibilityState === 'hidden') {
      // 标签页隐藏时
    } else if (visibilityState === 'visible') {
      // 进入标签页时
    }
}

componentWillUnmount() {
    document.removeEventListener('visibilitychange', this.visibilityChange)

    if (this.observer && this.observerTarget) {
      this.observer.unobserve(this.observerTarget)
    }

    window.removeEventListener('resize', this.redraw)
}

4. 结果

点开 Performance monitor,点击 Memory 旁边的 collect garbage按钮,即可查看到dom nodes数量下降。 collect garbage 是强制进行垃圾回收,即使你不点击该按钮(就默默等待浏览器一会),在浏览器下一次垃圾回收时,你也会看到 dom nodes 下降,点击它只为快速看到效果。

提示:一种比较好的做法是使用强制垃圾回收开始和结束记录。 在记录时点击 Collect garbage 按钮 (强制垃圾回收按钮) 可以强制进行垃圾回收。

以上这句提示还是 chrome 的那篇文章的内容。因此,如果你点击 collect garbage 之后,performance数据没有发生变化(不减少!!!),那么说明依旧有内存泄露问题!

5. 总结

  • 对 Chrome DevTools 的认识还是不够,Timeline,性能的一些调试的还没能结合实际情况理解。

  • 要学会如何排查bug,自己遗留的坑如果出现问题要自己填回去(暂时snap,eve那一块是好的,hack就先hack吧)。

  • 其实切换页面时不光dom nodes数量增加,还有JS event listeners 数量增加,这是不是已经能说明是事件的问题?!

感谢我那些无所不知的同事们。

Last updated