如何排查網頁在哪里發生了內存泄漏?

大家好,我是前端西瓜哥。
今天我們來學習用 devtool 的 Performance 和 Memory 工具來找出網頁哪里發生了內存泄漏。
Performace 面板
首先我們打開瀏覽器的 devtool,選擇 Performance(性能)面板,然后將 Memory 選項勾選上。不勾選的話,就不會記錄內存使用情況,內存泄漏分析就無從說起了。
然后進行性能數據收集:
- 點擊左上角的 “錄制” 按鈕(一個灰色的圓形),或者點它旁邊的 “刷新” 按鈕,會重新加載頁面并開始記錄,這樣就不用手動刷新然后手忙腳亂地點錄制按鈕了;
- 在頁面上執行可能發生內存泄漏的操作,比如打開一個彈窗,然后再關閉;
- 差不多了就再點擊 “錄制” 按鈕,結束錄制,然后出現下面圖片的結果。

查看內存指標
看看內存的使用情況。有這么幾步:
- 選中要分析的范圍;
- 選中 Main(主線程)。只有選中的話,內存圖表才能顯示主線程對應的信息;
- 查看內存圖表的指標。

內存圖表是一些折線圖,記錄了內存指標隨時間發生的變化。這些內存指標有:JS 堆內存、Document 數、節點數、綁定監聽器數量、GPU 內存。
點擊它們可顯示或隱藏對應的折線圖。
對于 JS Heap(11.9MB - 25.6MB) ,它表示的是在當前時間范圍內,JS 堆內存最小值為 11.9 MB,最大為 25.6 MB。
將光標懸停在折線圖上,可以看到對應的值:

查看內存下限的變化
內存會增長是正常的現象。比如我們調用函數,會創建一些臨時變量,導致內存升高。函數執行完,這些變量就沒用了,但不會馬上回收,而是會在適當的時機進行內存回收,將內存再降下去。
臨時分配的短命內存我們并不關心,我們更關注的是一些常駐的內存,對應的要看的是 內存下限的變化。

如果內存下限不斷上升,說明常駐內存變大了。大多數情況下是正常的,比如:
- 調用函數,將函數返回的結果進行緩存;
- 創建新的組件。
也可能是內存泄漏了。
當懷疑是內存泄漏時,我們就可以使用 Memory 面板記錄快照,做進一步的排查。
Memory 面板
打開 Memory 面板,點擊左上角的 “錄制按鈕”,生成當前時刻的堆內存快照。然后通過快照了解 JS 對象的內存分布

Summary View
快照結果默認會展示為 概要視圖(Summary View)。

這個表格的表格項是基于構造函數進行歸類的。可以看到有不少原生的構造函數,還有一堆閉包。
每個項有以下幾個屬性:
- Constructor:構造函數。對于沒有構造函數的字面量,用類似(string)? 、(array) 的表示。
- Distance:到根節點的最短路徑。
- Shallow Size:自己占用的內存大小,不包括它引入的其他對象內存,單位為字節。
- Retained Size:對象自己以及它引用的對象的內存,單位也是字節。
- Object Count:對象數量,就是 Constructor 名旁邊那個數字。
上面是默認的 Summary View 視圖。
除了它,我們還有其他的視圖,可以像下面這樣進行視圖類型的切換。

Comparison View
比較視圖(Comparison View)則是用來比較兩個快照的變化。

這里我選中了快照 3,然后將對比快照設置為 快照 1。
這個表格表示從快照 1 變成快照 3 發生的變化。沒有發生變化的項不會進行展示。
字段有:
- Constructor:構造函數。
- #New:新增的對象數量。
- #Deleted:刪除的對象數量。
- #Delta:總體上的對象變化數量。
- Alloc.Size:分配的總內存。
- Freed Size:釋放了多少內存。
- Size Delta:總體上的內存變化。
Containment View
該視圖可以讓我們從根節點為起點,往下去查看各種對象占用的內存,以及被創建的代碼位置等信息。

字段:
- Object:普通對象或者 DOM 節點:
- Distance:到根節點的距離。
- Shallow Size:對象大小,不計算引用的對象。
- Retained Size:對象大小,但其引用的對象大小也計算在內。
Statistics View
圓環統計表。
各種內存類型的占總內存的百分比情況。

