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 面板實戰
步驟一:建立基線
- 開啟 DevTools → Memory 面板
- 點選 Take heap snapshot
- 記錄為 "Baseline"
步驟二:操作與對比
- 執行可能洩漏的操作(如切換路由 10 次)
- 點選 Take heap snapshot
- 記錄為 "After operation"
- 選擇 "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 即時追蹤
- 選擇 Allocation instrumentation on timeline
- 開始錄製
- 執行操作
- 停止錄製
- 藍色柱狀條 = 仍存活的分配,灰色 = 已回收
常見洩漏場景的 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 清理不徹底,控制台會立即暴露問題。
最佳實踐
- 每個 useEffect 都檢查是否有清理函數
- 全域事件監聽器統一管理,元件卸載時批次移除
- 避免在閉包中捕獲大物件,只取需要的值
- 用 WeakMap/WeakSet 替代 Map/Set 儲存物件元資料
- Detached DOM 節點置空引用
- CI 整合記憶體洩漏檢測,防止回歸
- 定期做 Heap Snapshot 對比,特別是路由切換場景
- 使用 React 18 Strict Mode 開發環境雙重掛載檢測
#JavaScript#内存泄漏#Chrome DevTools#性能优化