一文搞懂如何避免JavaScript內(nèi)存泄漏
大家好,我是CUGGZ。SPA(單頁應(yīng)用程序)的興起,促使我們更加關(guān)注與內(nèi)存相關(guān)的 JavaScript 編碼實踐。如果應(yīng)用使用的內(nèi)存越來越多,就會嚴(yán)重影響性能,甚至導(dǎo)致瀏覽器的崩潰。下面就來看看JavaScript中常見的內(nèi)存泄漏以及如何避免內(nèi)存泄漏。
一、什么是內(nèi)存泄漏
JavaScript 就是所謂的垃圾回收語言之一,垃圾回收語言通過定期檢查哪些先前分配的內(nèi)存仍然可以從應(yīng)用程序的其他部分“訪問”來幫助開發(fā)人員管理內(nèi)存。垃圾回收語言中泄漏的主要原因是不需要的引用。如果你的 JavaScript 應(yīng)用程序經(jīng)常發(fā)生崩潰、高延遲和性能差,那么一個潛在的原因可能是內(nèi)存泄漏。
在 JavaScript 中,內(nèi)存是有生命周期的:
- 分配內(nèi)存: 內(nèi)存由操作系統(tǒng)分配,允許程序使用它。在 JavaScript 中,分配內(nèi)存是自動完成的。
- 使用內(nèi)存: 這是程序?qū)嶋H使用先前分配的內(nèi)存的空間。當(dāng)在代碼中使用分配的變量時,會發(fā)生讀取和寫入操作。
- 釋放內(nèi)存: 釋放不需要的內(nèi)存,這樣內(nèi)存就會空閑并可以再次利用。在 JavaScript 中,釋放內(nèi)存是自動完成的。
在JavaScript中,對象會保存在堆內(nèi)存中,可以根據(jù)引用鏈從根訪問它們。垃圾收集器是 JavaScript 引擎中的一個后臺進(jìn)程,用于識別無法訪問的對象、刪除它們并回收內(nèi)存。
下面是垃圾收集器根到對象的引用鏈?zhǔn)纠?/p>
當(dāng)內(nèi)存中應(yīng)該在垃圾回收周期中清理的對象,通過另一個對象的無意引用從根保持可訪問時,就會發(fā)生內(nèi)存泄漏。將冗余對象保留在內(nèi)存中會導(dǎo)致應(yīng)用程序內(nèi)部使用過多的內(nèi)存,并可能導(dǎo)致性能下降。
那該如何判斷代碼正在泄漏內(nèi)存呢?通常,內(nèi)存泄漏是很難被發(fā)現(xiàn)的,并且瀏覽器在運行它時不會拋出任何錯誤。如果注意到頁面的性能越來越差,瀏覽器的內(nèi)置工具可以幫助我們確定是否存在內(nèi)存泄漏以及導(dǎo)致內(nèi)存泄漏的對象。
內(nèi)存使用檢查最快的方法就是查看瀏覽器的任務(wù)管理器。 它們提供了當(dāng)前在瀏覽器中運行的所有選項卡和進(jìn)程的概覽。在任務(wù)管理器中查看每個選項卡的 JavaScript 內(nèi)存占用情況。如果網(wǎng)站什么都不做,但是 JavaScript 內(nèi)存使用量卻在逐漸增加,那么很有可能發(fā)生了內(nèi)存泄漏。
二、常見的內(nèi)存泄漏
我們可以通過了解在 JavaScript 中如何創(chuàng)建不需要的引用來防止內(nèi)存泄漏。以下情況就會導(dǎo)致不需要的引用。
1、意外的全局變量
全局變量始終可以從全局對象(在瀏覽器中,全局對象是window)中獲得,并且永遠(yuǎn)不會被垃圾回收。在非嚴(yán)格模式下,以下行為會導(dǎo)致變量從局部范圍泄露到全局范圍:
(1)為未聲明的變量賦值
這里我們給函數(shù)中一個未聲明的變量bar賦值,這時就會使bar成為一個全局變量:
function foo(arg) { bar = "hello world"; }
這就等價于:
function foo(arg) { window.bar = "hello world"; }
這樣就會創(chuàng)建一個多余的全局變量,當(dāng)執(zhí)行完foo函數(shù)之后,變量bar仍然會存在于全局對象中:
foo() window.bar // hello world
(2)使用指向全局對象的 this
使用以下方式也會創(chuàng)建一個以外的全局變量:
function foo() { this.bar = "hello world"; } foo();
這里foo是在全局對象中調(diào)用的,所以其this是指向全局對象的(這里是window):
window.bar // hello world
我們可以通過使用嚴(yán)格模式“use strict”來避免這一切。在JavaScript文件的開頭,它將開啟更嚴(yán)格的JavaScript解析模式,從而防止意外的創(chuàng)建全局變量。
需要特別注意那些用于臨時存儲和處理大量信息的全局變量。如果必須使用全局變量存儲數(shù)據(jù),就使用全局變量存儲數(shù)據(jù),但在不再使用時,就手動將其設(shè)置為 null,或者在處理完后重新分配。否則的話,請盡可能的使用局部變量。
2、 計時器
使用 setTimeout 或 setInterval 引用回調(diào)中的某個對象是防止對象被垃圾收集的最常見方法。如果我們在代碼中設(shè)置了循環(huán)計時器,只要回調(diào)是可調(diào)用的,計時器回調(diào)中對對象的引用就會保持活動狀態(tài)。
在下面的示例中,只有在清除計時器后,才能對數(shù)據(jù)對象進(jìn)行垃圾收集。由于我們沒有對setInterval的引用,所以它永遠(yuǎn)無法被清除和刪除數(shù)據(jù)。hugeString會一直保存在內(nèi)存中,直到應(yīng)用程序停止,盡管從未使用過。
function setCallback() { const data = { counter: 0, hugeString: new Array(100000).join('x') }; return function cb() { data.counter++; // data對象是回調(diào)范圍的一部分 console.log(data.counter); } } setInterval(setCallback(), 1000);
當(dāng)執(zhí)行這段代碼時,就會每秒輸出一個數(shù)字:
那我們?nèi)绾稳プ柚顾?尤其是在回調(diào)的壽命未定義或不確定的情況下:
- 修改計時器回調(diào)中引用的對象;
- 必要時使用從計時器返回的句柄(定時器的標(biāo)識符)取消它。
function setCallback() { // 將數(shù)據(jù)對象解包 let counter = 0; const hugeString = new Array(100000).join('x'); // 在setCallback返回時被刪除 return function cb() { counter++; // 只有計數(shù)器counter是回調(diào)范圍的一部分 console.log(counter); } } const timerId = setInterval(setCallback(), 1000); // 保存定時器的ID // 合適的時機清除定時器 clearInterval(timerId);
3、 閉包
我們知道,函數(shù)范圍內(nèi)的變量在函數(shù)退出調(diào)用堆棧后,如果函數(shù)外部沒有任何指向它們的引用,則會被清除。盡管函數(shù)已經(jīng)完成執(zhí)行,其執(zhí)行上下文和變量環(huán)境早已消失,但閉包將保持變量的引用和活動狀態(tài)。
function outer() { const potentiallyHugeArray = []; return function inner() { potentiallyHugeArray.push('Hello'); console.log('Hello'); }; }; const sayHello = outer(); function repeat(fn, num) { for (let i = 0; i < num; i++){ fn(); } } repeat(sayHello, 10);
顯而易見,這里就形成了一個閉包。其輸出結(jié)果如下:
這里,potentiallyHugeArray 永遠(yuǎn)不會從任何函數(shù)返回,也無法訪問,但它的大小可能會無限增長,這取決于調(diào)用函數(shù) inner() 的次數(shù)。
那該如何防止這個問題呢?閉包是不可避免的,也是JavaScript不可或缺的一部分,因此重要的是:
- 了解何時創(chuàng)建閉包以及閉包保留了哪些對象。
- 了解閉包的預(yù)期壽命和用法(尤其是用作回調(diào)時)。
4、 事件監(jiān)聽器
活動事件偵聽器將防止在其范圍內(nèi)捕獲的所有變量被垃圾收集。添加后,事件偵聽器將一直有效,直到:
- 使用 removeEventListener() 顯式刪除。
- 關(guān)聯(lián)的 DOM 元素被移除。
對于某些類型的事件,它會一直保留到用戶離開頁面,就像應(yīng)該多次單擊的按鈕一樣。但是,有時我們希望事件偵聽器執(zhí)行一定次數(shù)。
const hugeString = new Array(100000).join('x'); document.addEventListener('keyup', function() { // 匿名內(nèi)聯(lián)函數(shù),無法刪除它 doSomething(hugeString); // hugeString 將永遠(yuǎn)保留在回調(diào)的范圍內(nèi) });
在上面的示例中,匿名內(nèi)聯(lián)函數(shù)用作事件偵聽器,這意味著不能使用 removeEventListener() 刪除它。同樣,document 不能被刪除,因此只能使用 listener 函數(shù)以及它在其范圍內(nèi)保留的內(nèi)容,即使只需要啟動一次。
那該如何防止這個問題呢?一旦不再需要,我們應(yīng)該通過創(chuàng)建指向事件偵聽器的引用并將其傳遞給 removeEventListener() 來注銷事件偵聽器。
function listener() { doSomething(hugeString); } document.addEventListener('keyup', listener); document.removeEventListener('keyup', listener);
如果事件偵聽器只能執(zhí)行一次,addEventListener() 可以接受第三個參數(shù),這是一個提供附加選項的對象。假定將 {once:true} 作為第三個參數(shù)傳遞給 addEventListener() ,則偵聽器函數(shù)將在處理一次事件后自動刪除。
document.addEventListener('keyup', function listener() { doSomething(hugeString); }, {once: true});
5、緩存
如果我們不斷地將內(nèi)存添加到緩存中,而不刪除未使用的對象,并且沒有一些限制大小的邏輯,那么緩存可以無限增長。
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; console.log(mapCache); // {{…} => 'Peter has an id of 12345', {…} => 'Mark has an id of 54321'}
在上面的示例中,緩存仍然保留 user_1 對象。因此,我們需要將那些永遠(yuǎn)不會被重用的變量從緩存中清除。
可以使用 WeakMap 來解決此問題。它是一種具有弱鍵引用的數(shù)據(jù)結(jié)構(gòu),僅接受對象作為鍵。如果我們使用一個對象作為鍵,并且它是對該對象的唯一引用——相關(guān)變量將從緩存中刪除并被垃圾收集。在以下示例中,將 user_1 對象清空后,相關(guān)變量會在下一次垃圾回收后自動從 WeakMap 中刪除。
let user_1 = { name: "Peter", id: 12345 }; let user_2 = { name: "Mark", id: 54321 }; const weakMapCache = new WeakMap(); function cache(obj){ // ... 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; console.log(weakMapCache); // {(…) => "Mark has an id of 54321"}
6、分離的DOM元素
如果DOM節(jié)點具有來自 JavaScript 的直接引用,它將防止對其進(jìn)行垃圾收集,即使在從DOM樹中刪除該節(jié)點之后也是如此。
在下面的示例中,創(chuàng)建了一個div元素并將其附加到 document.body 中。removeChild() 就無法按預(yù)期工作,堆快照將顯示分離的HTMLDivElement,因為仍有一個變量指向div。
function createElement() { const div = document.createElement('div'); div.id = 'detached'; return div; } // 即使在調(diào)用deleteElement() 之后,它仍將繼續(xù)引用DOM元素 const detachedDiv = createElement(); document.body.appendChild(detachedDiv); function deleteElement() { document.body.removeChild(document.getElementById('detached')); } deleteElement();
要解決此問題,可以將DOM引用移動到本地范圍。在下面的示例中,在函數(shù)appendElement() 完成后,將刪除指向DOM元素的變量。
function createElement() {...} // DOM引用在函數(shù)范圍內(nèi) function appendElement() { const detachedDiv = createElement(); document.body.appendChild(detachedDiv); } appendElement(); function deleteElement() { document.body.removeChild(document.getElementById('detached')); } deleteElement();
三、識別內(nèi)存泄漏
調(diào)試內(nèi)存問題是一項復(fù)雜的工作,我們可以使用 Chrome DevTools 來識別內(nèi)存圖和一些內(nèi)存泄漏,我們需要關(guān)注以下兩個方面:
- 使用性能分析器可視化內(nèi)存消耗。
- 識別分離的 DOM 節(jié)點。
1、使用性能分析器可視化內(nèi)存消耗
以下面的代碼為例,有兩個按鈕:打印和清除。點擊“打印”按鈕,通過創(chuàng)建 paragraph 節(jié)點并將大字符串設(shè)置到全局,將1到10000的數(shù)字追加到DOM中。
“清除”按鈕會清除全局變量并覆蓋 body 的正文,但不會刪除單擊“打印”時創(chuàng)建的節(jié)點:
<!DOCTYPE html> <html lang="en"> <head> <title>Memory leaks</title> </head> <body> <button id="print">打印</button> <button id="clear">清除</button> </body> </html> <script> var longArray = []; function print() { for (var i = 0; i < 10000; i++) { let paragraph = document.createElement("p"); paragraph.innerHTML = i; document.body.appendChild(paragraph); } longArray.push(new Array(1000000).join("y")); } document.getElementById("print").addEventListener("click", print); document.getElementById("clear").addEventListener("click", () => { window.longArray = null; document.body.innerHTML = "Cleared"; }); </script>
當(dāng)每次點擊打印按鈕時,JavaScript Heap都會出現(xiàn)藍(lán)色的峰值,并逐漸增加,這是因為JavaScript正在創(chuàng)建DOM節(jié)點并字符串添加到全局?jǐn)?shù)組。當(dāng)點擊清除按鈕時,JavaScript Heap就變得正常了。除此之外,可以看到節(jié)點的數(shù)量(綠色的線)一直在增加,因為我們并沒有刪除這些節(jié)點。
在實際的場景中,如果觀察到內(nèi)存持續(xù)出現(xiàn)峰值,并且內(nèi)存消耗一直沒有減少,那可能存在內(nèi)存泄露。
2、 識別分離的 DOM 節(jié)點
當(dāng)一個節(jié)點從 DOM 樹中移除時,它被稱為分離,但一些 JavaScript 代碼仍然在引用它。讓我們使用下面的代碼片段檢查分離的 DOM 節(jié)點。通過單擊按鈕,可以將列表元素添加到其父級中并將父級分配給全局變量。簡單來說,全局變量保存著 DOM 引用:
var detachedElement; function createList(){ let ul = document.createElement("ul"); for(let i = 0; i < 5; i++){ ul.appendChild(document.createElement("li")); } detachedElement = ul; } document.getElementById("createList").addEventListener("click", createList);
我們可以使用 heap snapshot 來檢查分離的DOM節(jié)點,可以在Chrome DevTools 的Memory面板中打開Heap snapshots選項:
點擊頁面的按鈕后,點擊下面藍(lán)色的Take snapshot按鈕,我們可以在中間的搜索欄目輸入Detached來過濾結(jié)果以找到分離的DOM節(jié)點,如下所示:
當(dāng)然也可以嘗試使用此方法來識別其他內(nèi)存泄漏。
到此這篇關(guān)于一文搞懂如何避免JavaScript內(nèi)存泄漏的文章就介紹到這了,更多相關(guān)JavaScript內(nèi)存泄漏內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
利用prop-types第三方庫對組件的props中的變量進(jìn)行類型檢測
本篇文章主要介紹了利用prop-types第三方庫對組件的props中的變量進(jìn)行類型檢測的相關(guān)知識,具有很好的參考價值。下面跟著小編一起來看下吧2017-05-05uniapp微信小程序訂閱消息發(fā)送服務(wù)通知超詳細(xì)教程
在使用或開發(fā)小程序過程中,我們會發(fā)現(xiàn)消息通知是非常重要的一個環(huán)節(jié),下面這篇文章主要給大家介紹了關(guān)于uniapp微信小程序訂閱消息發(fā)送服務(wù)通知的相關(guān)資料,需要的朋友可以參考下2023-06-06javascript面向?qū)ο蟪绦蛟O(shè)計高級特性經(jīng)典教程(值得收藏)
這篇文章主要介紹了javascript面向?qū)ο蟪绦蛟O(shè)計高級特性,結(jié)合實例形式詳細(xì)講述了javascript對象的創(chuàng)建,訪問,刪除,對象類型,擴展等,需要的朋友可以參考下2016-05-05uniapp開發(fā)微信小程序自定義頂部導(dǎo)航欄功能實例
uni-app是一個使用Vue.js開發(fā)跨平臺應(yīng)用的前端框架,下面這篇文章主要給大家介紹了關(guān)于uniapp開發(fā)微信小程序自定義頂部導(dǎo)航欄功能的相關(guān)資料,文中通過圖文以及示例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-08-08深入理解requireJS-實現(xiàn)一個簡單的模塊加載器
本篇文章主要介紹了深入理解requireJS-實現(xiàn)一個簡單的模塊加載器,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-01-01