内存泄漏排查与修复
场景
SPA 应用长时间使用后越来越慢甚至崩溃,或 Task Manager 中页面内存持续增长,你怎么排查和修复?
分析思路
什么是内存泄漏?
内存泄漏是指已经不再使用的对象仍然被引用,无法被垃圾回收(GC),导致内存持续增长。
第一步:确认是否泄漏
Chrome Task Manager(Shift+Esc):
- 观察页面的 JavaScript Memory 是否持续增长
- 每次操作后手动点 GC(DevTools → Performance → 🗑️ 按钮),如果内存不回落,大概率有泄漏
Performance Monitor:
DevTools → More Tools → Performance Monitor
- 观察 JS Heap Size、DOM Nodes、JS Event Listeners 三条线
- 正常状态应该围绕一个基准线波动,持续走高则有问题
第二步:堆快照对比定位
DevTools → Memory 面板 → Heap Snapshot
三步对比法:
- 操作前拍快照 A
- 执行可疑操作(如打开/关闭弹窗、路由切换)
- 操作后手动 GC,拍快照 B
- 使用 Comparison 模式对比 A 和 B,看哪些对象增加了
重点关注:
- Detached DOM:已从 DOM 树移除但仍被 JS 引用的节点
- 对象增量:
#Delta列 > 0 的对象 - Retained Size:对象及其引用链占用的总内存
第三步:常见泄漏场景与修复
泄漏 1:事件监听未移除
❌ 组件卸载后监听器还在
function ChatRoom() {
useEffect(() => {
const handler = (e: MessageEvent) => {
// 处理消息...
};
window.addEventListener('message', handler);
// 忘记清理!
}, []);
}
✅ 在 cleanup 中移除监听
function ChatRoom() {
useEffect(() => {
const handler = (e: MessageEvent) => {
// 处理消息...
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
}
泄漏 2:定时器未清除
❌ setInterval 未清除
function Countdown() {
const [count, setCount] = useState(60);
useEffect(() => {
const timer = setInterval(() => {
setCount((c) => c - 1);
}, 1000);
// 忘记清理!组件卸载后定时器还在跑
}, []);
}
✅ 清除定时器
function Countdown() {
const [count, setCount] = useState(60);
useEffect(() => {
const timer = setInterval(() => {
setCount((c) => c - 1);
}, 1000);
return () => clearInterval(timer);
}, []);
}
泄漏 3:闭包引用大对象
❌ 闭包意外持有大数据
function processData() {
const hugeData = new Array(1_000_000).fill({ /* ... */ });
// 返回的函数闭包持有 hugeData 引用,即使只需要 length
return () => {
console.log(hugeData.length);
};
}
const getLength = processData(); // hugeData 永远不会被回收
✅ 只保留需要的值
function processData() {
const hugeData = new Array(1_000_000).fill({ /* ... */ });
const length = hugeData.length; // 只提取需要的值
// hugeData 可以被 GC
return () => {
console.log(length);
};
}
泄漏 4:被遗忘的全局变量/缓存
❌ 无限增长的缓存
const cache = new Map<string, ResponseData>();
async function fetchWithCache(url: string): Promise<ResponseData> {
if (cache.has(url)) return cache.get(url)!;
const data = await fetch(url).then((r) => r.json());
cache.set(url, data); // 只增不减,一直增长
return data;
}
✅ 使用 LRU 缓存限制大小
class LRUCache<K, V> {
private cache = new Map<K, V>();
constructor(private maxSize: number) {}
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// 移到末尾(最近使用)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key: K, value: V): void {
this.cache.delete(key);
this.cache.set(key, value);
if (this.cache.size > this.maxSize) {
// 删除最久未使用的(第一个)
const firstKey = this.cache.keys().next().value!;
this.cache.delete(firstKey);
}
}
}
const cache = new LRUCache<string, ResponseData>(100);
泄漏 5:Detached DOM 节点
❌ DOM 被移除但 JS 仍持有引用
let detachedNode: HTMLDivElement | null = null;
function showModal() {
const modal = document.createElement('div');
modal.innerHTML = '<h1>Modal</h1>';
document.body.appendChild(modal);
detachedNode = modal; // JS 引用!
}
function hideModal() {
detachedNode?.remove(); // 从 DOM 移除
// 但 detachedNode 变量还引用着它 → Detached DOM
}
✅ 清除 JS 引用
function hideModal() {
detachedNode?.remove();
detachedNode = null; // 清除引用
}
WeakRef 和 WeakMap
对于缓存等不需要强引用的场景,使用 WeakRef 或 WeakMap,让 GC 在需要时自动回收:
const cache = new WeakMap<object, ComputedResult>();
// 当 key 对象没有其他引用时,GC 会自动清除条目
泄漏 6:WebSocket / EventSource 未关闭
✅ 组件卸载时关闭连接
function LiveFeed() {
useEffect(() => {
const ws = new WebSocket('wss://api.example.com');
ws.onmessage = (e) => { /* ... */ };
return () => {
ws.close();
};
}, []);
}
排查流程总结
常见面试问题
Q1: 前端常见的内存泄漏有哪些?
答案:
| 类型 | 示例 | 解决 |
|---|---|---|
| 事件监听器 | addEventListener 未 removeEventListener | cleanup 中移除 |
| 定时器 | setInterval 未 clearInterval | cleanup 中清除 |
| 闭包 | 闭包捕获大对象 | 只保留必要数据 |
| 全局变量/缓存 | Map 只增不减 | LRU 限制大小、WeakMap |
| Detached DOM | DOM 移除但 JS 引用还在 | 置为 null |
| WebSocket | 未 close() | cleanup 中关闭 |
| 第三方库 | 未调用 destroy() / dispose() | 查文档调用清理方法 |
Q2: 如何使用 Chrome DevTools 排查内存泄漏?
答案:
- Performance Monitor:先观察 JS Heap Size 趋势
- Memory → Heap Snapshot:三步对比法找出泄漏对象
- Memory → Allocation Timeline:录制操作过程,看内存分配时间线
- Memory → Allocation Sampling:性能开销低的采样模式,适合长期监控
- 用 Retainers 查看泄漏对象的引用链,找出是谁在引用它
Q3: WeakMap 和 Map 在内存管理上有什么区别?
答案:
| 特性 | Map | WeakMap |
|---|---|---|
| key 类型 | 任意 | 只能是 object |
| GC | key 不会被自动回收 | key 无其他引用时自动回收 |
| 可遍历 | ✅ for...of | ❌ 不可遍历 |
| size 属性 | ✅ | ❌ |
| 适用场景 | 需要遍历/持久化的数据 | 不需要持久的缓存、私有数据 |
WeakMap 的 key 是弱引用,不会阻止垃圾回收。适合存储 DOM 节点的关联数据、对象的私有属性等。