JavaScript 內存泄漏防范之道
一般情況下,忽視內存管理不會對傳統(tǒng)的網頁產生顯著的后果。這是因為,用戶刷新頁面后,內存數(shù)據都被清理了。
但是隨著SPA(單頁應用)的普及,我們不得不更加關注頁面的內存管理。用戶在 SPA 上往往很少刷新頁面,隨著頁面停留時間的增長,內存可能越占越多,輕則影響頁面性能,嚴重的可能導致標簽頁崩潰。
在這篇文章中,我們將探討導致 JavaScript 中內存泄露的常見原因,以及如何改善內存管理。
瀏覽器將對象保留在堆內存中,通過引用鏈可從根對象到達這些對象。垃圾回收器(GC)是 JavaScript 引擎中的一個后臺進程,它可以識別無法到達的對象,將其刪除,并回收相應的內存。
引用鏈 - GC - 對象關系圖
當內存中本應在垃圾回收循環(huán)中被清理的對象,通過另一個對象意外的引用從而維持可訪問狀態(tài),就會發(fā)生內存泄漏。將多余的對象保持在內存中,會導致應用程序內部的內存使用量過大,進而影響性能。
內存泄露
如何判斷代碼是否存在內存泄漏呢?內存泄漏通常比較隱蔽,難以發(fā)現(xiàn)和定位。造成內存泄漏的 JavaScript 代碼看上去挺正常,瀏覽器在運行的時候也不會拋出錯誤。如果發(fā)現(xiàn)頁面性能越來越差,通常是內存泄漏的征兆,可以通過瀏覽器內置的工具判斷是否存在內存泄漏,并分析出原因。
最快的方法是查看瀏覽器的任務管理器(注意,不是操作系統(tǒng)的任務管理器)。它提供了瀏覽器運行中的所有 tab 頁和進程的資源使用情況,比如內存占用、CPU 占用和進程 ID 等。Chrome 的任務管理器可通過 Shift+Esc 快捷鍵打開,F(xiàn)irefox 可在地址欄輸入about:performance打開。
如果頁面都沒有任何交互,內存占用卻越來越多,很可能存在泄漏。
Chrome 任務管理器
瀏覽器 DevTools 則提供了更豐富的內存管理功能??梢栽?Chrome 的性能面板錄制頁面運行情況,查看可視化的性能分析數(shù)據。
Chrome 性能面板
除此之外,Chrome 和 Firefox 的 DevTools 還有專門的內存工具用于分析內存使用情況。通過比較連續(xù)的內存快照,可以看出內存分配情況。
通過前面的分析,內存泄露的根本原因就是代碼在無意之中引用了本該被 GC 回收的對象。那么,哪些情況容易造成內存泄露呢?
1、意外的全局變量
全局變量一直處于可訪問狀態(tài),不會被 GC 回收。在非嚴格模式下,有時會不小心讓局部變量變成全局變量。
- 給未聲明的變量賦值
- 使用指向全局對象的 this
- function createGlobalVariables() {
- leaking1 = '變成全局變量了'; // 給未聲明的變量賦值
- this.leaking2 = '這也是全局變量'; // 'this' 指向全局對象
- };
- createGlobalVariables();
- window.leaking1; // '變成全局變量了'
- window.leaking2; // '這也是全局變量'
如何避免: 嚴格模式 ("use strict") 會避免意外的全局變量,以上代碼在嚴格模式下會報錯。
2、閉包
函數(shù)作用域變量在函數(shù)執(zhí)行完后會被清理,前提是在函數(shù)外部沒有引用它。閉包會讓變量一直處于被引用狀態(tài),即使它的執(zhí)行上下文和作用域已經不存在了。
- function outer() {
- const Array = [];
- return function inner() {
- bigArray.push('Hello');
- console.log('Hello');
- };
- };
- const sayHello = outer(); // 包含了對 inner 的引用
- function repeat(fn, num) {
- for (let i = 0; i < num; i++){
- fn();
- }
- }
- repeat(sayHello, 10); // 每次調用 sayHello 都會添加 'Hello' 到potentiallyHugeArray
- // 如果是10萬次呢?闊怕:repeat(sayHello, 100000)
上面例子中的數(shù)組 bigArray 沒有從任何函數(shù)中直接返回,因此無法直接訪問,但是它卻不停地膨脹,取決于我們調用了多少次 function inner()。
如何避免: 閉包是 JavaScript 語言的特性之一,如果無法避開,那就請注意兩點:
- 清楚閉包是何時創(chuàng)建的,以及哪些對象會被保留在內存中;
- 清楚閉包的生命周期和用途(尤其是當做回調函數(shù)的時候)
3、定時器
在 setTimeout 或 setInterval 的回調函數(shù)中引用某些對象,是防止被 GC 回收的常見做法。如果在代碼里設置循環(huán)定時器(setTimeout也能像setInterval一樣定時重復執(zhí)行,只要設置成遞歸調用),只要定時器還在運行,回調函數(shù)中的對象就會一直保持在內存中。
下面的例子中,data 對象會在清除定時器后被 GC 回收。但我們沒有獲取 setInterval的返回值,也就沒辦法用代碼清除這個定時器,因此盡管完全沒有用到,data.hugeString 也會一直保留在內存中,直到進程結束。
- function setCallback() {
- const data = {
- counter: 0,
- hugeString: new Array(100000).join('x')
- };
- return function cb() {
- data.counter++; // data 對象現(xiàn)在已經屬于回調函數(shù)的作用域了
- console.log(data.counter);
- }
- }
- setInterval(setCallback(), 1000); // 沒法停止定時器了
如何避免: 對于生命周期不確定的回調函數(shù),我們應該:
- 注意被定時器回調函數(shù)引用的對象
- 使用定時器返回的句柄,在必要時清除它
也可以通過分離變量的方式,避免對大對象的引用:
- function setCallback() {
- // 分開定義變量
- let counter = 0;
- const hugeString = new Array(100000).join('x'); // setCallback執(zhí)行完即可被回收
- return function cb() {
- counter++; // 只剩 counter 位于回調函數(shù)作用域
- console.log(counter);
- }
- }
- const timerId = setInterval(setCallback(), 1000); // 保存定時器 ID
- // 執(zhí)行某些操作 ...
- clearInterval(timerId); // 停止定時器
4、事件監(jiān)聽器
活動的事件監(jiān)聽器會阻止作用域內的變量被 GC 回收。事件監(jiān)聽器一直處于活動狀態(tài),直到用 removeEventListener() 顯式移除,或者關聯(lián)的 DOM 元素被移除。
對于有些事件來說,監(jiān)聽器需要一直保留,直到頁面被銷毀。比如按鈕點擊事件,我們可能需要重復使用。但是,有時候我們希望某個事件只執(zhí)行特定次數(shù)。
- const hugeString = new Array(100000).join('x');
- document.addEventListener('keyup', function() { // 匿名監(jiān)聽器無法移除
- doSomething(hugeString); // hugeString 會一直處于回調函數(shù)的作用域內
- });
上面例子中的事件監(jiān)聽器用了匿名函數(shù),這樣就沒法用removeEventListener()移除了。同時,document元素也無法刪除,因此事件回調函數(shù)內的變量會一直保留,哪怕我們只想觸發(fā)一次事件。
如何避免: 事件監(jiān)聽器不再需要時,要記得解除綁定。使用具名函數(shù)方式獲取引用,通過removeEventListener()解除綁定。
- function listener() {
- doSomething(hugeString);
- }
- document.addEventListener('keyup', listener);
- document.removeEventListener('keyup', listener);
如果事件監(jiān)聽器只需要執(zhí)行一次, addEventListener()可以接受第三個參數(shù),是一個配置對象。指定{once: true},監(jiān)聽器函數(shù)會在事件觸發(fā)一次執(zhí)行后自動移除(匿名函數(shù)也可以)。
- document.addEventListener('keyup', function listener(){
- doSomething(hugeString);
- }, {once: true}); // 執(zhí)行一次后自動移除事件監(jiān)聽器
5、緩存
如果持續(xù)不斷地往緩存里增加數(shù)據,沒有定時清除無用的對象,也沒有限制緩存大小,那么緩存就會像滾雪球一樣越來越大。
- let user_1 = { name: "Kayson", id: 12345 };
- let user_2 = { name: "Jerry", 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); // ['Kayson has an id of 12345', 'computed']
- cache(user_1); // ['Kayson has an id of 12345', 'cached']
- cache(user_2); // ['Jerry has an id of 54321', 'computed']
- console.log(mapCache); // ((…) => "Kayson has an id of 12345", (…) => "Jerry has an id of 54321")
- user_1 = null;
- //Garbage Collector
- console.log(mapCache); // ((…) => "Kayson has an id of 12345", (…) => "Jerry has an id of 54321") // 依然在緩存里
上面的例子中,緩存依然保留了user_1 的數(shù)據。因此我們需要把不再使用的數(shù)據從緩存中刪除。
可能的解決方案: 為了解決這個問題,可以使用 WeakMap。 WeakMap 是一種數(shù)據結構,它只用對象作為鍵,并保持對象鍵的弱引用,如果這個對象被置空了,相關的鍵值對會被 GC 自動回收。
- let user_1 = { name: "Kayson", id: 12345 };
- let user_2 = { name: "Jerry", id: 54321 };
- const weakMapCache = new WeakMap();
- function cache(obj){
- // 代碼跟前一個例子相同,只不過用的是 weakMapCache
- return [weakMapCache.get(obj), 'cached'];
- }
- cache(user_1); // ['Kayson has an id of 12345', 'computed']
- cache(user_2); // ['Jerry has an id of 54321', 'computed']
- console.log(weakMapCache); // ((…) => "Kayson has an id of 12345", (…) => "Jerry has an id of 54321"}
- user_1 = null;
- // Garbage Collector
- console.log(weakMapCache); // ((…) => "Jerry has an id of 54321") - 第一條記錄已被 GC 刪除
6、分離的 DOM 元素
如果 DOM 節(jié)點被 JavaScript 代碼直接引用,即使從 DOM 樹分離,也不會被 GC 回收。
下面的例子中,removeChild() 達不到預期效果,堆快照會顯示HTMLDivElement處于分離狀態(tài),因為有個變量指向了這個div。
- function createElement() {
- const div = document.createElement('div');
- div.id = 'detached';
- return div;
- }
- // 即使調用了deleteElement() ,依然保存著 DOM 元素的引用
- const detachedDiv = createElement();
- document.body.appendChild(detachedDiv);
- function deleteElement() {
- document.body.removeChild(document.getElementById('detached'));
- }
- deleteElement(); // 堆快照顯示: detached div#detached
如何避免: 一種方法是把DOM 引用限制為局部作用域。
- function createElement() {...} //
- // DOM 引用位于函數(shù)作用域內
- function appendElement() {
- const detachedDiv = createElement();
- document.body.appendChild(detachedDiv);
- }
- appendElement();
- function deleteElement() {
- document.body.removeChild(document.getElementById('detached'));
- }
- deleteElement();
總結
對于重要的前端應用,定位和解決 JavaScript 內存問題是一項頗具挑戰(zhàn)性的任務。因此,理解典型的內存泄露原因,從而在源頭上避免,是做好內存管理的必要工作。希望本文總結的造成內存泄漏的六大來源對你有所啟發(fā),在寫代碼的時候有所防范。