瀏覽器可以快速啟動與關閉,加上 JavaScript 自帶垃圾回收(Garbage Collection),讓不少前端工程師對記憶體議題比較少有警覺。然而長時間運作的 SPA 或記憶體密集的應用,一旦寫法有問題就可能出現難以察覺的記憶體洩漏(Memory Leak),在不知不覺中拖垮網頁效能。

本章會以 V8 引擎為例,看看 JS 的資料是怎麼被儲存的、垃圾回收怎麼運作,以及怎麼避開常見的洩漏寫法。

記憶體的生命週期#

不論程式語言,記憶體的生命週期都是三個階段:

  • 配置(allocation):分配需要的空間。
  • 使用(read/write)。
  • 釋放(free):當不再需要時釋出空間。

JavaScript 把第三步交給 GC,但這不代表開發者完全可以放手不管。

Stack 與 Heap#

V8 把記憶體切成兩塊資料儲存區:Stack 與 Heap。注意這裡指的是「記憶體區域」,而不是資料結構意義上的堆疊或堆。

  • Stack:用來存放 primitive type,如 stringnumberbooleannullundefinedsymbol。同時也維護 Execution Context 的切換。
  • Heap:用來存放 reference type,例如物件。Stack 上只記錄物件在 Heap 中的記憶體位址。
function ironman() {
  let one = "鐵人賽";
  let two = one;
  let three = { author: "Kyle Mo" };
  let four = three;
}

ironman();

onetwo 都是字串,會以 primitive 的形式被處理;threefour 則會在 Heap 中指向同一個物件。這也是為什麼修改 four.author 會同時影響 three

補充說明 V8 對於字串與某些數字會做最佳化(例如 constant pool 或字串 hashmap),實際上 string 會放在 Heap,且相同字串會共用同一個位置。Stack 主要用來支援快速的 Execution Context 切換,因此空間越精簡越好。

V8 的世代式垃圾回收#

V8 會把 Heap 切成兩個區域:

  • New Space(Young Generation):存放新建立、生命短的物件。空間小(約 1–8 MB),GC 速度快、頻率高。
  • Old Space(Old Generation):存放經歷過幾次 GC 仍存活、或是體積較大的物件。空間大、GC 頻率低。

兩個區域分別採用不同的演算法:

  • Young Generation:Scavenge collection。
  • Old Generation:Mark-Sweep / Mark-Compact collection。

Scavenge 演算法#

Scavenge 把 New Space 再切成「物件區」與「空閒區」。新物件先放進物件區,當區域快要爆掉時:

  • 標記出仍然存活的物件。
  • 把存活的物件 copy 到空閒區並排序,避免碎片。
  • 翻轉兩塊區域的角色,繼續服務後續分配。

因為 Copy & Sort 很耗時,所以 New Space 才會刻意設計得小一點。經歷兩次 Scavenge 還活著的物件會被晉升到 Old Generation。

標記-清除(Mark-and-Sweep)與 Mark-Compact#

Old Generation 的物件通常較大,繼續使用 Scavenge 的 copy 策略太奢侈,因此採用標記-清除(Mark-and-Sweep):

  • Mark:從根節點開始遍歷,能被走到的物件視為存活。
  • Sweep:把沒被標記的物件清除。

但 Mark-Sweep 容易留下碎片化的空間,使大型物件無法分配到連續記憶體。Mark-Compact 在標記後額外做一個壓縮(compact)步驟,把存活的物件往一端搬移,整理出連續空間。

Stop-the-world 與 Incremental Marking#

GC 會在 Main Thread 上執行,而 JavaScript 的 single-threaded 特性導致 GC 進行時整個 Script 都會被暫停,這個現象被稱作 Stop-the-world。Old Generation 的回收可能耗時數百毫秒,足以造成肉眼可見的卡頓。

V8 採用 Incremental Marking 將標記階段切成多個小段,讓 GC 與 Script 交錯執行,緩解 Stop-the-world 帶來的影響。

在 JavaScript 中防止 Memory Leak#

GC 會「儘量」回收不再使用的記憶體,但「是否還會被使用」這件事是無法判定的,因此某些寫法會讓 GC 沒辦法正確判斷。常見的洩漏來源包含:

Event Listener#

事件監聽器是前端最常見的洩漏來源。注意:

  • 是否重複註冊。
  • 元件解除掛載時是否正確 removeEventListener

React 中常見的寫法就是在 useEffect 回傳 cleanup function 來移除監聽。

不當使用全域變數#

const express = require("express");
const app = express();

const requestStatusCollection = [];

app.get("/ironman", (req, res) => {
  requestStatusCollection.push(req.status);
  return res.send({});
});

app.listen(3000);

requestStatusCollection 永遠不會被清空,量大後就會變成記憶體無底洞。如果非用全域變數不可,至少要搭配 LRU Cache 等容量受限的結構。

JavaScript 的 hoisting 也會無意間造出全域變數:

function ironman() {
  author = "Kyle Mo";
}

function helloIthome() {
  this.author = "Kyle Mo";
}

const helloIronman = () => {
  this.author = "Kyle Mo";
};

失效的 DOM 參考(Out of DOM references)#

const elementsMap = {
  button: document.getElementById("btn"),
  image: document.getElementById("img"),
};

function removeButton() {
  document.body.removeChild(document.getElementById("btn"));
}

即使 DOM 已被 removeChild 移除,elementsMap 仍持有 reference,因此 GC 不會釋放對應的記憶體。

老舊瀏覽器與不良擴充功能#

舊版 IE 對 circular reference 處理不完善,部分瀏覽器擴充也可能造成洩漏。

Chrome DevTools 的 Memory 工具#

開發者可以在 Chrome DevTools Performance Panel 與 Memory Tab 觀測記憶體用量曲線、強制執行 GC,或拍下 heap snapshot 比較前後差異:

  • 觀察 JS Heap Size 的變化趨勢。
  • 觸發強制 GC 後比較記憶體是否回得來。
  • 比對不同 snapshot 找出殘留物件。

寫法不同,記憶體差很多#

下面以 React 範例呈現兩種更新陣列的方式:

function cheapLoop(arr) {
  for (let i = 0; i < arr.length; i += 1) {
    arr[i].count = i;
  }
}

function expensiveLoop(arr) {
  for (let i = 0; i < arr.length; i += 1) {
    arr[i] = { ...arr[i], count: i };
  }
}

兩者的最終結果一樣,但 expensiveLoop 每次迭代都會建立新的物件。在資料量極大時,瞬間的記憶體峰值可能變成原本的數倍。雖然這些物件之後會被 GC 清掉,但在那之前,頁面已經卡頓了。

注意事項 這並不是說 immutable 寫法不好,一般情境下是合理且安全的。問題出在「短時間內爆量配置物件」,特別是在迴圈或大量資料更新時要小心。

小結#

了解記憶體管理機制嚴格來說不是「優化技巧」,而是讓我們避免效能瓶頸的一條防線。掌握 Stack/Heap、了解 GC 的運作方式、警覺常見的洩漏寫法,才能在面對長時間運作或資料量大的應用時,維持穩定的使用體驗。

原文出處#