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

大家好,我是 CUGGZ。SPA(單頁應用程序)的興起,促使我們更加關注與內存相關的 JavaScript 編碼實踐。如果應用使用的內存越來越多,就會嚴重影響性能,甚至導致瀏覽器的崩潰。下面就來看看 JavaScript 中常見的內存泄漏以及如何避免內存泄漏。

一、什麼是內存泄漏?

JavaScript 就是所謂的垃圾回收語言之一,垃圾回收語言通過定期檢查哪些先前分配的內存仍然可以從應用程序的其他部分 “訪問” 來幫助開發人員管理內存。垃圾回收語言中泄漏的主要原因是不需要的引用。如果你的 JavaScript 應用程序經常發生崩潰、高延遲和性能差,那麼一個潛在的原因可能是內存泄漏。

在 JavaScript 中,內存是有生命週期的:

在 JavaScript 中,對象會保存在堆內存中,可以根據引用鏈從根訪問它們。垃圾收集器是 JavaScript 引擎中的一個後臺進程,用於識別無法訪問的對象、刪除它們並回收內存。

下面是垃圾收集器根到對象的引用鏈示例:

當內存中應該在垃圾回收週期中清理的對象,通過另一個對象的無意引用從根保持可訪問時,就會發生內存泄漏。將冗餘對象保留在內存中會導致應用程序內部使用過多的內存,並可能導致性能下降。

那該如何判斷代碼正在泄漏內存呢?通常,內存泄漏是很難被發現的,並且瀏覽器在運行它時不會拋出任何錯誤。如果注意到頁面的性能越來越差,瀏覽器的內置工具可以幫助我們確定是否存在內存泄漏以及導致內存泄漏的對象。

內存使用檢查最快的方法就是查看瀏覽器的任務管理器。 它們提供了當前在瀏覽器中運行的所有選項卡和進程的概覽。在任務管理器中查看每個選項卡的 JavaScript 內存佔用情況。如果網站什麼都不做,但是 JavaScript 內存使用量卻在逐漸增加,那麼很有可能發生了內存泄漏。

二、常見的內存泄漏

我們可以通過了解在 JavaScript 中如何創建不需要的引用來防止內存泄漏。以下情況就會導致不需要的引用。

1. 意外的全局變量

全局變量始終可以從全局對象(在瀏覽器中,全局對象是 window)中獲得,並且永遠不會被垃圾回收。在非嚴格模式下,以下行爲會導致變量從局部範圍泄露到全局範圍:

(1)爲未聲明的變量賦值

這裏我們給函數中一個未聲明的變量 bar 賦值,這時就會使 bar 成爲一個全局變量:

function foo(arg) {
    bar = "hello world";
}

這就等價於:

function foo(arg) {
    window.bar = "hello world";
}

這樣就會創建一個多餘的全局變量,當執行完 foo 函數之後,變量 bar 仍然會存在於全局對象中:

foo()
window.bar   // hello world

(2)使用指向全局對象的 this。

使用以下方式也會創建一個以外的全局變量:

function foo() {
    this.bar = "hello world";
}

foo();

這裏foo是在全局對象中調用的,所以其this是指向全局對象的(這裏是 window):

window.bar   // hello world

我們可以通過使用嚴格模式 “use strict” 來避免這一切。在 JavaScript 文件的開頭,它將開啓更嚴格的 JavaScript 解析模式,從而防止意外的創建全局變量。

需要特別注意那些用於臨時存儲和處理大量信息的全局變量。如果必須使用全局變量存儲數據,就使用全局變量存儲數據,但在不再使用時,就手動將其設置爲 null,或者在處理完後重新分配。否則的話,請儘可能的使用局部變量。

2. 計時器

使用 setTimeout 或 setInterval 引用回調中的某個對象是防止對象被垃圾收集的最常見方法。如果我們在代碼中設置了循環計時器,只要回調是可調用的,計時器回調中對對象的引用就會保持活動狀態。

在下面的示例中,只有在清除計時器後,才能對數據對象進行垃圾收集。由於我們沒有對 setInterval 的引用,所以它永遠無法被清除和刪除數據。hugeString 會一直保存在內存中,直到應用程序停止,儘管從未使用過。

function setCallback() {
  const data = {
    counter: 0,
    hugeString: new Array(100000).join('x')
  };

  return function cb() {
    data.counter++;   // data對象是回調範圍的一部分
    console.log(data.counter);
  }
}

setInterval(setCallback(), 1000);

當執行這段代碼時,就會每秒輸出一個數字:

那我們如何去阻止他呢?尤其是在回調的壽命未定義或不確定的情況下:

function setCallback() {
  // 將數據對象解包
  let counter = 0;
  const hugeString = new Array(100000).join('x'); // 在setCallback返回時被刪除
  
  return function cb() {
    counter++; // 只有計數器counter是回調範圍的一部分
    console.log(counter);
  }
}

const timerId = setInterval(setCallback(), 1000); // 保存定時器的ID

// 合適的時機清除定時器
clearInterval(timerId);

3. 閉包

