JS 常見記憶體流失原因與解決 循環參照與監聽器管理實務

me
林彥成
2019-10-01 | 4 min.
文章目錄
  1. 1. 什麼是 JavaScript Memory Leak 及其成因?
  2. 2. Memory Leak 原因
    1. 2.1. 循環參照
      1. 2.1.1. Memory Leak 工具推薦
    2. 2.2. 事件循環監聽
    3. 2.3. 存取全域變數
  3. 3. Memory Leak 解決
  4. 4. FAQ:JavaScript 記憶體流失常見問題
    1. 4.1. Q1:JavaScript 的垃圾回收 (GC) 不是自動的嗎?為什麼還會洩漏?
    2. 4.2. Q2:如何利用 Chrome DevTools 偵測網頁的記憶體流失?
    3. 4.3. Q3:SPA (單頁應用程式) 中最容易導致記憶體流失的行為是什麼?

什麼是 JavaScript Memory Leak 及其成因?

JavaScript Memory Leak (記憶體流失) 是指網頁應用程式中已不再需要的物件,卻因仍被引用而導致 垃圾回收機制 (GC) 無法將其釋放,最終造成記憶體佔用持續增加。高品質的 JavaScript 記憶體管理 需注意三大成因:1. 循環參照:兩個物件相互引用形成封閉鏈;2. 事件監聽器移除 不當:在元件銷毀後仍保留全域監聽(如 window.addEventListener);3. 全域變數污染:意外建立的變數持續駐留。解決方案包含利用 Chrome DevTools 記憶體分析 抓取快照、手動將變數設為 null 以及使用 madge 等工具檢測 循環參照解決 方案,確保網站的高效運行。


在 JavaScript Web 應用程式的開發過程中,記憶體流失 (Memory Leak) 是一個常見且可能嚴重影響效能的問題。當 JavaScript 引擎的記憶體回收 (Garbage Collection) 機制無法識別並回收不再使用的物件時,就會發生記憶體流失,導致應用程式佔用的記憶體持續增加,最終可能造成網站卡頓甚至崩潰。

本文將深入探討 JavaScript 環境中常見的記憶體流失原因,並提供有效的解決策略,幫助開發者優化網頁效能。

Memory Leak 原因

記憶體流失 (Memory Leak) 發生的原因,依照程式語言的回收機制可分成兩種:

  1. 沒有回收機制: C 或是 C++ 在存取與 process 獨立的資源後要記得手動釋放
  2. 有回收機制: 即使像 Java 或 JavaScript 這類有回收機制的語言,仍有可能因撰寫疏忽造成記憶體無法被自動回收,常見記憶體流失(Memory leak)的情境如下:
    • 循環參照
    • 事件循環監聽
    • 存取全域變數

循環參照

同時太多地方去存取同個資源,對自動回收機制來說,就無法確定資源什麼時候沒有被使用。舉例生活上的例子來說:

  1. A 需要今天下午抄 B 的答案才能交作業
  2. B 需要今天下午抄 A 的答案才能交作業

在 JavaScript 中,當兩個或多個物件相互引用,形成一個封閉的引用鏈,即使這些物件已經不再被外部程式碼使用,記憶體回收機制也可能因為這些內部引用而無法將它們釋放。

這種情況下,這些相互引用的物件及其佔用的記憶體就會持續存在,形成記憶體流失。了解循環參照的本質對於避免 JavaScript 應用中的潛在效能問題至關重要。

Memory Leak 工具推薦

這邊推薦一個方便的套件叫做 madge 可以幫我們產生圖像化的參照圖。

1
2
3
4
5
6
7
const madge = require("madge");

madge("path/to/app.js")
.then((res) => res.image("path/to/image.svg"))
.then((writtenImagePath) => {
console.log("Image written to " + writtenImagePath);
});

紅色就代表有循環參照 (circular dependencies) 產生
Madge 產生圖像化的循環參照圖

事件循環監聽

在程式的撰寫上,可能因為疏忽就一直增加監聽。舉例生活上的例子來說:

  1. A 收到問題後,請 B 幫忙查詢問題
  2. B 收到問題後,請 C 幫忙查詢問題
  3. C 收到問題後,請 A 幫忙查詢問題

