在上篇文章中,提到了 GC 和 Memory Leaks,不過僅有介紹該名詞的意義,是比較碎片化的知識。所以在這篇文章將會更完整的介紹 JavaScript 的記憶體管理,了解它們背後的機制有助於我們去避免寫出 Memory Leaks 的程式碼。
JavaScript 這個語言的其中一個特性是會自動幫你做 GC,所以不用像其他語言還要做一些記憶體配置的處理。
而自動 GC 的背後是因為透過了 Mark-and-sweep algorithm 這個演算法去判斷是否要去回收記憶體,其中這個演算法涵蓋了 reference 的概念。
這個演算法會假設有一個 roots 的物件(在 JavaScript 中,根(roots)是全域物件 window),然後垃圾回收器會自動定期去遍歷所有被根物件參考的物件,遍歷結束後,垃圾回收器就會回收那些沒有被訪問到的物件。
例如以下圖片中,右側的三個物件因為都和 roots 物件,沒有直接或是間接的 reference,所以會被 GC。
另外補充一點,這裡的物件概念不單單是指 JS 物件(ex: {}),也包括原始型別、物件型別的變數值以及函式等。
以下用一段 記憶體管理(MDN 文件) 的程式碼舉例:
let o = {
a: { b: 2 }
};
// 1. 在上段程式碼中建立了兩個物件,a 和 b
// b 是物件 a 的一個屬性,同時也被 a 參考
// 物件 a 也被物件 o 參考
// 而 o 因為是全域變數,全域變數只有在離開頁面或重新載入時,才會將全域變數回收
let o2 = o;
o = 1;
// 2. 新宣告一個變數 o2,它參考的是 o 參考的物件, { a: { b: 2 } }
// 而原本的 o 被賦予另一個值,但它還是全域變數
let o3 = o2.a;
// 3. 新宣告一個變數 o3,參考的是物件 a, { b: 2 }
o2 = 'mozilla';
// 4. 現在 o2 變成 'mozilla'
// 原本應該沒有任何物件參考到 a ,因此它應該被回收
// 但 a 仍然被變數 o3 參考,因此它逃過被回收的命運
o3 = null;
// 5. 現在把 o3 變成 null,因為沒有任何物件與變數參考 a ,因此它可以被回收
在前面的內容對 GC 有更進一步理解後,那什麼時候可能會造成記憶體洩漏?讓我們來看看幾個案例。
當點擊按鈕時,會移除 childEle 節點,但由於全域變數 childEle 還是有 reference 到該節點,所以一樣不會被 GC。可以將 childEle 放入監聽事件的 callback 函式變成區域變數去解決。
// html
<div id="root">
<div class="childEle">I am child element</div>
<button>remove</button>
</div>
const btn = document.querySelector('button');
const childEle = document.querySelector('.childEle');
const root = document.querySelector('#root');
btn.addEventListener('click', function() {
root.removeChild(childEle);
})
Event Handlers + Arrow Function Memory Leak 範例
如果沒有使用 JS 的嚴格模式的話,在 fn 函式內會建立一個全域變數 empty,即使函式執行完畢還是不會清除變數 empty,導致 Memory Leaks,變數儲存的資料多時影響會較明顯。
可以加上嚴格模式去避免這種變數。
function fn() {
empty = new Array(10000);
// 瀏覽器環境等於 window.empty = new Array(1);
}
fn();
全域變數只有在離開頁面、重新載入,或者是將全域變數設定成 null,才會將全域變數回收,而在區塊作用域中宣告的變數則是區塊範圍結束後就被回收。
使用 Timer 的函式如 setTimeout、setInterval 也請記得使用 clearTimeout、clearInterval 去清除。
以下範例若 renderDiv 元素被移除時,整個 setInterval 內的函式是沒有作用了,且不會被回收。
const getData = loadData();
setInterval(function() {
const renderDiv = document.getElementById('renderDiv');
if(renderDiv) {
renderDiv.innerHTML = JSON.stringify(getData);
}
}, 5000); // 每 5 秒呼叫一次
另個範例中,即使 config 被重新賦值,setInterval 還是有 reference 到 config,所以會不斷間隔 1 秒印出 Alert。
let config = {
alert: setInterval(() => {
console.log('Alert!');
}, 1000),
};
config = null;
在閉包環境下的變數,有些情況也不會被回收,例如以下範例的狀況中,func 是全域變數,根據前面提到的 Mark-and-sweep algorithm 演算法,root 會參考到它而不被 GC。
而 func 變數接收到的是 innerFunc 這個函式,那這個函式內部所參考的 outVar 變數也不會被 GC。
function outterFunc() {
const outVar = 'outVar';
function innerFunc() {
const innerVar = 'innerFunc';
console.log(outVar);
console.log(innerVar);
}
return innerFunc;
}
const func = outterFunc();
func();
如果上面的範例最後改成 outterFunc()()
就不會有參考了。
// 原本
const func = outterFunc();
func();
// 調整後
outterFunc()();
我們可以使用 DevTools 的 Performance 頁籤記錄記憶體的使用情況,操作步驟如下:
此外可以點擊附圖的垃圾桶按鈕,在記錄效能的過程中按下按鈕會強制 GC,所以可以看到紅框 JS Heap 的部分突然有記憶體用量下跌,如果記憶體用量沒有回到執行前的量,就有可能是 Memory leak。
它可以用來即時的監測各種效能資訊,在我們點擊換頁一段時間後,會發現記憶體用量回到點擊前的量。
點擊 Ese 鍵可以打開下方包括 Console、Rendering、Performance monitor 的面板
另外也可以點擊 Memory 頁籤,選擇 Heap snapshot 按鈕去檢視記憶體用量。
從名字就能了解,它可以用來產生 Memory Heap 使用量的快照,所以我們可以進行多次可能會發生 Memory Leak 的動作並產生多個快照進行比對。
JavaScript's Memory Management Explained
The Secrets of Memory Leaks in JavaScript You Don’t Know
avoid memory leaks with closures. google javascript style guide.