手把手解決由於this指向丟失致使的內存泄漏問題

前幾天項目組一個app內嵌h5頁面在長時間使用後(半個小時),就會出現頁面卡頓的狀況。頁面內元素的點擊事件須要等待5秒多才能進行響應,在排除了原生app形成問題的狀況以後我基本判斷應該是前端本身的問題——內存泄漏。html

原因

前幾天組內一個前端妹子火急火燎的跑了過來講,她所負責的一個app內嵌h5項目首頁在持續使用了一段時間以後會變得異常卡頓。一個簡單的頁面跳轉或者點擊行爲都會卡5秒多才會進行響應。前端

最終通過層層排查確認是內存泄漏形成的問題,出現的bug則是由於對於js中的this指向掌握不充分致使this指向丟失,出現問題的代碼出如今這裏:node

//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呢?android

思路

在通過簡單的排查以後,在發現v-console也是卡頓56秒以後基本確認是由於內存泄漏問題的形成的卡頓問題了。可是由於咱們項目首頁的邏輯異常沉重,涉及到大量的原生交互事件以及回調,並且也有大量的定時器進行輪詢獲取數據進行處理。**如何快速的定位是否內存泄漏形成的問題?如何快速的定位到是那一行代碼形成的內存泄漏?**就成了如今的難題之一。git

問題的解決辦法:獲取到當前項目的堆棧快照加以分析處理。在nodejs中咱們能夠經過node-heapdump這個庫來獲取nodejs的堆棧快照文件,在瀏覽器中咱們能夠經過chrome的開發者工具memory模塊來獲取當前頁面的內存快照信息。github

解決過程

接下來我會大體講述是如何一步一步解決這個內存泄漏的問題的。web

真機調試

由於須要調試的h5頁面是app裏內嵌的webview頁面,依賴於native提供的能力,這種狀況在pc上很難模擬因此須要使用真機調試方式。若是是比較獨立的頁面的話能夠放到chrome瀏覽器中打開直接進行調試。面試

由於當前項目是內嵌在app裏面的,涉及到一些加密以及鑑權的流程並且爲了更好的模擬實際環境,咱們決定放棄經過chrome打開頁面而是直接使用真機調試的方式來解決bug。chrome

由於Android webview已經幫咱們開啓了調試模式,爲了方便直接使用android手機 + 電腦chrome瀏覽器的組合來方便真機調試。具體的調試方法能夠參考阮一峯老師的這篇博客api

打印內存快照

按上面的步驟鏈接完手機以後,爲了確認是否內存泄漏的問題咱們須要打印內存快照::

  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 設備網頁

相關文章
相關標籤/搜索