如何避免JavaScript中的記憶體洩漏?

葡萄城技術團隊發表於2023-10-30

前言

過去,我們瀏覽靜態網站時無須過多關注記憶體管理,因為載入新頁面時,之前的頁面資訊會從記憶體中刪除。 然而,隨著單頁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 記憶體洩漏問題可能是一項非常艱鉅的任務。瞭解記憶體洩漏的常見原因以防止它們發生是非常重要的。在涉及記憶體和效能方面,最重要的是使用者體驗,這才是最重要的。


[Redis從入門到實踐](https://gcdn.grapecity.com.cn/course-273.html)
[ 一節課帶你搞懂資料庫事務!](https://gcdn.grapecity.com.cn/course-58.html)
[Chrome開發者工具使用教程](https://gcdn.grapecity.com.cn/course-78.html)

擴充套件連結:

如何在前端系統中使用甘特圖

視窗函式大揭秘!輕鬆計算資料累計佔比,玩轉資料分析的絕佳利器

探秘移動端BI:發展歷程與應用前景解析

相關文章