舉 Socket.IO 的例子來說,主要是以下兩個功能:

  • 監聽訂閱的訊息
  • 針對監聽到的訊息再發送訊息

在這兩個功能互動的過程中,如果需要和 React.js 搭配使用,就要注意 Rerender 時:

  • 是否重複產生新的 socket
  • 是否重複監聽事件

在 JavaScript 應用中,尤其是在單頁應用程式 (SPA) 或使用框架如 React.js 時,若沒有妥善管理事件監聽器,很容易造成記憶體流失

當一個元件被銷毀但其綁定的事件監聽器仍未被移除時,該監聽器所引用的閉包 (closure) 會阻止相關的 DOM 元素和資料物件被記憶體回收。這會導致即使頁面或元件已經不存在,相關的記憶體仍被佔用,隨著時間累積,嚴重影響網站效能。

因此,在元件生命週期結束時移除所有事件監聽器是避免事件循環監聽造成記憶體流失的關鍵最佳實踐。

存取全域變數

避免 closure 去存取到全域變數,像是 setInterval 遇上 closure。

1
2
3
4
function foo(arg) {
bar = "this is a hidden global variable";
// window.bar = "this is a hidden global variable";
}

在 JavaScript 中,變數若未經 var, let, 或 const 宣告就直接賦值,會自動變成全域物件 (例如瀏覽器環境中的 window 物件) 的屬性。

這些意外的全域變數會一直存在於記憶體中,直到頁面卸載,即便它們的用途已經結束,也無法被記憶體回收

此外,閉包 (closure) 若不慎捕捉到對外部作用域中全域變數的引用,也可能導致相關資源無法釋放,進而引發記憶體流失

因此,嚴格限制全域變數的使用,並確保閉包中的引用得到妥善管理,是預防 JavaScript 記憶體流失的重要環節。

Memory Leak 解決

  1. 使用 delete 和將變數設為 null,手動告訴機器這個物件沒有使用了
1
2
3
4
5
var myVar = "Hello";
var myVar1 = myVar;
myVar = null;
delete myVar;
console.log(myVar1);
  1. 利用開發者工具中的快照,簡單用法就是使用一陣子之後重新抓一次快照,觀察記憶體有沒有上升太多
  2. 事件中的 listener 可以放個 console.log('避免重複監聽') 來暴力觀察

FAQ:JavaScript 記憶體流失常見問題

Q1:JavaScript 的垃圾回收 (GC) 不是自動的嗎?為什麼還會洩漏?

A:GC 雖然是自動的,但它判斷物件是否需要回收的依據是「可達性 (Reachability)」。如果一個物件在邏輯上已經不再使用,但因為開發者撰寫疏忽(如:忘記移除全域事件監聽、閉包捕捉了不再需要的變數),導致該物件在引用鏈中仍是「可達的」,GC 就無法將其釋放,從而產生 JavaScript Memory Leak

Q2:如何利用 Chrome DevTools 偵測網頁的記憶體流失?

A:高品質的偵測方法是利用 DevTools 的 Memory 面板。您可以先抓取一個基準快照 (Heap Snapshot),接著在網頁執行一些操作(如:切換分頁、打開彈窗),最後再次抓取快照。透過「Comparison」功能對比兩個快照間的物件增量,如果發現某些應被銷毀的元件(如 ReactComponent)仍存在於記憶體中,那就是發生了 記憶體流失

Q3:SPA (單頁應用程式) 中最容易導致記憶體流失的行為是什麼?

A:在 SPA 中,由於頁面不會刷新,記憶體洩漏會不斷累積。最常見的錯誤是 事件監聽器移除 不完全。例如在 useEffect 中啟用了 setInterval 或監聽了 window.scroll,卻沒有在 Cleanup Function 中執行 clearIntervalremoveEventListener。這會導致即使切換了頁面,舊頁面的邏輯仍持續運行並佔用記憶體。


參考連結:


喜歡這篇文章,請幫忙拍拍手喔 🤣