本文為開源書籍《JavaScript 內存調試技巧與泄露分析》第二章節,本書採用「保持署名—非商用」創意共享4.0許可證。如有轉載,請註明作者和出處。

andycall/master-of-javascript-memory?

github.com圖標

內存泄露是開發過程中難以發現的一種bug。它經常會變得撲朔迷離,讓開發者抓狂。它並不像代碼異常那樣直接在控制台拋出來,而是靜靜的隱藏在你的代碼中,當你寫的代碼被更多的人使用的時候給予你致命的一擊。

那麼問題來了,我在開發一個應用的時候,該如何確定應用中存在內存泄露?

有的人認為,應用佔用內存很多就一定是內存泄露,這其實是一個錯誤的想法。軟體越複雜,佔用的內存也會越大,這是再也正常不過的事情了。任何系統的代碼,變數,函數都需要佔用內存。開發者有時為了加快軟體運行速度,還會在應用中大量利用緩存技術。緩存的使用,就會佔用一定量的內存,但是緩存和內存泄露的分界線是:緩存不會無限制的佔用內存,而內存泄露會。

雖然大家也都在說該如何寫代碼才能避免內存泄露,但是等你真正寫出內存泄露的時候,你根本毫無察覺。我線下測試好好的,不是嗎?

本節將介紹如何使用Chrome的devtools來發現內存泄露問題,能夠讓你在完成產品功能之後,立刻找出代碼中潛在的內存泄露。

難以發現的泄露

本demo的代碼都可以在github上進行獲取:獲取地址

假如現在有個基於Vue開發的SPA頁面,頁面有2個路由地址,可以自由進行切換。一個頁面只顯示一段文字,而另外一個頁面會實時獲取到瀏覽器框的寬和高。

在這個頁面上,有一個潛在的內存泄露問題——如果連續在Foo和Bar之間切換,內存會以肉眼不可見的形式增長。這乍一看貌似問題不大,不過如果我嘗試增大Foo頁面所佔用內存的大小的話,內存泄露的問題就會被放大。

救火最佳的時機就是在剛起火的時候來一盆水,那麼問題來了,對於這種難以發現的場景,該如何去發現和定位問題呢?

基於時間線的內存調試工具

Chrome devtools提供了一種可以基於時間線的內存調試工具——Allocation instrumentation on timeline。通過這樣的工具,就能很清晰的觀察當前內存的狀態。

打開devtools,選取Memory,然後再選中第二個選項,就可以進行基於時間線的錄製了。點擊Start按鈕,進入錄製模式。

這時候,使用滑鼠在頁面上反覆切換Go to FooGo to Bar,就可以觀察到調試工具不斷畫出了一些藍色的線條。

如果重複以上步奏,使用已經修復內存泄露的例子進行錄製的話,可以得到下圖的線條。

通過肉眼就能很明顯的對比出來,在2s之後,第二張圖幾乎看不到任何藍色的線條,大部分內容都是由灰色線條組成。而第一張圖,每過一段時間就有一小截藍色線條出現,藍色和灰色線條各佔一半。

在這樣的圖中,藍色的線條都代表當前進行了一些內存的分配,而灰色的線條代表GC已經成功將分配出去的內存回收了。所以從第一張圖中,我們就可以直接看出,每一次切換頁面之後,都有將近一半的內存收不回來。而這些收不回來的內存就是潛在的泄露。

分析某一時段的內存

現在可以確認第一個例子中,在切換頁面的過程中,內存發生了泄露,接下來,可以直接在圖表上,選取某一個沒有被回收的藍色線條,來作為分析的依據。

選擇之後,devtools會顯示出在當前時間段內,被分配出去的對象列表。這時,我們可以注意到,這裡有一個VueComponent對象,這說明在進行切換的時候,上一個頁面的Vue實例並沒有被釋放。

點擊 VueComponent @2275067, 就可以查看在內存中,其他對象和這個Vue對象之間的引用關係。

選中之後,上圖的Retainers下面就列舉出了所有引用這個Vue Component的對象。乍一看感覺挺複雜,這時候如果將這些對象都展開,就會在每一個對象的引用關係鏈中,找到一個來自於index.html的一個contextcontext在內存中一般是標識這是一個閉包,由一個函數來生成。

這樣就能說明,雖然有這麼多對象引用這個Vue Component,但是它們都被這個來自於index.html的一個函數創建的閉包所引用,這時候再順著右邊的鏈接跳轉到代碼,就能發現問題所在。

看似沒問題的事件監聽

確定了問題,再回來看頁面的代碼。Foo組件的代碼很簡短,除了渲染數據到頁面之外,只有一個監聽Window對象的事件監聽函數,並實時將數據同步到頁面上。

const Foo = {
template: `
<div>
<p>the window width: {{width}}px</p>
<p>the window height: {{height}}px</p>
</div>`,
data: function() {
return {
width: window.innerWidth,
height: window.innerHeight
}
},
mounted() {
window.addEventListener(resize, () => {
this.width = window.innerWidth;
this.height = window.innerHeight;
});
}
};

這乍一看確實沒有什麼問題,而且頁面確實能夠正常運行。不過問題恰好就出在這個事件監聽上了,在JavaScript中,事件監聽都會一直持有對監聽函數的引用,而這個函數恰好是一個箭頭函數,它的this指向正是Vue的實例,並且在箭頭函數內部,就有兩行調用this的代碼。因此就會形成一個鏈式的引用關係,一直延續到全局的Window對象,導致整個鏈條上所有的對象都不會被GC所釋放。從內存中也可以印證這一點:

不要忘記解除事件綁定

由於事件的綁定,讓Vue實例和Window對象之間形成了一條引用關係,從而導致Vue實例無法被釋放,因此解決這個問題的關鍵就是要解除這條引用關係。所以只需要在Vue實例即將要銷毀的時候,清空已經綁定的事件,就可以解除Vue 實例和Window之間的關係,從而能夠讓Vue 實例可以正常被釋放。

const Foo = {
template: `
<div>
<p>the window width: {{width}}px</p>
<p>the window height: {{height}}px</p>
</div>`,
data: function() {
return {
width: window.innerWidth,
height: window.innerHeight
}
},
mounted() {
this.resizeFunc = () => {
this.width = window.innerWidth;
this.height = window.innerHeight;
};
window.addEventListener(resize, this.resizeFunc);
},
beforeDestroy() {
window.removeEventListener(resize, this.resizeFunc);
this.resizeFunc = null;
}
};

推薦閱讀:

相关文章