JS 内存泄漏排查:用真实案例教你如何用 Chrome DevTools 定位并解决,顺带搞懂闭包和 GC

18 阅读5分钟

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+,浏览器直接卡死。

第一步:复现 + 录制堆快照

  1. 打开 Chrome DevTools → Memory​ 面板
  2. 选中 Heap Snapshot
  3. 点击 Take snapshot(记为 Snapshot 1)——这是基线
  4. 操作页面:切换项目 5 次
  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 的引用!
  }

  // ❌ 没有清理逻辑
}

问题链条:

  1. renderChart 每次创建新图表,塞进 Map
  2. 切换项目时旧 DOM 被 container.remove() 移除
  3. Map 里还存着旧 chart → chart 引用着旧 DOM
  4. 旧 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)
}

问题handlereventBus.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 面板就是帮你把那条隐形的引用链拽出来——找到它,断开它,完事。