我們知道,函數範圍內的變量在函數退出調用堆棧後,如果函數外部沒有任何指向它們的引用,則會被清除。儘管函數已經完成執行,其執行上下文和變量環境早已消失,但閉包將保持變量的引用和活動狀態。

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);

顯而易見,這裏就形成了一個閉包。其輸出結果如下:

這裏,potentiallyHugeArray 永遠不會從任何函數返回,也無法訪問,但它的大小可能會無限增長,這取決於調用函數 inner() 的次數。

那該如何防止這個問題呢?閉包是不可避免的,也是 JavaScript 不可或缺的一部分,因此重要的是:

4. 事件監聽器

活動事件偵聽器將防止在其範圍內捕獲的所有變量被垃圾收集。添加後,事件偵聽器將一直有效,直到:

對於某些類型的事件,它會一直保留到用戶離開頁面,就像應該多次單擊的按鈕一樣。但是,有時我們希望事件偵聽器執行一定次數。

const hugeString = new Array(100000).join('x');

document.addEventListener('keyup'function() { // 匿名內聯函數,無法刪除它
  doSomething(hugeString); // hugeString 將永遠保留在回調的範圍內
});

在上面的示例中,匿名內聯函數用作事件偵聽器,這意味着不能使用 removeEventListener() 刪除它。同樣,document 不能被刪除,因此只能使用 listener 函數以及它在其範圍內保留的內容,即使只需要啓動一次。

那該如何防止這個問題呢?一旦不再需要,我們應該通過創建指向事件偵聽器的引用並將其傳遞給 removeEventListener() 來註銷事件偵聽器。

function listener() {
  doSomething(hugeString);
}

document.addEventListener('keyup', listener); 
document.removeEventListener('keyup', listener);

如果事件偵聽器只能執行一次,addEventListener() 可以接受第三個參數,這是一個提供附加選項的對象。假定將 {once:true} 作爲第三個參數傳遞給 addEventListener() ,則偵聽器函數將在處理一次事件後自動刪除。

document.addEventListener('keyup'function listener() {
  doSomething(hugeString);
}{once: true});

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;

console.log(mapCache); // {{} ='Peter has an id of 12345'{} ='Mark has an id of 54321'}

在上面的示例中,緩存仍然保留 user_1 對象。因此,我們需要將那些永遠不會被重用的變量從緩存中清除。

可以使用 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){
  // ...

  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 節點具有來自 JavaScript 的直接引用,它將防止對其進行垃圾收集,即使在從 DOM 樹中刪除該節點之後也是如此。

在下面的示例中,創建了一個 div 元素並將其附加到 document.body 中。removeChild() 就無法按預期工作,堆快照將顯示分離的 HTMLDivElement,因爲仍有一個變量指向 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();

要解決此問題,可以將 DOM 引用移動到本地範圍。在下面的示例中,在函數 appendElement() 完成後,將刪除指向 DOM 元素的變量。

function createElement() {...}

// DOM引用在函數範圍內
function appendElement() {
  const detachedDiv = createElement();
  document.body.appendChild(detachedDiv);
}

appendElement();

function deleteElement() {
  document.body.removeChild(document.getElementById('detached'));
}

deleteElement();

三、識別內存泄漏

調試內存問題是一項複雜的工作,我們可以使用 Chrome DevTools 來識別內存圖和一些內存泄漏,我們需要關注以下兩個方面:

1. 使用性能分析器可視化內存消耗

以下面的代碼爲例,有兩個按鈕:打印和清除。點擊 “打印” 按鈕,通過創建 paragraph 節點並將大字符串設置到全局,將 1 到 10000 的數字追加到 DOM 中。

“清除”按鈕會清除全局變量並覆蓋 body 的正文,但不會刪除單擊 “打印” 時創建的節點:

<!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>

當每次點擊打印按鈕時,JavaScript Heap 都會出現藍色的峯值,並逐漸增加,這是因爲 JavaScript 正在創建 DOM 節點並字符串添加到全局數組。當點擊清除按鈕時,JavaScript Heap 就變得正常了。除此之外,可以看到節點的數量(綠色的線)一直在增加,因爲我們並沒有刪除這些節點。

在實際的場景中,如果觀察到內存持續出現峯值,並且內存消耗一直沒有減少,那可能存在內存泄露。

2. 識別分離的 DOM 節點

當一個節點從 DOM 樹中移除時,它被稱爲分離,但一些 JavaScript 代碼仍然在引用它。讓我們使用下面的代碼片段檢查分離的 DOM 節點。通過單擊按鈕,可以將列表元素添加到其父級中並將父級分配給全局變量。簡單來說,全局變量保存着 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 節點,可以在 Chrome DevTools 的Memory面板中打開Heap snapshots選項:

點擊頁面的按鈕後,點擊下面藍色的 Take snapshot 按鈕,我們可以在中間的搜索欄目輸入 Detached 來過濾結果以找到分離的 DOM 節點,如下所示:

當然也可以嘗試使用此方法來識別其他內存泄漏。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pArOA6yXIx0NNsU1BXF62g