JS 内存泄漏排查:用真实案例教你如何用 Chrome DevTools 定位并解决,顺带搞懂闭包和 GC
先说结论:90% 的前端内存泄漏不是"JS 写错了",而是"你以为对象该被回收,但它还被某个引用拽着"。 Chrome DevTools 的 Memory 面板就是帮你找到"谁拽着它"的工具。下面从原理到实战,一步一步来。
一、先搞懂两个底层概念
1. 垃圾回收(GC):标记-清除
JS 用的是可达性算法(Reachability)——从根节点(window / global / 当前执行栈)出发,能顺着引用链摸到的对象就是"活的",摸不到的就是"垃圾"。
根节点 (window)
├── objA ──→ { name: "A" }
├── objB ──→ { name: "B" }
│ └── ref ──→ objA ← objA 被 objB 引用
└── objC ──→ { name: "C" }
如果 objB 被置为 null,那 { name: "B" } 以及它引用的 objA 都从根节点不可达了 → 下次 GC 全部回收。
关键点:GC 不看"你还有没有用",只看"根节点能不能摸到你"。
2. 闭包:为什么它既是功臣也是坑
闭包 = 函数 + 它声明时的词法作用域。内部函数引用了外部变量,即使外部函数执行完了,那些变量也不会被回收——因为内部函数还活着。
function createCounter() {
let count = 0; // 这个变量"应该"在函数结束后销毁
return function () {
count++; // 但内部函数引用了它
return count;
};
}
const counter = createCounter();
// count 不会被回收,因为 counter 还在引用它
闭包本身不是泄漏——它是语言特性。泄漏发生在:你无意中让一个本该消失的闭包一直活着。
二、最常见的 4 种内存泄漏模式
| 模式 | 本质 | 出现频率 |
|---|---|---|
| 意外的全局变量 | 变量挂到 window 上,永远可达 | 老代码常见 |
| 遗忘的定时器/回调 | setInterval 引用了 DOM 或闭包 | 最高频 |
| DOM 引用未释放 | JS 里还存着已移除 DOM 的引用 | 高频 |
| 闭包捕获大对象 | 内部函数活着 → 外部大变量跟着活着 | 中频 |
三、真实案例:一个"越用越卡"的 SPA 页面
场景还原
一个数据看板页面,用户切换不同项目时会重新渲染图表。用了一周后,QA 反馈:页面切 20 次后,内存从 80MB 涨到 800MB+,浏览器直接卡死。
第一步:复现 + 录制堆快照
- 打开 Chrome DevTools → Memory 面板
- 选中 Heap Snapshot
- 点击 Take snapshot(记为 Snapshot 1)——这是基线
- 操作页面:切换项目 5 次
- 再点 Take snapshot(Snapshot 2)
第二步:对比快照,找到"谁没被回收"
在 Snapshot 2 面板左上角下拉框选 Comparison,对比 Snapshot 1:
# New 列:新增了多少个对象
# Deleted 列:回收了多少个
# Delta 列:净增(正数 = 泄漏嫌疑)
你看到 Detached HTMLDivElement 的 Delta 是 +47。这说明有 47 个 DOM 节点已经从页面移除,但 JS 还引用着它们。
第三步:定位引用链
点击那行 Detached HTMLDivElement,看底部的 Retainers 面板:
window
└── appState.chartsCache
└── Map(47) { key → value }
└── value → HTMLDivElement ← 就是它
引用链一目了然:window → appState.chartsCache → Map → HTMLDivElement。
第四步:看代码,找到根因
// 问题代码
class ChartManager {
constructor() {
this.cache = new Map(); // 缓存图表实例
}
renderChart(container, data) {
const chart = new FancyChart(container, data);
this.cache.set(container.id, chart); // ← 用 DOM id 做 key
// 但 chart 内部持有 container 的引用!
}
// ❌ 没有清理逻辑
}
问题链条:
renderChart每次创建新图表,塞进Map- 切换项目时旧 DOM 被
container.remove()移除 - 但
Map里还存着旧 chart → chart 引用着旧 DOM - 旧 DOM 无法被 GC → 泄漏
第五步:修复
class ChartManager {
constructor() {
this.cache = new Map();
}
renderChart(container, data) {
// 先清理旧图表
this.disposeChart(container.id);
const chart = new FancyChart(container, data);
this.cache.set(container.id, chart);
}
disposeChart(id) {
const old = this.cache.get(id);
if (old) {
old.destroy(); // 图表自己的清理逻辑
this.cache.delete(id); // ← 关键:从 Map 移除引用
}
}
}
// 切换项目时调用
function onProjectChange(newId) {
chartManager.disposeChart(currentProjectId);
chartManager.renderChart(document.getElementById("chart"), newData);
}
第六步:验证
修复后再跑一遍:切换 5 次 → 拍快照 → Comparison 视图里 Detached HTMLDivElement 的 Delta 变成 0。✅
四、另一个经典案例:闭包泄漏
场景
// 一个全局事件总线
const eventBus = {
listeners: [],
on(fn) { this.listeners.push(fn); },
off(fn) { this.listeners = this.listeners.filter(l => l !== fn); }
};
// 组件里
function createWidget() {
const hugeData = new Array(1000000).fill("leak"); // 大数组
eventBus.on(function handler() {
console.log(hugeData.length); // 闭包引用了 hugeData
});
// ❌ 组件销毁时忘记 eventBus.off(handler)
}
问题:handler 被 eventBus.listeners 引用 → handler 活着 → hugeData 活着 → 即使组件 DOM 销毁了,400MB 数组还在。
定位方式:Memory 面板 → 录制 → 搜索 (array) 或 Array → 看 Retainers 链 → 发现 eventBus.listeners 拽着它。
修复:组件销毁时 eventBus.off(handler),或者用 AbortController 做一次性清理。
五、Chrome DevTools 内存排查速查表
| 场景 | 用什么工具 | 怎么看 |
|---|---|---|
| 页面越用越慢,怀疑泄漏 | Heap Snapshot + Comparison | 找 Delta 为正的、数量持续增长的对象 |
| 想看"谁拽着这个对象" | 快照里点对象 → Retainers | 从下往上看引用链,找到根因 |
| 想看内存分配时间线 | Allocation instrumentation on timeline | 红色柱子 = 那段时间分配了大量对象 |
| 想看 GC 是否及时 | Performance 面板 → Memory 勾选 | 看 JS Heap 曲线,频繁 GC 但曲线不降 = 泄漏 |
| 想强制触发 GC 验证 | DevTools → Collect garbage 按钮 | 点完堆快照没降 = 有引用没释放 |
六、防泄漏 checklist(写代码时就能用)
- 定时器(
setInterval/setTimeout)在组件卸载时clear - 事件监听(
addEventListener)在卸载时removeEventListener - 全局缓存(Map / 数组)有对应的
delete/clear逻辑 - 闭包里不引用大对象,或者引用时用
WeakMap/WeakRef - 单页应用路由切换时,做资源清理(
dispose/destroy) - 用
WeakMap替代Map存 DOM → 数据的映射(DOM 移除后自动释放)
七、一句话总结
内存泄漏的本质 = 引用没断。 GC 只认可达性,不认"你还有没有用"。Chrome DevTools 的 Memory 面板就是帮你把那条隐形的引用链拽出来——找到它,断开它,完事。