瀏覽器可以快速啟動與關閉,加上 JavaScript 自帶垃圾回收(Garbage Collection),讓不少前端工程師對記憶體議題比較少有警覺。然而長時間運作的 SPA 或記憶體密集的應用,一旦寫法有問題就可能出現難以察覺的記憶體洩漏(Memory Leak),在不知不覺中拖垮網頁效能。
本章會以 V8 引擎為例,看看 JS 的資料是怎麼被儲存的、垃圾回收怎麼運作,以及怎麼避開常見的洩漏寫法。
記憶體的生命週期#
不論程式語言,記憶體的生命週期都是三個階段:
- 配置(allocation):分配需要的空間。
- 使用(read/write)。
- 釋放(free):當不再需要時釋出空間。
JavaScript 把第三步交給 GC,但這不代表開發者完全可以放手不管。
Stack 與 Heap#
V8 把記憶體切成兩塊資料儲存區:Stack 與 Heap。注意這裡指的是「記憶體區域」,而不是資料結構意義上的堆疊或堆。
- Stack:用來存放 primitive type,如
string、number、boolean、null、undefined、symbol。同時也維護 Execution Context 的切換。 - Heap:用來存放 reference type,例如物件。Stack 上只記錄物件在 Heap 中的記憶體位址。
function ironman() {
let one = "鐵人賽";
let two = one;
let three = { author: "Kyle Mo" };
let four = three;
}
ironman();one 與 two 都是字串,會以 primitive 的形式被處理;three 與 four 則會在 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 的運作方式、警覺常見的洩漏寫法,才能在面對長時間運作或資料量大的應用時,維持穩定的使用體驗。