JavaScript – 效能與最佳化:記憶體管理與垃圾回收
簡介
在前端開發中,效能 常常是使用者體驗的關鍵。即使演算法本身已經很快,若程式持續佔用過多記憶體或未能及時釋放不再使用的物件,瀏覽器的回應速度仍會受到嚴重影響。
JavaScript 採用自動垃圾回收 (Garbage Collection, GC) 機制,開發者不需要手動釋放記憶體,但了解 記憶體管理的原理、常見的記憶體洩漏 以及 最佳化技巧,才能寫出既快又不會「吃掉」使用者裝置資源的程式碼。
本篇文章將從 VM(執行環境)如何分配與回收記憶體、哪些情況會導致記憶體無法回收、以及 實務上可採取的最佳化手法 逐一說明,並提供多個可直接套用的程式碼範例,幫助初學者到中級開發者建立正確的記憶體觀念與實作技巧。
核心概念
1. JavaScript 記憶體模型概述
| 項目 | 說明 |
|---|---|
| 堆 (Heap) | 用來存放 物件、陣列、函式 以及其他參考型別。分配與釋放皆由 GC 負責。 |
| 棧 (Stack) | 用來儲存 原始值(Number、String、Boolean、undefined、null)以及執行上下文的呼叫資訊。棧的空間在函式返回時自動釋放。 |
| 執行上下文 | 每一次函式呼叫都會產生一個上下文,包含變數環境、作用域鏈與 this 參考。上下文結束即會被棧彈出,相關的局部變數會被清除。 |
重點:只有 堆上的物件 需要 GC 處理;棧上的原始值會在作用域結束時自動回收。
2. 垃圾回收的兩大演算法
標記-清除 (Mark‑and‑Sweep)
- 標記階段:從根 (global objects、執行上下文、閉包等) 開始,遍歷所有可達 (reachable) 的物件並標記。
- 清除階段:未被標記的物件被視為「垃圾」,其佔用的記憶體會被回收。
分代收集 (Generational GC)
- 新生代 (Young Generation):大部分短命物件會先被放在此區,使用 Scavenge (複製式) 方式快速回收。
- 老年代 (Old Generation):長命物件會被晉升到此區,使用 標記-清除 或 標記-整理。
實務觀點:若程式產生大量短暫物件(如大量
Array.map、JSON.parse),新生代 GC 會頻繁觸發;適當的物件重用可減少 GC 次數,提升效能。
3. 什麼是「可達」物件?
- 根 (Root):全域變數 (
window、globalThis)、執行上下文的變數環境、閉包中的變數等。 - 可達 (Reachable):從根出發,透過屬性或陣列索引能夠「走到」的任何物件。
- 不可達 (Unreachable):沒有任何路徑可以從根到達的物件,即為垃圾回收的目標。
4. 常見的記憶體洩漏類型
| 類型 | 典型情境 | 防範方式 |
|---|---|---|
| 全域變數 | 忘記使用 let/const,直接賦值給未宣告的變數。 |
嚴格模式 ('use strict')、Lint 工具。 |
| 閉包持有舊參考 | 在迭代中建立大量閉包,且閉包內引用了外層的大型物件。 | 使用 for…of、forEach 時避免在迴圈內建立不必要的閉包。 |
| 事件監聽器未移除 | 動態建立元素並註冊事件,卻在移除元素時未 removeEventListener。 |
在 componentWillUnmount、useEffect cleanup 中統一解除。 |
| 定時器 / 週期任務 | setInterval/setTimeout 持續執行但未在適當時機 clearInterval/clearTimeout。 |
保存 timer ID,必要時手動清除。 |
| DOM 參考循環 | 透過 WeakMap / WeakSet 以外的結構,將 DOM 元素作為鍵值保存,導致循環參考。 |
使用 WeakMap、WeakSet 或在移除元素前手動刪除引用。 |
程式碼範例
範例 1:全域變數造成的記憶體洩漏
// ❌ 錯誤寫法:未使用 let/const,會在全域成為隱性變數
function loadData() {
data = fetch('/api/data'); // data 成為 window.data,永遠不會被 GC
}
loadData();
修正:
function loadData() {
const data = fetch('/api/data'); // 只在函式範圍內存在,結束後可被回收
}
loadData();
說明:在嚴格模式下,未宣告變數會直接拋出
ReferenceError,可有效防止此類錯誤。
範例 2:閉包持有不必要的參考
function createHandlers(items) {
const handlers = [];
for (let i = 0; i < items.length; i++) {
// 每一次迭代都建立一個閉包,且閉包內仍持有整個 items 陣列的參考
handlers.push(function () {
console.log(items[i]); // items 整個陣列無法被 GC,即使只需要單一元素
});
}
return handlers;
}
優化:利用 Array.prototype.map 或直接傳遞需要的值,避免捕獲整個陣列。
function createHandlers(items) {
return items.map(item => () => console.log(item));
}
說明:此寫法每個閉包只捕獲單一
item,不會保留整個items陣列,讓先前的陣列能在不再使用時被回收。
範例 3:事件監聽器的正確移除
function attach() {
const btn = document.getElementById('myBtn');
function onClick(e) {
console.log('clicked');
}
btn.addEventListener('click', onClick);
// 假設稍後要移除按鈕
setTimeout(() => {
btn.remove(); // 只移除 DOM,onClick 仍被引用
// btn.removeEventListener('click', onClick); // ← 必須手動解除
}, 5000);
}
attach();
使用 WeakMap 儲存監聽器(自動解除):
const listenerMap = new WeakMap();
function attach() {
const btn = document.getElementById('myBtn');
const onClick = () => console.log('clicked');
btn.addEventListener('click', onClick);
listenerMap.set(btn, onClick);
}
// 在需要時自動清理
function detach(btn) {
const handler = listenerMap.get(btn);
if (handler) {
btn.removeEventListener('click', handler);
listenerMap.delete(btn);
}
}
說明:
WeakMap的鍵是弱引用,當btn被移除且不再被其他變數持有時,對應的 entry 會自動被 GC,避免手動追蹤。
範例 4:使用 WeakRef 減少記憶體佔用(ES2021)
// 假設有一個大型物件 cache
class ImageCache {
constructor() {
this.cache = new Map(); // key: url, value: WeakRef(image)
}
get(url) {
const ref = this.cache.get(url);
if (ref) {
const img = ref.deref(); // 可能已被回收
if (img) return img;
}
// 若不存在或已被回收,重新載入
const img = new Image();
img.src = url;
this.cache.set(url, new WeakRef(img));
return img;
}
}
說明:
WeakRef讓快取的物件不會阻止 GC,只有在需要時才嘗試取得,適用於大型或不常使用的資源。
範例 5:避免過度產生暫時物件(Array 迭代最佳化)
// ❌ 每次迭代都產生新陣列,會觸發大量新生代 GC
function sumSquares(arr) {
return arr.map(x => x * x).reduce((a, b) => a + b, 0);
}
// ✅ 直接累加,減少中間陣列
function sumSquares(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
const v = arr[i];
total += v * v;
}
return total;
}
說明:在大量資料處理時,盡量避免產生不必要的暫時陣列或字串,可以顯著降低 GC 負擔。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 最佳實踐 |
|---|---|---|
| 過度使用全域變數 | 讓變數無法被 GC,且可能被其他程式碼意外改寫。 | 使用模組化 (import/export) 或 IIFE,並在 strict mode 下開發。 |
| 忘記解除計時器 | setInterval 持續佔用記憶體,尤其在 SPA 中切換頁面時。 |
在組件銷毀或離開頁面時 clearInterval / clearTimeout。 |
| 大量短暫物件 | 觸發頻繁的 新生代 GC,造成卡頓。 | 重用物件、使用 Object Pool、或改寫為原地演算法。 |
| 閉包捕獲大型容器 | 閉包會把整個容器「凍結」在記憶體中。 | 僅捕獲必要的值,或使用 箭頭函式 搭配參數傳遞。 |
| DOM 參考循環 | 透過普通 Map 保存 DOM 節點,導致循環參考。 |
使用 WeakMap / WeakSet,或在移除節點前手動清除引用。 |
具體的最佳化技巧
使用
const/let取代varvar會自動提升,容易形成意外的全域變數。
適時使用
null釋放引用let largeObj = { /* huge data */ }; // 使用完畢 largeObj = null; // 讓 GC 知道此物件已不再需要利用
requestIdleCallback延遲非關鍵工作- 把不需要即時完成的計算或資料整理放到閒置時間,減少主執行緒的壓力。
在大型迴圈中使用
for而非forEachforEach內部會產生回呼函式,增加閉包開銷。
避免在頻繁呼叫的函式內部宣告大量局部變數
- 可將重複使用的物件搬到外層,或使用 物件池 重用。
實際應用場景
1. 單頁應用 (SPA) 中的路由切換
在 React、Vue、Angular 等框架中,頁面切換往往伴隨大量 組件掛載/卸載。若每個組件在 componentDidMount / mounted 時註冊事件或計時器,卻忘記在 componentWillUnmount / unmounted 時清除,將導致 記憶體持續上升,最終造成瀏覽器卡頓甚至崩潰。
解法:建立統一的「清理機制」:
// React Hook 範例
import { useEffect } from 'react';
function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id); // unmount 時自動清除
}, [callback, delay]);
}
2. 大型資料視覺化 (Chart / Table)
使用 D3、Chart.js 等套件時,會產生大量 SVG/Canvas 元素 與 資料點物件。若每次重新渲染都建立新物件而不回收舊物件,記憶體會快速飆升。
最佳實踐:
- 資料重用:僅更新變更的屬性,避免整體重建。
- 使用
WeakMap儲存圖形與資料的對應:當圖形被移除時,對應的資料物件可自行回收。
const shapeDataMap = new WeakMap();
function renderChart(data) {
const svg = d3.select('#chart');
const circles = svg.selectAll('circle')
.data(data, d => d.id); // key function,減少不必要的 enter/exit
circles.enter()
.append('circle')
.attr('r', 5)
.each(function(d) { shapeDataMap.set(this, d); });
circles.exit().remove();
}
3. 長時間運行的 Web Worker
在背景計算(如影像處理、加密)時,Web Worker 可能持續存在數分鐘甚至更久。若在 Worker 中不斷產生大型緩衝區(ArrayBuffer、Blob)而未釋放,會導致 主執行緒的記憶體壓力。
最佳做法:
- 使用
postMessage時 轉移 (transfer)ArrayBuffer,讓所有權直接交給主執行緒,Worker 端的記憶體會即時釋放。 - 完成任務後
self.close()結束 Worker。
// worker.js
self.onmessage = function (e) {
const buffer = e.data; // 已是 Transferable
// 處理...
self.postMessage(buffer, [buffer]); // 再次轉移回主執行緒
self.close(); // 任務結束,釋放所有資源
};
總結
- JavaScript 的垃圾回收 主要依賴 標記‑清除 與 分代收集,只要物件 不可達 就會被回收。
- 記憶體洩漏 多因全域變數、閉包、未移除的事件監聽器、持續的計時器或 DOM 循環參考等造成。
- 最佳化策略 包括:使用
let/const、嚴格模式、WeakMap/WeakSet、適時null釋放、避免過度產生暫時物件、以及在框架中正確清理資源。 - 在 SPA、資料視覺化、長時間運行的 Worker 等實務情境下,遵循上述原則可以顯著降低記憶體佔用、減少 GC 造成的卡頓,提升使用者體驗。
掌握 記憶體管理的底層原理,再配合 具體的程式碼實踐,即可在開發 JavaScript 應用時兼顧功能與效能,寫出更穩定、更高效的程式碼。祝開發順利! 🚀