手把手解决因为this指向丢失导致的内存泄漏问题

1,112

前几天项目组一个app内嵌h5页面在长时间使用后(半个小时),就会出现页面卡顿的情况。页面内元素的点击事件需要等待5秒多才能进行响应,在排除了原生app造成问题的情况之后我基本判断应该是前端自己的问题——内存泄漏。

缘由

前几天组内一个前端妹子火急火燎的跑了过来说,她所负责的一个app内嵌h5项目首页在持续使用了一段时间之后会变得异常卡顿。一个简单的页面跳转或者点击行为都会卡5秒多才会进行响应。

最终经过层层排查确认是内存泄漏造成的问题,出现的bug则是因为对于js中的this指向掌握不充分导致this指向丢失,出现问题的代码出现在这里:

  //h5调用原生 通过iframe urlScheme的方法
  callNativeByUrlScheme(eventName: string, data: any) {
    const url = this.getUrlScheme(eventName, data);
    const iframe = document.createElement('iframe');
    iframe.style.width = '1px';
    iframe.style.height = '1px';
    iframe.style.display = 'none';
    iframe.src = url;
    document.body.appendChild(iframe);
    setTimeout(iframe.remove, 100);
  }

在项目封装的jsBridge中,前端通知原生的交互方式是通过url scheme的形式来实现的(h5创建一个隐藏的iframe标签,原生通过劫持url的形式来进行通信处理)。这是项目中创建iframe所封装的一个函数,不知道你是否能发生发现这个隐晦的bug呢?

思路

在经过简单的排查之后,在发现v-console也是卡顿56秒之后基本确认是因为内存泄漏问题的造成的卡顿问题了。但是因为我们项目首页的逻辑异常沉重,涉及到大量的原生交互事件以及回调,而且也有大量的定时器进行轮询获取数据进行处理。**如何快速的定位是否内存泄漏造成的问题?如何快速的定位到是那一行代码造成的内存泄漏?**就成了现在的难题之一。

问题的解决办法:获取到当前项目的堆栈快照加以分析处理。在nodejs中我们可以通过node-heapdump这个库来获取nodejs的堆栈快照文件,在浏览器中我们可以通过chrome的开发者工具memory模块来获取当前页面的内存快照信息。

解决过程

接下来我会大致讲述是如何一步一步解决这个内存泄漏的问题的。

真机调试

因为需要调试的h5页面是app里内嵌的webview页面,依赖于native提供的能力,这种情况在pc上很难模拟所以需要使用真机调试方式。如果是比较独立的页面的话可以放到chrome浏览器中打开直接进行调试。

因为当前项目是内嵌在app里面的,涉及到一些加密以及鉴权的流程而且为了更好的模拟实际环境,我们决定放弃通过chrome打开页面而是直接使用真机调试的方式来解决bug。

因为Android webview已经帮我们开启了调试模式,为了方便直接使用android手机 + 电脑chrome浏览器的组合来方便真机调试。具体的调试方法可以参考阮一峰老师的这篇博客

打印内存快照

按上面的步骤连接完手机之后,为了确认是否内存泄漏的问题我们需要打印内存快照::

  1. 打开chrome开发者工具,并且切换到Memory模块。
  2. 选择Head snapshot模式并且点击Take heap snapshot按钮开始打印第一份快照,这时候得到了第一份快照Snapshot1

alt

  1. 等待一段时间或者手动触发点击一些事件,反正就是会触发内存泄漏的行为。
  2. 确定好合适的时机之后点击Collect garbage手动执行垃圾回收策略,再次点击Take heap snapshot按钮打印第二份内存快照

alt

得到两份快照之后,我们发现第二份快照的内存占用明显比第一份增加了很多,在手动执行了垃圾回收后占用的内存依然没被释放。至此确认:确实是内存泄漏问题造成的页面卡顿现象

alt

为了百分百确认,我们打了第三份快照。确认内存占用依然明显增加并且经过垃圾回收没有释放,至此问题停留在了——如何通过内存快照定位到具体的造成内存泄漏的代码

通过快照比较定位内存泄漏的代码

获取到了两份内存快照之后,chrome的memory模块提供了不同的快照类型查阅模式:

  1. Summary: 按构造函数名称分组显示, 此视图可以根据构造函数的分组类型深入了解对象的内存使用情况, 此视图特别适合查找DOM泄露
  2. Comparison: 显示两个快照之间的不同, 此视图可以比较两个或多个内存快照的差异, 检查某个操作前后的差异、检查已释放内存的变化额参考计数来确认内存泄露及其原因
  3. Containment: 从window对象的对象结构视图, 此视图可以分析闭包以及在较低级别深入了解应用的对象
  4. Dominators: 可以显示支配树, 对于查找聚焦点非常有用, 此视图query对象的意外引用已消失, 以及删除/垃圾回收正在运动(笔者的浏览器无此视图。)

在这里,我们使用Comparison模式,以快照2为基准对比快照1的内存变化差异:

alt

在进行对比中,我们发现内存增加最明显的两块地方system以及closure

system栏目看不出什么有效的信息,因此我们展开closure栏目,我们不难发现内存中增加了许多document元素节点,但是一般来说一个html页面一般来说应该只会出现一个document节点。

alt

