JavaScript中4種常見的記憶體洩漏及避免方法
垃圾回收演算法
常用垃圾回收演算法叫做**標記清除 (Mark-and-sweep) **,演算法由以下幾步組成:
-
1、垃圾回收器建立了一個“roots”列表。roots 通常是程式碼中全域性變數的引用。JavaScript 中,“window” 物件是一個全域性變數,被當作 root 。window 物件總是存在,因此垃圾回收器可以檢查它和它的所有子物件是否存在(即不是垃圾);
-
2、所有的 roots 被檢查和標記為啟用(即不是垃圾)。所有的子物件也被遞迴地檢查。從 root 開始的所有物件如果是可達的,它就不被當作垃圾。
-
3、所有未被標記的記憶體會被當做垃圾,收集器現在可以釋放記憶體,歸還給作業系統了。
現代的垃圾回收器改良了演算法,但是本質是相同的:可達記憶體被標記,其餘的被當作垃圾回收。
四種常見的JS記憶體洩漏
1、意外的全域性變數
未定義的變數會在全域性物件建立一個新變數,如下:
function foo(arg) {
bar = "this is a hidden global variable";
}
函式 foo
內部忘記使用 var
,實際上JS會把bar掛載到全域性物件上,意外建立一個全域性變數:
function foo(arg) {
window.bar = "this is an explicit global variable";
}
另一個意外的全域性變數可能由 this
建立:
function foo() {
this.variable = "potential accidental global";
}
// Foo 呼叫自己,this 指向了全域性物件(window)
// 而不是 undefined
foo();
解決方法
在 JavaScript 檔案頭部加上 'use strict'
undefined
。如果必須使用全域性變數儲存大量資料時,確保用完以後把它設定為 null 或者重新定義。
2、被遺忘的計時器或回撥函式
計時器setInterval
程式碼很常見:
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
// 處理 node 和 someResource
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);
上面的例子表明,在節點node或者資料不再需要時,定時器依舊指向這些資料。所以哪怕當node節點被移除後,interval 仍舊存活並且垃圾回收器沒辦法回收,它的依賴也沒辦法被回收,除非終止定時器。
var element = document.getElementById('button');
function onClick(event) {
element.innerHTML = 'text';
}
element.addEventListener('click', onClick);
對於上面觀察者的例子,一旦它們不再需要(或者關聯的物件變成不可達),明確地移除它們非常重要。老的 IE 6 是無法處理迴圈引用的。因為老版本的 IE 是無法檢測 DOM 節點與 JavaScript 程式碼之間的迴圈引用,會導致記憶體洩漏。
但是,現代的瀏覽器(包括 IE 和 Microsoft Edge)使用了更先進的垃圾回收演算法(標記清除),已經可以正確檢測和處理迴圈引用了。即回收節點記憶體時,不必非要呼叫 removeEventListener
了。
3、脫離 DOM 的引用
如果把DOM 存成字典(JSON 鍵值對)或者陣列,此時,同樣的 DOM 元素存在兩個引用:一個在 DOM 樹中,另一個在字典中。那麼將來需要把兩個引用都清除。
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image'),
text: document.getElementById('text')
};
function doStuff() {
image.src = 'http://some.url/image';
button.click();
console.log(text.innerHTML);
// 更多邏輯
}
function removeButton() {
// 按鈕是 body 的後代元素
document.body.removeChild(document.getElementById('button'));
// 此時,仍舊存在一個全域性的 #button 的引用
// elements 字典。button 元素仍舊在記憶體中,不能被 GC 回收。
}
如果程式碼中儲存了表格某一個 <td>
的引用。將來決定刪除整個表格的時候,直覺認為 GC 會回收除了已儲存的 <td>
以外的其它節點。實際情況並非如此:此 <td>
是表格的子節點,子元素與父元素是引用關係。由於程式碼保留了 <td>
的引用,導致整個表格仍待在記憶體中。所以儲存 DOM 元素引用的時候,要小心謹慎。
4、閉包
閉包的關鍵是匿名函式可以訪問父級作用域的變數:
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);
每次呼叫 replaceThing
,theThing
得到一個包含一個大陣列和一個新閉包(someMethod
)的新物件。同時,變數 unused
是一個引用 originalThing
的閉包(先前的 replaceThing
又呼叫了 theThing
)。someMethod
可以通過 theThing
使用,someMethod
與 unused
分享閉包作用域,儘管 unused
從未使用,它引用的 originalThing
迫使它保留在記憶體中(防止被回收)。
解決方法是,在 replaceThing
的最後新增 originalThing = null
。