比過去的網頁,今天流行的 SPA 需要開發人員更加關注程序中的內存泄漏情況。因為以前的網站在瀏覽時會不斷刷新頁面,可是 SPA 網站往往只有少數幾個頁面,很少完全重新加載。這篇文章主要探討 JS 代碼中容易導致內存泄漏的模式,并給出改進對策。
本文最初發布于 ditdot.hr 網站,經作者授權由 InfoQ 中文站編譯并分享。
什么是內存泄漏?
內存泄漏指由于疏忽或錯誤造成程序未能釋放已經不再使用的內存的情況。內存泄漏并非指內存在物理上的消失,而是應用程序分配某段內存后,由于設計錯誤,失去了對該段內存的控制,因而造成了內存的浪費。
JavaScript 是一個有垃圾回收機制的語言,我們不需要手動回收內存。當本應在垃圾回收周期中清理的內存中的對象,通過另一個對象的無意引用從根保持可訪問狀態時,就會發生內存泄漏,并可能導致性能下降的后果。
內存泄漏通常很難發現和定位。泄漏的 JavaScript 代碼從任何層面都不會被認為是無效的,并且瀏覽器在運行時不會引發任何錯誤。
檢查內存使用情況的最快方法是查看瀏覽器的 任務管理器(不是操作系統的那個任務管理器)。在 Linux 和 Windows 上按 Shift+Esc 來訪問 Chrome 的任務管理器;Firefox 則在地址欄中鍵入 about:performance。我們能用它查看每個選項卡的 JavaScript 內存占用量。如果發現異常的內存使用量持續增長,就很可能出現了泄漏。
開發工具 提供了更高級的內存管理方法。通過 Chrome 的性能工具,我們可以直觀地分析頁面在運行時的性能。像下面這種模式就是內存泄漏的典型表現:
除此之外,Chrome 和 Firefox 的開發工具都可以用“內存”工具進一步探索內存使用情況。
JS 代碼中常見的幾個內存泄漏源
全局變量
全局變量總是從根可用,并且永遠不會回收垃圾。在非嚴格模式下,一些錯誤會導致變量從本地域泄漏到全局域:
function createGlobalVariables() {
leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'
預防措施:使用嚴格模式("use strict")。
閉包
函數作用域內的變量將在函數退出調用棧后清除,并且如果函數外部沒有其他指向它們的引用,則將清理它們。但閉包將保留引用的變量并保持活動狀態。
function outer() {
const potentiallyHugeArray = [];
return function inner() {
potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
console.log('Hello');
};
};
const sayHello = outer(); // contains definition of the function inner
function repeat(fn, num) {
for (let i = 0; i < num; i++){
fn();
}
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray
// now imagine repeat(sayHello, 100000)
在此示例中,從任何一個函數都不會返回 potentialHugeArray,并且無法到達它,但它的大小可以無限增加,具體取決于我們調用函數 inner() 的次數。
預防措施:閉包是肯定會用到的,所以重要的是:
計時器
如果我們在代碼中設置了遞歸計時器(recurring timer),則只要回調可調用,計時器回調中對該對象的引用就將保持活動狀態。
在下面的示例中,由于我們沒有對 setInterval 的引用,因此它永遠不會被清除,并且 data.hugeString 會一直保留在內存中。
function setCallback() {
const data = {
counter: 0,
hugeString: new Array(100000).join('x')
};
return function cb() {
data.counter++; // data object is now part of the callback's scope
console.log(data.counter);
}
}
setInterval(setCallback(), 1000); // how do we stop it?
預防措施:尤其是在回調的生命周期不確定或 undefined 的情況下:
function setCallback() {
// 'unpacking' the data object
let counter = 0;
const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns
return function cb() {
counter++; // only counter is part of the callback's scope
console.log(counter);
}
}
const timerId = setInterval(setCallback(), 1000); // saving the interval ID
// doing something ...
clearInterval(timerId); // stopping the timer i.e. if button pressed
事件偵聽器
添加后,事件偵聽器將一直保持有效,直到:
對于某些類型的事件,應該一直保留到用戶離開頁面為止。但是,有時我們希望事件偵聽器執行特定的次數。
const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});
在上面的示例中,用一個匿名內聯函數作為事件偵聽器,這意味著無法使用 removeEventListener() 將其刪除。同樣,該文檔也無法刪除,因此即使我們只需要觸發它一次,它和它域中的內容就都刪不掉了。
預防措施:我們應該始終創建指向事件偵聽器的引用并將其傳遞給 removeEventListener(),來注銷不再需要的事件偵聽器。
function listener() {
doSomething(hugeString);
}
document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here
如果事件偵聽器僅執行一次,則 addEventListener() 可以使用第三個參數。假設{once: true}作為第三個參數傳遞給 addEventListener(),則在處理一次事件后,將自動刪除偵聽器函數。
document.addEventListener('keyup', function listener() {
doSomething(hugeString);
}, {once: true}); // listener will be removed after running once
緩存
如果我們不刪除未使用的對象且不控制對象大小,那么緩存就會失控。
let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const mapCache = new Map();
function cache(obj){
if (!mapCache.has(obj)){
const value = `${obj.name} has an id of ${obj.id}`;
mapCache.set(obj, value);
return [value, 'computed'];
}
return [mapCache.get(obj), 'cached'];
}
cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_1); // ['Peter has an id of 12345', 'cached']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321")
user_1 = null; // removing the inactive user
// Garbage Collector
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // first entry is still in cache
在上面的示例中,緩存仍保留在 user_1 對象上。因此,我們還需要清除不會再重用的條目的緩存。
可能的解決方案:我們可以使用 WeakMap。它的數據結構中,鍵名是對象的弱引用,它僅接受對象作為鍵名,所以其對應的對象可能會被自動回收。當對象被回收后,WeakMap 自動移除對應的鍵值對。在以下示例中,在使 user_1 對象為空后,下一次垃圾回收后關聯的條目會自動從 WeakMap 中刪除。
let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const weakMapCache = new WeakMap();
function cache(obj){
// ...same as above, but with weakMapCache
return [weakMapCache.get(obj), 'cached'];
}
cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(weakMapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321"}
user_1 = null; // removing the inactive user
// Garbage Collector
console.log(weakMapCache); // ((…) => "Mark has an id of 54321") - first entry gets garbage collected
分離的 DOM 元素
如果 DOM 節點具有來自 JavaScript 的直接引用,則即使從 DOM 樹中刪除了該節點,也不會對其垃圾回收。
在以下示例中,我們創建了一個 div 元素并將其附加到 document.body。removeChild() 無法正常工作,并且由于仍然存在指向 div 的變量,所以堆快照將顯示分離的 HTMLDivElement。
function createElement() {
const div = document.createElement('div');
div.id = 'detached';
return div;
}
// this will keep referencing the DOM element even after deleteElement() is called
const detachedDiv = createElement();
document.body.appendChild(detachedDiv);
function deleteElement() {
document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // Heap snapshot will show detached div#detached
怎么預防呢?一種方案是將 DOM 引用移入本地域。在下面的示例中,在函數 appendElement() 完成之后,將刪除指向 DOM 元素的變量。
function createElement() {...} // same as above
// DOM references are inside the function scope
function appendElement() {
const detachedDiv = createElement();
document.body.appendChild(detachedDiv);
}
appendElement();
function deleteElement() {
document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // no detached div#detached elements in the Heap Snapshot
關注我并轉發此篇文章,私信我“領取資料”,即可免費獲得InfoQ價值4999元迷你書!
者:京東零售 謝天
在任何語言開發的過程中,對于內存的管理都非常重要,JavaScript 也不例外。
然而在前端瀏覽器中,用戶一般不會在一個頁面停留很久,即使有一點內存泄漏,重新加載頁面內存也會跟著釋放。而且瀏覽器也有自己的自動回收內存的機制,所以前端并沒有特別關注內存泄漏的問題。
但是如果我們對內存泄漏沒有什么概念,有時候還是有可能因為內存泄漏,導致頁面卡頓。了解內存泄漏,如何避免內存泄漏,都是不可缺少的。
在硬件級別上,計算機內存由大量觸發器組成。每個觸發器包含幾個晶體管,能夠存儲一個位。單個觸發器可以通過唯一標識符尋址,因此我們可以讀取和覆蓋它們。因此,從概念上講,我們可以把我們的整個計算機內存看作是一個巨大的位數組,我們可以讀和寫。
這是內存的底層概念,JavaScript 作為一個高級語言,不需要通過二進制進行內存的讀寫,而是相關的 JavaScript 引擎做了這部分的工作。
內存也會有生命周期,不管什么程序語言,一般可以按照順序分為三個周期:
內存分配 -> 內存使用 -> 內存釋放
在計算機科學中,內存泄漏指由于疏忽或錯誤造成程序未能釋放已經不再使用的內存。內存泄漏并非指內存在物理上的消失,而是應用程序分配某段內存后,由于設計錯誤,導致在釋放該段內存之前就失去了對該段內存的控制,從而造成了內存的浪費。
如果內存不需要時,沒有經過生命周期的的釋放期,那么就存在內存泄漏。
內存泄漏的簡單理解:無用的內存還在占用,得不到釋放和歸還。比較嚴重時,無用的內存會持續遞增,從而導致整個系統的卡頓,甚至崩潰。
像 C 語言這樣的底層語言一般都有底層的內存管理接口,但是 JavaScript 是在創建變量時自動進行了內存分配,并且在不使用時自動釋放,釋放的過程稱為“垃圾回收”。然而就是因為自動回收的機制,讓我們錯誤的感覺開發者不必關心內存的管理。
JavaScript 內存管理機制和內存的生命周期是一致的,首先需要分配內存,然后使用內存,最后釋放內存。絕大多數情況下不需要手動釋放內存,只需要關注對內存的使用(變量、函數、對象等)。
JavaScript 定義變量就會自動分配內存,我們只需要了解 JavaScript 的內存是自動分配的就可以了。
let num = 1;
const str = "名字";
const obj = {
a: 1,
b: 2
}
const arr = [1, 2, 3];
function func (arg) { ... }
使用值的過程實際上是對分配的內存進行讀寫的操作,讀取和寫入的操作可能是寫入一個變量或者一個對象的屬性值,甚至傳遞函數的參數。
// 繼續上部分
// 寫入內存
num = 2;
// 讀取內存,寫入內存
func(num);
垃圾回收被稱為 GC(Garbage Collection)
內存泄漏一般都是發生在這一步,JavaScript 的內存回收機制雖然可以回收絕大部分的垃圾內存,但是還是存在回收不了的情況,如果存在這些情況,需要我們自己手動清理內存。
以前一些老版本的瀏覽器的 JavaScript 回收機制沒有那么完善,經常出現一些 bug 的內存泄漏,不過現在的瀏覽器一般都沒有這個問題了。
這里了解下現在 JavaScript 的垃圾內存的兩種回收方式,熟悉一下這兩種算法可以幫助我們理解一些內存泄漏的場景。
這是最初級的垃圾收集算法。此算法把“對象是否不再需要”簡化定義為“對象有沒有其他對象引用到它”。如果沒有引用指向該對象(零引用),對象將被垃圾回收機制回收。
// “對象”分配給 obj1
var obj1 = {
a: 1,
b: 2
}
// obj2 引用“對象”
var obj2 = obj1;
// “對象”的原始引用 obj1 被 obj2 替換
obj1 = 1;
當前執行環境中,“對象”內存還沒有被回收,需要手動釋放“對象”的內存(在沒有離開當前執行環境的前提下)
obj2 = null;
// 或者 obj2 = 1;
// 只要替換“對象”就可以了
這樣引用的“對象”內存就被回收了。
ES6 中把引用分為強引用和弱引用,這個目前只有在 Set 和 Map 中才存在。
強引用才會有引用計數疊加,只有引用計數為 0 的對象的內存才會被回收,所以一般需要手動回收內存(手動回收的前提在于標記清除法還沒執行,還處于當前的執行環境)。
而弱引用沒有觸發引用計數疊加,只要引用計數為 0,弱引用就會自動消失,無需手動回收內存。
當變量進入執行時標記為“進入環境”,當變量離開執行環境時則標記為“離開環境”,被標記為“進入環境”的變量是不能被回收的,因為它們正在被使用,而標記為“離開環境”的變量則可以被回收。
環境可以理解為我們的執行上下文,全局作用域的變量只會在頁面關閉時才會被銷毀。
// 假設這里是全局上下文
var b = 1; // b 標記進入環境
function func() {
var a = 1;
return a + b; // 函數執行時,a 被標記進入環境
}
func();
// 函數執行結束,a 被標記離開環境,被回收
// 但是 b 沒有標記離開環境
JavaScript 的內存回收機制雖然能回收絕大部分的垃圾內存,但是還是存在回收不了的情況。程序員要讓瀏覽器內存泄漏,瀏覽器也是管不了的。
下面有些例子是在執行環境中,沒離開當前執行環境,還沒觸發標記清除法。所以你需要讀懂上面 JavaScript 的內存回收機制,才能更好的理解下面的場景。
// 在全局作用域下定義
function count(num) {
a = 1; // a 相當于 window.a = 1;
return a + num;
}
不過在 eslint 幫助下,這種場景現在基本沒人會犯了,eslint 會直接報錯,了解下就好。
無用的計時器忘記清理,是最容易犯的錯誤之一。
拿一個 vue 組件舉個例子。
<script>
export default {
mounted() {
setInterval(() => {
this.fetchData();
}, 2000);
},
methods: {
fetchData() { ... }
}
}
</script>
上面的組件銷毀的時候,setInterval 還是在運行的,里面涉及到的內存都是沒法回收的(瀏覽器會認為這是必須的內存,不是垃圾內存),需要在組件銷毀的時候清除計時器。
<script>
export default {
mounted() {
this.timer = setInterval(() => { ... }, 2000);
},
beforeDestroy() {
clearInterval(this.timer);
}
}
</script>
無用的事件監聽器忘記清理也是最容易犯的錯誤之一。
還是使用 vue 組件舉個例子。
<script>
export default {
mounted() {
window.addEventListener('resize', () => { ... });
}
}
</script>
上面的組件銷毀的時候,resize 事件還是在監聽中,里面涉及到的內存都是沒法回收的,需要在組件銷毀的時候移除相關的事件。
<script>
export default {
mounted() {
this.resizeEvent = () => { ... };
window.addEventListener('resize', this.resizeEvent);
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeEvent);
}
}
</script>
Set 是 ES6 中新增的數據結構,如果對 Set 不熟,可以看這里。
如下是有內存泄漏的(成員是引用類型,即對象):
let testSet = new Set();
let value = { a: 1 };
testSet.add(value);
value = null;
需要改成這樣,才會沒有內存泄漏:
let testSet = new Set();
let value = { a: 1 };
testSet.add(value);
testSet.delete(value);
value = null;
有個更便捷的方式,使用 WeakSet,WeakSet 的成員是弱引用,內存回收不會考慮這個引用是否存在。
let testSet = new WeakSet();
let value = { a: 1 };
testSet.add(value);
value = null;
Map 是 ES6 中新增的數據結構,如果對 Map 不熟,可以看這里。
如下是有內存泄漏的(成員是引用類型,即對象):
let map = new Map();
let key = [1, 2, 3];
map.set(key, 1);
key = null;
需要改成這樣,才會沒有內存泄漏:
let map = new Map();
let key = [1, 2, 3];
map.set(key, 1);
map.delete(key);
key = null;
有個更便捷的方式,使用 WeakMap,WeakMap 的鍵名是弱引用,內存回收不會考慮到這個引用是否存在。
let map = new WeakMap();
let key = [1, 2, 3];
map.set(key, 1);
key = null
和上面事件監聽器的道理是一樣的。
建設訂閱發布事件有三個方法,emit、on、off 三個方法。
還是繼續使用 vue 組件舉例子:
<template>
<div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';
export default {
mounted() {
EventEmitter.on('test', () => { ... });
},
methods: {
onClick() {
EventEmitter.emit('test');
}
}
}
</script>
上面組件銷毀的時候,自定義 test 事件還是在監聽中,里面涉及到的內存都是沒辦法回收的,需要在組件銷毀的時候移除相關的事件。
<template>
<div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';
export default {
mounted() {
EventEmitter.on('test', () => { ... });
},
methods: {
onClick() {
EventEmitter.emit('test');
}
},
beforeDestroy() {
EventEmitter.off('test');
}
}
</script>
閉包是經常使用的,閉包能提供很多的便利,
首先看下下面的代碼:
function closure() {
const name = '名字';
return () => {
return name.split('').reverse().join('');
}
}
const reverseName = closure();
reverseName(); // 這里調用了 reverseName
上面有沒有內存泄漏?是沒有的,因為 name 變量是要用到的(非垃圾),這也是從側面反映了閉包的缺點,內存占用相對高,數量多了會影響性能。
但是如果 reverseName 沒有被調用,在當前執行環境未結束的情況下,嚴格來說,這樣是有內存泄漏的,name 變量是被 closure 返回的函數調用了,但是返回的函數沒被使用,在這個場景下 name 就屬于垃圾內存。name 不是必須的,但是還是占用了內存,也不可被回收。
當然這種也是極端情況,很少人會犯這種低級錯誤。這個例子可以讓我們更清楚的認識內存泄漏。
每個頁面上的 DOM 都是占用內存的,建設有一個頁面 A 元素,我們獲取到了 A 元素 DOM 對象,然后賦值到了一個變量(內存指向是一樣的),然后移除了頁面上的 A 元素,如果這個變量由于其他原因沒有被回收,那么就存在內存泄漏,如下面的例子:
class Test {
constructor() {
this.elements = {
button: document.querySelector('#button'),
div: document.querySelector('#div')
}
}
removeButton() {
document.body.removeChild(this.elements.button);
// this.elements.button = null
}
}
const test = new Test();
test.removeButton();
上面的例子 button 元素雖然在頁面上移除了,但是內存指向換成了 this.elements.button,內存占用還是存在的。所以上面的代碼還需要這么寫:this.elements.button = null,手動釋放內存。
內存泄漏時,內存一般都是周期性的增長,我們可以借助谷歌瀏覽器的開發者工具進行判斷。
這里針對下面的例子進行一步步的的排查和找到問題點:
<html>
<body>
<div id="app">
<button id="run">運行</button>
<button id="stop">停止</button>
</div>
<script>
const arr = []
for (let i = 0; i < 200000; i++) {
arr.push(i)
}
let newArr = []
function run() {
newArr = newArr.concat(arr)
}
let clearRun
document.querySelector('#run').onclick = function() {
clearRun = setInterval(() => {
run()
}, 1000)
}
document.querySelector('#stop').onclick = function() {
clearInterval(clearRun)
}
</script>
</body>
</html>
訪問上面的代碼頁面,打開開發者工具,切換至 Performance 選項,勾選 Memory 選項。
在頁面上點擊運行按鈕,然后在開發者工具上面點擊左上角的錄制按鈕,10 秒后在頁面上點擊停止按鈕,5 秒停止內存錄制。得到內存走勢如下:
由上圖可知,10 秒之前內存周期性增長,10 秒后點擊了停止按鈕,內存平穩,不再遞增。我們可以使用內存走勢圖判斷是否存在內存泄漏。
上一步確認內存泄漏問題后,我們繼續利用開發者工具進行問題查找。
訪問上面的代碼頁面,打開開發者工具,切換至 Memory 選項。頁面上點擊運行按鈕,然后點擊開發者工具左上角的錄制按鈕,錄制完成后繼續點擊錄制,直到錄制完成三個為止。然后點擊頁面上的停止按鈕,在連續錄制三次內存(不要清理之前的錄制)。
從這里也可以看出,點擊運行按鈕之后,內存在不斷的遞增。點擊停止按鈕之后,內存就平穩了。雖然我們也可以用這種方式來判斷是否存在內存泄漏,但是沒有第一步的方法便捷,走勢圖也更加直觀。
然后第二步的主要目的是為了記錄 JavaScript 堆內存,我們可以看到哪個堆占用的內存更高。
從內存記錄中,發現 array 對象占用最大,展開后發現,第一個 object elements 占用最大,選擇這個 object elements 后可以在下面看到 newArr 變量,然后點擊后面的高亮鏈接,就可以跳轉到 newArr 附近。
端的學習已經進入了一個艱難的上升期,越來越發現自己學習的東西還多得多,需要掌握的知識面寬廣了很多,知識點需要理解的深度也加深了很多。今天看到前端內存泄漏相關,自己總結總結,也便于自己以后學習記憶。由于經驗所致,必然會有不足之處,歡迎指正!
內存是計算機中重要的部件之一,它是與CPU進行溝通的橋梁。計算機中所有程序的運行都是在內存中進行的,因此內存的性能對計算機的影響非常大。
內存泄漏(Memory Leak)是指程序中己動態分配的堆內存由于某種原因程序未釋放或無法釋放,造成系統內存的浪費,導致程序運行速度減慢甚至系統崩潰等嚴重后果。 說白了就是 不再用到的內存,沒有及時釋放,就叫做內存泄漏(memory leak)。
看完上面的解釋,腦海中就會有一點概念,計算機正常運轉會用到內存,內存像是一個中轉站,他把你暫存的數據,馬上就會用到的數據存儲在這,以讓你更快捷方便的使用, 那你肯定會想到一個問題,暫存的數據到底哪些該存儲在這里,存儲的東西不會一直在這,又是怎么消失的呢?
在我的理解,前端開發中,全局的、被引用的對象就會被保存在內存中。比如我們常見的閉包:
function leak(arg) {
this.arg = arg;
}
function test() {
var l1= new leak('It is a leak');
document.body.addEventListener('click', function() {
l1.arg = 'Here you are!'
})
}
test()
很明顯,l1被閉包環境引用,無法被回收
對前端開發來說只需要理解'引用計數法'就可以了 語言引擎有一張"引用表",保存了內存里面所有的資源(通常是各種值)的引用次數。如果一個值的引用次數是0,就表示這個值不再用到了,因此可以將這塊內存釋放。
2. 如果是在Node環境下,可以用Node提供的process.memoryUsage()方法來檢查內存泄露:具體方法可以參考阮一峰的例子:https://github.com/ruanyf/es6tutorial/issues/362#issuecomment-292451925
判斷內存泄露以heapUsed為準。
作者:天微蔚藍
鏈接:https://juejin.im/post/5c6663a85188252a160efa3c
來源:掘金
*請認真填寫需求信息,我們會在24小時內與您取得聯系。