突然间临机一动,通过iframe标签可以将另一个HTML页面嵌入到当前页面中,这样子就会在iframe上下文中也会创建对应的document节点。然后又联想到在和原生端进行通信的过程中我们是通过创建iframe的形式来进行通信的。猜测可能是用于原生通信的iframe标签在创建完成之后没有被清除仍然停留在dom树中导致无法被垃圾回收

为了验证上面的猜想,我们只需要在chrome的控制台中获取dom树中的iframe标签到底有多少个就可以了:

$$('iframe')

alt

全局搜索找到相关的创建iframe的代码,最终定位到这一个函数上:

  callNativeByUrlScheme(eventName: string, data: any) {
    const url = this.getUrlScheme(eventName, data);
    const iframe = document.createElement('iframe');
    iframe.style.width = '1px';
    iframe.style.height = '1px';
    iframe.style.display = 'none';
    iframe.src = url;
    document.body.appendChild(iframe);
    setTimeout(iframe.remove, 100);
  }

this指向丢失造成的内存泄漏

百思不得其解

从上可知,内存泄漏的原因出现于:用于跟原生通信交互而创建的iframe标签并没有从dom树中删除导致无法被v8进行垃圾回收。

回到这行代码上,iframe的节点上是通过iframe.remove方法进行删除的。这个方法在MDN上确认是没问题的:ChildNode.remove() 方法,把对象从它所属的 DOM 树中删除。

  callNativeByUrlScheme(eventName: string, data: any) {
    const url = this.getUrlScheme(eventName, data);
    const iframe = document.createElement('iframe');
    iframe.style.width = '1px';
    iframe.style.height = '1px';
    iframe.style.display = 'none';
    iframe.src = url;
    document.body.appendChild(iframe);
    setTimeout(iframe.remove, 100);
  }

柳暗花明又一村

确认api的使用是确实没有问题之后,我们把焦点聚集在了setTimeout上,是不是因为异步的问题导致remove方法调用失败, 于是乎我们尝试使用同步的方式来调用remove方法。

// 改动代码
document.body.appendChild(iframe);

//setTimeout(iframe.remove, 100);
// 改用同步的方式
iframe.remove()

改用同步之后重新打包。。。问题解决了!也没有再出现内存泄漏的问题,dom树中创建的iframe节点每次创建成功也被及时的清理了。WTF???? 难道ChildNode.remove() 不方法不支持异步调用?

alt

拨开云雾见光明

思考了一段时间,然后把目光聚焦在了变化的两端代码上发现了除了异步之外的另外一个差一点:setTimeout中传入的是remove的函数引用,在setTimeout中本质上是函数自调用,而同步的写法则是对象语法调用

// 函数自调用 
setTimeout(iframe.remove, 100);

// setTimeout大致模拟实现
//async function setTimeout(fn,delay) {
//    await delay()
//    fn &&fn()
//}

// 对象语法调用
iframe.remove()

熟悉this指向的同学们会清楚,在js语法中this的指向大概可以分为四种:

  1. 构造函数绑定(new):绑定到新创建的对象,注意:显示return函数或对象,返回值不是新创建的对象,而是显式返回的函数或对象。
  2. 显示绑定(call,apply,bind):严格模式下,绑定到指定的第一个参数。非严格模式下,null和undefined,指向全局对象(浏览器中是window),其余值指向被new Object()包装的对象。
  3. 隐性绑定(对象上的函数调用): 绑定到那个对象。
  4. 默认绑定(函数自调用):在严格模式下绑定到 undefined,否则绑定到全局对象。

setTimeout中传入的是remove的函数引用,在setTimeout中本质上是函数自调用,所以remove方法中的this指向的是undefined或者全局对象

iframe.remove()是对象调用所以this指向的是iframe元素本身

为此我找到MDN上的ChildNode.remove() profilly代码,确认了内部实现中确实使用到了this变量来指向元素节点:

//https://github.com/jserz/js_piece/blob/master/DOM/ChildNode/remove()/remove().md
(function (arr) {
   arr.forEach(function (item) {
    if (item.hasOwnProperty('remove')) {
      return;
    }
    Object.defineProperty(item, 'remove', {
      configurable: true,
      enumerable: true,
      writable: true,
      // 注意这里
      value: function remove() {
        this.parentNode.removeChild(this);
      }
    });
  });
})([Element.prototype, CharacterData.prototype, DocumentType.prototype]);

最终我们确定了内存泄漏发生的真正原因:this指向丢失造成创建的iframe元素无法从dom树中删除导致的内存泄漏

解决办法

在确认了问题的原因之后,解决问题的办法就容易多了:

  1. 改用对象函数调用形式 iframe.remove()或者setTimeout(() => iframe.remove(), 100);
  2. 通过显示绑定thissetTimeout(iframe.remove.bind(iframe), 100);

落幕

至此,一次从发现问题——查找问题——解决问题的链路式解决内存泄漏的旅程结束了。归根到底,出现这个bug的主要原因有两个:

  1. 对this的指向掌握不够充分
  2. 对一些内部的api具体实现没有具体的了解

内存泄漏出现不可怕,如何解决如何定位到问题的原因才可怕。感谢各位小伙伴们的查阅,希望这篇文章能给遇到内存泄漏困扰的同学们提供帮助啦!

参考资料

谷歌开发者文档

若川-面试官问:JS的this指向

阮一峰-远程调试 Android 设备网页