JavaScript 記憶體洩漏排查實戰:從 Chrome DevTools 到自動檢測的完整鏈路

前端工程(更新於 2026年5月14日)

記憶體洩漏:前端的隱形殺手

記憶體洩漏不會立即崩潰,而是像慢性毒藥——頁面越來越卡,最終白屏。在 SPA 應用中尤為嚴重,因為頁面從不刷新。

洩漏嚴重度 症狀 時間尺度
輕微 長時間使用後輕微卡頓 數小時
中等 切換路由後明顯變慢 30-60 分鐘
嚴重 操作後頁面白屏/崩潰 數分鐘

六大經典洩漏模式

1. 未清除的定時器

// 洩漏:元件卸載後定時器仍在執行
function PollingComponent() {
  useEffect(() => {
    const timer = setInterval(fetchData, 5000);
    // 忘記 return cleanup
  }, []);
}

// 修復
function PollingComponent() {
  useEffect(() => {
    const timer = setInterval(fetchData, 5000);
    return () => clearInterval(timer);
  }, []);
}

2. 未移除的事件監聽

// 洩漏:全域事件監聽器持有元件引用
function ScrollComponent() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    // 忘記移除
  }, []);
}

// 修復
function ScrollComponent() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
}

3. 閉包中的意外引用

// 洩漏:閉包持有整個 largeData 的引用
function processChunk(largeData) {
  const result = largeData.items[0].value;
  // 即使只用 result,閉包仍持有 largeData
  return function getResult() {
    return result;
  };
}

// 修復:只捕獲需要的值
function processChunk(largeData) {
  const result = largeData.items[0].value;
  const captured = result; // 基本型別,無引用
  return function getResult() {
    return captured;
  };
}

4. Detached DOM 節點

// 洩漏:移除 DOM 但 JS 仍引用
const element = document.getElementById('card');
element.remove();
// element 變數仍持有引用,DOM 節點無法回收

// 修復:移除後置空引用
element.remove();
element = null;

5. Map/Set 中的殭屍引用

// 洩漏:Map 持有已銷毀元件的引用
const componentCache = new Map();

function register(id, component) {
  componentCache.set(id, component);
}

// 元件銷毀時未從 Map 中移除
// 修復:銷毀時清理
function unregister(id) {
  componentCache.delete(id);
}

6. WeakMap 誤用為 Map

// 當鍵是物件且希望自動回收時,用 WeakMap
const metadata = new WeakMap();

function attachMeta(obj, meta) {
  metadata.set(obj, meta); // obj 被 GC 時,entry 自動消失
}

// 如果用 Map,即使 obj 被 GC,Map 仍持有引用

Chrome DevTools Memory 面板實戰

步驟一:建立基線

  1. 開啟 DevTools → Memory 面板
  2. 點選 Take heap snapshot
  3. 記錄為 "Baseline"

步驟二:操作與對比

  1. 執行可能洩漏的操作(如切換路由 10 次)
  2. 點選 Take heap snapshot
  3. 記錄為 "After operation"
  4. 選擇 "Comparison" 檢視,對比 Baseline

步驟三:分析增長物件

列名 含義
Added 新增物件數
Deleted 已刪除物件數
Delta 淨增物件數
Allocated Size 新分配記憶體
Freed Size 已釋放記憶體
Size Delta 淨增記憶體

重點關注:Delta > 0 且 Size Delta 較大的物件型別。

步驟四:檢視 Retainers

點選洩漏物件 → 檢視 Retainers 面板 → 追蹤引用鏈:

Detached HTMLDivElement
  ↳ retained by Object (componentCache)
    ↳ retained by Map @12345
      ↳ retained by Window (global)

這條鏈告訴你:一個已脫離 DOM 樹的元素,被 componentCache 這個 Map 持有,而 Map 是全域變數。

Allocation Timeline 即時追蹤

  1. 選擇 Allocation instrumentation on timeline
  2. 開始錄製
  3. 執行操作
  4. 停止錄製
  5. 藍色柱狀條 = 仍存活的分配,灰色 = 已回收

常見洩漏場景的 DevTools 特徵

洩漏型別 Heap Snapshot 特徵 關鍵詞
事件監聽 EventListener 數量持續增長 eventListeners
定時器 Timer 物件不減少 timer
Detached DOM Detached HTML*Element Detached
閉包引用 closure 持有大物件 context
Map/Set Map size 持續增長 Map / Set

自動化記憶體洩漏檢測

Puppeteer + DevTools Protocol

const puppeteer = require('puppeteer');

async function detectMemoryLeak(url, action, iterations = 10) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(url);

  const results = [];

  for (let i = 0; i < iterations; i++) {
    // 執行操作
    await action(page);

    // 強制 GC
    await page.evaluate(() => {
      if (window.gc) window.gc();
    });

    // 獲取記憶體指標
    const metrics = await page.metrics();
    results.push({
      iteration: i + 1,
      jsHeapUsedSize: metrics.JSHeapUsedSize,
    });
  }

  await browser.close();

  // 分析趨勢:如果堆記憶體持續增長,可能存在洩漏
  const first = results[0].jsHeapUsedSize;
  const last = results[results.length - 1].jsHeapUsedSize;
  const growth = (last - first) / first;

  return {
    leaked: growth > 0.1, // 增長超過 10% 視為洩漏
    growthRate: `${(growth * 100).toFixed(1)}%`,
    details: results,
  };
}

// 使用範例
const result = await detectMemoryLeak(
  'http://localhost:3000',
  async (page) => {
    await page.click('#navigate-btn');
    await page.waitForSelector('#content');
    await page.click('#back-btn');
  },
  20
);

console.log(result);

整合到 CI

# .github/workflows/memory-check.yml
name: Memory Leak Check
on: [push]
jobs:
  memory-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - run: node scripts/memory-leak-test.js

React 專項:useEffect 清理清單

function DataComponent({ id }) {
  useEffect(() => {
    const controller = new AbortController();
    const timer = setInterval(() => {}, 5000);
    const handler = () => {};
    window.addEventListener('resize', handler);
    const subscription = eventBus.subscribe('event', handler);

    return () => {
      controller.abort();        // 取消請求
      clearInterval(timer);      // 清除定時器
      window.removeEventListener('resize', handler); // 移除監聽
      subscription.unsubscribe(); // 取消訂閱
    };
  }, [id]);
}

React 18 Strict Mode 的幫助

React 18 Strict Mode 下,元件會雙重掛載再卸載,如果 useEffect 清理不徹底,控制台會立即暴露問題。


最佳實踐

  1. 每個 useEffect 都檢查是否有清理函數
  2. 全域事件監聽器統一管理,元件卸載時批次移除
  3. 避免在閉包中捕獲大物件,只取需要的值
  4. 用 WeakMap/WeakSet 替代 Map/Set 儲存物件元資料
  5. Detached DOM 節點置空引用
  6. CI 整合記憶體洩漏檢測,防止回歸
  7. 定期做 Heap Snapshot 對比,特別是路由切換場景
  8. 使用 React 18 Strict Mode 開發環境雙重掛載檢測
#JavaScript#内存泄漏#Chrome DevTools#性能优化