如何避免JavaScript中的內存泄漏?

2024年2月6日 22点热度 0人点赞

前言

過去,我們瀏覽靜態網站時無須過多關註內存管理,因為加載新頁面時,之前的頁面信息會從內存中刪除。 然而,隨著單頁Web應用(SPA)的興起,應用程序消耗的內存越來越多,這不僅會降低瀏覽器性能,甚至會導致瀏覽器卡死。

因此,在編碼實踐中,開發人員需要更加關註與內存相關的內容。因此,小編今天將為大傢介紹JavaScript內存泄漏的編程模式,並提供一些內存管理的改進方法。

什麼是內存泄漏以及如何發現它?

什麼是內存泄漏?

JavaScript對象被保存在瀏覽器內存的堆中,並通過引用方式訪問。值得一提的是,JavaScript垃圾回收器則運行於後臺,並通過識別無法訪問的對象來釋放並恢復底層存儲空間,從而保證JavaScript引擎的良好運行狀態。

當內存中的對象在垃圾回收周期中應該被清理時,若它們被另一個仍然存在於內存中的對象通過一個意外的引用所持有,就會引發內存泄漏問題。這種情況下,冗餘對象會繼續占據內存空間,導致應用程序消耗過多的內存資源,並可能導致性能下降和表現不佳的情況出現。因此,及時清理無用對象並釋放內存資源是至關重要的,以確保應用程序的正常運行和良好的性能表現。

如何發現內存泄漏?

那麼如何知道代碼中是否存在內存泄漏?內存泄漏往往隱蔽且很難檢測和定位。即使代碼中存在內存泄漏,瀏覽器在運行時也不會返回任何錯誤。如果註意到頁面的性能逐漸下降,可以使用瀏覽器內置的工具來確定是否存在內存泄漏以及是哪個對象引起的。

任務管理器(不要與操作系統的任務管理器混淆)提供了瀏覽器中所有選項卡和進程的概覽。Chrome 中,可以通過在 Linux 和 Windows 操作系統上按 Shift Esc 來打開任務管理器;而在 Firefox 中,通過在地址欄中鍵入 about:performance 則可以訪問內置的管理器,它可以顯示每個標簽的 JavaScript 內存占用情況。如果網站停留在那裡什麼都不做,但 JavaScript內存使用量逐漸增加,那很可能是存在內存泄漏。

開發者工具提供了一些先進的內存管理方法,例如,使用Chrome瀏覽器的性能記錄工具,可以對頁面的性能進行可視化分析。在這個過程中,可以通過一些指標來判斷是否存在內存泄漏問題,比如堆內存使用量增加的情況,並及時采取措施解決這些問題,以確保應用程序的正常運行和良好的性能表現。

另外,通過Chrome和Firefox的開發者工具提供的內存工具,可以進一步探索內存使用情況。隊列內存使用快照的比較可以顯示在兩個快照之間分配了多少內存以及分配的位置,並提供額外信息來幫助識別代碼中存在問題的對象。這些工具為開發者提供了便利,能夠更好地進行內存管理和性能優化,提高應用程序的質量和性能。

JavaScript代碼中常見的內存泄漏的常見來源:

研究內存泄漏問題就相當於尋找符合垃圾回收機制的編程方式,有效避免對象引用的問題。下面小編就為大傢介紹幾個常見的容易導致內存泄漏的地方:

1.全局變量

全局變量始終存儲在根目錄下,且永遠不會被回收。而在JavaScript的開發中,一些錯誤會導致局部變量被轉換到了全局,尤其是在非嚴格的代碼模式下。下面是兩個常見的局部變量被轉化到全局變量的情況:

  1. 為未聲明的變量賦值
  2. 使用this指向全局對象。
function createGlobalVariables() {
  leaking1 = 'I leak into the global scope'; // 為未聲明的變量賦值
  this.leaking2 = 'I also leak into the global scope'; // 使用this指向全局對象
};
createGlobalVariables();
window.leaking1; 
window.leaking2; 

註意:嚴格模式("use strict")將幫助您避免上面示例中的內存泄漏和控制臺錯誤。

2.閉包

函數中定義的變量會在函數退出調用棧並且在函數外部沒有指向它的引用時被清除。而閉包則會保持被引用的變量一直存在,即便函數的執行已經終止。

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)

在這個例子中,potentiallyHugeArray從未被任何函數返回,也無法被訪問,但它的大小會隨著調用 inner 方法的次數而增長。

3.定時器

在JavaScript中,使用使用 setTimeout 或 setInterval函數引用對象是防止對象被垃圾回收的最常見方法。當在代碼中設置循環定時器(可以使 setTimeout 表現得像 setInterval,即使其遞歸)時,隻要回調可調用,定時器回調對象的引用就會永遠保持活動狀態。

例如下面的這段代碼,隻有在移除定時器後,data對象才會被垃圾回收。在沒有移除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?

那麼應該如何避免上述這種情況的發生呢?可以從以下兩個方法入手:

  1. 註意定時器回調引用的對象。
  2. 必要時取消定時器。

如下方的代碼所示:

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

4.事件監聽

活動的事件監聽器會阻止其范圍內的所有變量被回收。一旦添加,事件監聽器會一直生效,直到下面兩種情況的發生:

  1. 通過 removeEventListener() 移除。
  2. 相關聯的 DOM 元素被移除。

在下面的示例中,使用匿名內聯函數作為事件監聽器,這意味著它不能與 removeEventListener() 一起使用。此外,由於document 不能被移除,觸發方法中的內容會一直駐留內存,即使隻使用它觸發一次。

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()釋放監聽器:

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

5.緩存

如果不斷向緩存中添加內容,而未使用的對象也沒有移除,也沒有限制緩存的大小,那麼緩存的大小就會無限增長:

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

為了解決這個問題,需要清除不需要的緩存:

一種有效的解決內存泄漏問題的方法是使用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

結論

對於復雜的應用程序,檢測和修復 JavaScript 內存泄漏問題可能是一項非常艱巨的任務。了解內存泄漏的常見原因以防止它們發生是非常重要的。在涉及內存和性能方面,最重要的是用戶體驗,這才是最重要的。