本文 AI 產出,尚未審核

JavaScript – 效能與最佳化:記憶體管理與垃圾回收


簡介

在前端開發中,效能 常常是使用者體驗的關鍵。即使演算法本身已經很快,若程式持續佔用過多記憶體或未能及時釋放不再使用的物件,瀏覽器的回應速度仍會受到嚴重影響。
JavaScript 採用自動垃圾回收 (Garbage Collection, GC) 機制,開發者不需要手動釋放記憶體,但了解 記憶體管理的原理常見的記憶體洩漏 以及 最佳化技巧,才能寫出既快又不會「吃掉」使用者裝置資源的程式碼。

本篇文章將從 VM(執行環境)如何分配與回收記憶體哪些情況會導致記憶體無法回收、以及 實務上可採取的最佳化手法 逐一說明,並提供多個可直接套用的程式碼範例,幫助初學者到中級開發者建立正確的記憶體觀念與實作技巧。


核心概念

1. JavaScript 記憶體模型概述

項目 說明
堆 (Heap) 用來存放 物件陣列函式 以及其他參考型別。分配與釋放皆由 GC 負責。
棧 (Stack) 用來儲存 原始值(Number、String、Boolean、undefined、null)以及執行上下文的呼叫資訊。棧的空間在函式返回時自動釋放。
執行上下文 每一次函式呼叫都會產生一個上下文,包含變數環境、作用域鏈與 this 參考。上下文結束即會被棧彈出,相關的局部變數會被清除。

重點:只有 堆上的物件 需要 GC 處理;棧上的原始值會在作用域結束時自動回收。

2. 垃圾回收的兩大演算法

  1. 標記-清除 (Mark‑and‑Sweep)

    • 標記階段:從根 (global objects、執行上下文、閉包等) 開始,遍歷所有可達 (reachable) 的物件並標記。
    • 清除階段:未被標記的物件被視為「垃圾」,其佔用的記憶體會被回收。
  2. 分代收集 (Generational GC)

    • 新生代 (Young Generation):大部分短命物件會先被放在此區,使用 Scavenge (複製式) 方式快速回收。
    • 老年代 (Old Generation):長命物件會被晉升到此區,使用 標記-清除標記-整理

實務觀點:若程式產生大量短暫物件(如大量 Array.mapJSON.parse),新生代 GC 會頻繁觸發;適當的物件重用可減少 GC 次數,提升效能。

3. 什麼是「可達」物件?

  • 根 (Root):全域變數 (windowglobalThis)、執行上下文的變數環境、閉包中的變數等。
  • 可達 (Reachable):從根出發,透過屬性或陣列索引能夠「走到」的任何物件。
  • 不可達 (Unreachable):沒有任何路徑可以從根到達的物件,即為垃圾回收的目標。

4. 常見的記憶體洩漏類型

類型 典型情境 防範方式
全域變數 忘記使用 let/const,直接賦值給未宣告的變數。 嚴格模式 ('use strict')、Lint 工具。
閉包持有舊參考 在迭代中建立大量閉包,且閉包內引用了外層的大型物件。 使用 for…offorEach 時避免在迴圈內建立不必要的閉包。
事件監聽器未移除 動態建立元素並註冊事件,卻在移除元素時未 removeEventListener componentWillUnmountuseEffect cleanup 中統一解除。
定時器 / 週期任務 setInterval/setTimeout 持續執行但未在適當時機 clearInterval/clearTimeout 保存 timer ID,必要時手動清除。
DOM 參考循環 透過 WeakMap / WeakSet 以外的結構,將 DOM 元素作為鍵值保存,導致循環參考。 使用 WeakMapWeakSet 或在移除元素前手動刪除引用。

程式碼範例

範例 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,或在移除節點前手動清除引用。

具體的最佳化技巧

  1. 使用 const / let 取代 var

    • var 會自動提升,容易形成意外的全域變數。
  2. 適時使用 null 釋放引用

    let largeObj = { /* huge data */ };
    // 使用完畢
    largeObj = null; // 讓 GC 知道此物件已不再需要
    
  3. 利用 requestIdleCallback 延遲非關鍵工作

    • 把不需要即時完成的計算或資料整理放到閒置時間,減少主執行緒的壓力。
  4. 在大型迴圈中使用 for 而非 forEach

    • forEach 內部會產生回呼函式,增加閉包開銷。
  5. 避免在頻繁呼叫的函式內部宣告大量局部變數

    • 可將重複使用的物件搬到外層,或使用 物件池 重用。

實際應用場景

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 應用時兼顧功能與效能,寫出更穩定、更高效的程式碼。祝開發順利! 🚀