使用 Memory 面板注意事項
盡量減少干擾項的影響力。
- 分辨正常的內存變化會的干擾。
- 注意開發環境的打包器熱加載邏輯等的影響。
- 生成環境的代碼是混淆過的,一些構造器名字很奇怪,如果可以的話,本地打包一份沒經過混淆過的代碼做 debug。或者也可以 hover 看看對象結構猜測對應構造器,但效率不高。
- 不要有瀏覽器插件,它們也占用和影響內存,可以用無痕瀏覽器。
常見內存泄漏原因和排查
忘記及時取消監聽器綁定
新手老鳥都容易犯的錯誤,就是 忘記及時取消監聽器綁定。它會導致:
- 監聽器函數中的對象遲遲不能釋放,比如非常大的組件實例。
- 綁定大量無用的監聽器函數。
怎么排查?
如果監聽器是綁定到 DOM 中,我們可以不斷執行可以看 Listener 數量的變化。
我寫了個彈窗組件,它會在掛載時給 document.body 注冊一個函數,然后這個函數會用到這個組件下的變量。但銷毀時不取消注冊。
打開 Performance 面板,錄制,然后不停打開和關閉彈窗,然后結束錄制。我們就能看這個 Listeners 的數量的變化,不斷地變高那就是忘了。

也可以看看 Memoery 面板中 Comparison View 的快照對比中,EventListener 數量的變化:

具體是哪個,可以看 EventListener 下的最后幾個對象。

點擊這個藍色的鏈接,就能跳到對應的代碼位置:

此外,還可以用 Chrome 控制臺提供的 getEventListeners(element) 方法,它會返回一個元素事件綁定的函數有哪些。這個方法不是標準方法,是 Chrome 自帶的工具方法,只能在控制臺上用。我們可以寫個方法,從根節點往下找,找出綁定函數數量最多的節點,這個節點多得離譜那就大概率是忘了解綁。
如果不是 DOM 上的監聽器,比如發布訂閱庫的事件集合,那就要看構造器對應對象數量的變化了。
閉包
閉包就是拿到函數 A 內的另一個函數 B,函數 B 會捕獲到函數 A 作用域中的變量。
這個就導致了對一些對象的隱式引用,比如一個 DOM 元素。我們需要在不需要使用時將其設置為 null。
我們可以看看有沒有什么 Detached 的元素。Detached 表示不在當前文檔樹上,如果持續增多,可能發生了內存泄漏。

說真的閉包是一個正常的特性,沒理由和內存泄漏有關才是。
函數 B 被持有不銷毀,自然它捕獲的函數 A 中的變量就不能銷毀,和對象里有一些屬性,這些屬性不能銷毀沒啥區別。函數 B 銷毀了,對應的變量自然也就回收了。
有空我再研究下寫篇專題。
console
“你到底都打印了些什么啊?”
還有個比較常見的就是,在開發的時候用 console 打印一些對象,合并到主分支又忘記去掉。這些對象是不會被回收的,因為開發者可能會去控制臺看看這些對象的內容。這在打印大量大對象時會出性能問題。
排查方法很簡單,去看 DevTool 的控制臺輸出了什么內容,看看有沒有大對象。
一些有助于 debug 的 console 是有必要的,但不要濫用。
集合類型的緩存爆炸
我們經常用對象、數組、Map、Set 等集合類型,去做數據的緩存。
當緩存大量對象時,會占用大量的內存,但其中有不少內容是不需要用的。對于前端來說,內存不像后端那樣純金寸土,動不動就是大批量數據要處理,緩存使用起來挺隨意的。
對于緩存問題,還要要有點意識,我們可以:
- 使用 LRU 算法,將最久沒使用的緩存移除,控制緩存數量;
- 設置緩存過期時間;
- 對于臨時緩存,考慮使用 WeakMap 和 WeakSet,它們會在 GC 時強制回收;
這些就沒啥好分析的,就看看內存下限變化,某些對象是否變大變多了。
結尾
今天帶大家簡單入門了 devtool 提供的內存分析工具,但光說不練假把式,還是要多多實戰。



























