文章

内存泄漏排查与常见泄漏场景

面试高频:JS 有 GC 为什么还会泄漏、常见的七类泄漏场景、怎么用 Chrome DevTools 三步定位到具体代码行、React/Vue 里正确的清理姿势。

内存泄漏排查与常见泄漏场景

一句话概括

JS 有垃圾回收,但 GC 判断的是“还能不能从根对象走到你”(可达性,mark-and-sweep),不是”你还有没有用”。所以所谓内存泄漏,本质是你不小心留了一条引用链,让本该死的对象还活着。

面试为什么爱问?因为这是区分”会写页面”和”会排查线上问题”的分水岭。用户反馈”页面用久了越来越卡、最后崩了”,你能不能有方法论地定位到那一行代码,答案完全不一样。回答这题的标准套路是三段:场景(哪几类)→ 工具(怎么定位)→ 修复(怎么根治)。

核心知识点

1. 先说清”泄漏”的定义,别一上来就背场景

面试官问”什么是内存泄漏”,很多人直接开始列场景,其实第一句话应该是定义:

不再需要的对象,因为仍然从 GC 根(全局对象、调用栈、正在执行的闭包)可达,无法被回收,导致堆持续增长。

顺带把三个容易混的概念区分开,这是加分项(Chrome 官方文档就是这么分类的):

现象用户感知本质
内存泄漏 leak用得越久越卡,最后崩引用没断,内存只增不减
内存膨胀 bloat一直都卡内存用量本身就超出设备能力
频繁 GC一顿一顿地卡频繁分配触发 GC,GC 期间脚本暂停

记住这一条就行:泄漏是”斜坡”,膨胀是”高台”,频繁 GC 是”锯齿”。

2. 七类高频泄漏场景(面试按这个顺序背)

① 未清理的定时器

1
2
3
4
5
6
// ❌ 组件销毁了,interval 还在跑,回调闭包里的 this / state 全被吊着
setInterval(() => render(bigData), 1000);

// ✅ 谁创建谁清理
const id = setInterval(() => render(bigData), 1000);
// 销毁时:clearInterval(id);

② 未移除的事件监听

挂在 window / document 这种长生命周期对象上的监听最危险——它们本身永远不死,监听函数和闭包捕获的一切就跟着永生。

1
2
3
4
5
6
7
// ❌ 匿名函数根本没法 remove
window.addEventListener("resize", () => update(hugeList));

// ✅ 具名引用 + 成对出现;一次性监听可以用 { once: true }
function onResize() { update(hugeList); }
window.addEventListener("resize", onResize);
// 销毁时:window.removeEventListener("resize", onResize);

③ 游离 DOM(detached DOM)——面试最爱考的一类

1
2
3
4
5
6
7
8
// ❌ 节点从文档里移走了,但数组还攥着它,整棵子树都回收不了
const cache = [];
function add() {
  const el = document.createElement("ul");
  document.body.append(el);
  cache.push(el);        // 就是这行让它"游离"而不死
}
function remove(el) { el.remove(); } // DOM 树里没了,cache 里还在

关键点:只要有一个后代节点被 JS 引用,整棵 DOM 子树都活着。修复就是移除后把引用也断掉(cache.length = 0 / 置 null),或者一开始就用 WeakMap/WeakSet 存元数据。

④ 闭包意外捕获大对象

这条最隐蔽:泄漏的不是闭包本身,是它顺手捕获的整个词法环境。

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 只想返回 total,却把 raw(几十万条)一起吊住了
function build(raw) {
  const total = raw.length;
  return () => total;   // 闭包持有整个 build 作用域,raw 也在里面
}

// ✅ 用完就断,或者把大对象隔离在另一个函数作用域里
function build(raw) {
  const total = raw.length;
  raw = null;           // 显式释放
  return () => total;
}

⑤ 意外的全局变量

非严格模式下漏写 let/const 就挂到了 window 上,永不回收。开 "use strict"(ESM 默认严格)基本能杜绝。

⑥ 第三方实例没销毁

ECharts、地图、播放器、编辑器这类库内部注册了大量监听和 canvas,必须调它自己的销毁方法(chart.dispose()、map.destroy()、observer.disconnect())。面试提到这条会显得有实战经验。

⑦ 无界缓存

const cache = new Map() 一路 set 从不淘汰,就是个内存黑洞。要么加 LRU + 容量上限,要么用 WeakMap(键是对象且不想挡 GC 时)。

3. 排查三步法(这段是回答的核心,务必能复述)

第一步:确认有没有泄漏 —— 看趋势

  • Chrome 任务管理器(Shift + Esc):右键表头勾上 JavaScript memory,看括号里的”live”数值。反复操作页面,它只涨不落 → 有嫌疑。
  • Performance 面板勾上 Memory 录制:录制开头和结尾各点一次强制 GC(垃圾桶图标),然后对比 JS Heap 曲线。若 GC 之后仍高于起点,且 Nodes / Listeners 计数阶梯式上涨 → 基本确诊。

第二步:定位泄漏对象 —— 堆快照对比

Memory 面板 → Heap snapshot,标准动作是”三快照法“:

  1. 打开页面,拍快照 1(基线)。
  2. 反复做可疑操作 5~10 次(比如来回切路由 / 反复开关弹窗),把泄漏放大。
  3. 点强制 GC,再拍快照 2。
  4. 视图切到 Comparison、基准选快照 1,按 Size Delta / 新增数量排序。

看哪些构造器数量只增不减;Class filter 里输 Detached 可以直接列出所有游离 DOM。

第三步:找到代码行 —— 看 Retainers(保留路径)

选中一个泄漏对象,看底部 Retainers 面板,它给你的就是”谁在吊着我”的完整引用链,比如:

1
2
3
Detached HTMLUListElement
  ← cache[] in module scope
    ← (closure) in add()  @ list.js:13

点链接直接跳到源码那一行。回答这题时一定要说出 “Retainers / 保留路径” 这个词——它是从”我看到内存涨了”到”我知道是哪行代码”的唯一桥梁。

补充两个概念,问到必答:

  • Shallow size:对象自身占的内存。
  • Retained size:删掉它能连带释放的总内存 —— 排查时看 retained size 才有意义。

4. 框架里的正确清理姿势

React:所有副作用都在 useEffect 的返回函数里收尾,请求用 AbortController 取消。

1
2
3
4
5
6
7
8
9
10
11
12
useEffect(() => {
  const ctrl = new AbortController();
  fetch("/api/list", { signal: ctrl.signal }).then(setData).catch(() => {});

  const onScroll = () => setY(window.scrollY);
  window.addEventListener("scroll", onScroll);

  return () => {                              // ✅ 卸载时统一清理
    ctrl.abort();
    window.removeEventListener("scroll", onScroll);
  };
}, []);

Vue 3:onUnmounted 里手动清;组件内创建的 watch / watchEffect 会随组件作用域自动停止,但在组件外(比如 store、工具函数)创建的必须自己 stop,否则泄漏。

1
2
3
4
5
import { onUnmounted, effectScope } from "vue";

const scope = effectScope();      // 组件外批量管理副作用
scope.run(() => { /* watch / watchEffect ... */ });
onUnmounted(() => scope.stop());  // ✅ 一次性全停

通用兜底:ResizeObserver / IntersectionObserver / MutationObserver 要 disconnect(),WebSocket 要 close(),EventSource 要 close()。

其实你每天都在用

  • SPA 切路由内存不降:99% 是上一个页面的监听或定时器没清。
  • 弹窗反复开关变卡:每次 open 都 addEventListener('keydown'),close 忘了 remove。
  • 列表虚拟滚动内存涨:回收的行节点被自己写的复用池数组攥着,成了游离 DOM。
  • 图表容器换数据后卡:echarts.init 了很多次却没 dispose 上一个实例。
  • 登录态轮询:setInterval 轮询接口,退出登录时忘了 clear,后台还在跑。
  • React 严格模式下 effect 跑两次:正好用来暴露”你没写 cleanup”这个问题。

常见误解(FAQ)

❌ 误区一:”JS 有垃圾回收,所以不会内存泄漏”

GC 回收的是不可达对象,不是”你不用的”对象。只要还有一条引用链从根连到它,GC 就认为它有用。泄漏的锅从来在引用,不在 GC。

❌ 误区二:”循环引用会导致内存泄漏”

现代引擎用的是 mark-and-sweep(可达性标记),两个对象互相引用但都不可达时会被正常回收。循环引用泄漏是老 IE 引用计数时代的事,这么答会被追问到掉坑。

❌ 误区三:”在 React 里给 setState 加 isMounted 判断就是修内存泄漏”

不是。首先 React 18 已经移除了那条 “setState on unmounted component” 警告,官方明确说它多数情况是误报;其次加 mounted 标记只是消掉警告,真正的泄漏(没解绑的订阅、没清的定时器)一点没修。正确做法是在 cleanup 里 abort 请求、取消订阅。

❌ 误区四:”手动 delete 属性 / 赋 null 就能立刻释放内存”

只是断开了引用,回收时机由 GC 自己决定。也正因为这点,DevTools 里对比快照前必须先点强制 GC,不然你看到的”没降”可能只是 GC 还没跑。

❌ 误区五:”用 performance.memory 就能在线上监控内存”

performance.memory 是 Chrome 非标准接口且已废弃,精度差、跨浏览器不可用。标准方案是 performance.measureUserAgentSpecificMemory(),但它要求页面处于跨域隔离(crossOriginIsolated)环境,落地有门槛——面试如实说清这个限制比硬吹更稳。

❌ 误区六:”堆快照里 shallow size 大的就是泄漏元凶”

要看 retained size。一个只有几十字节的数组,可能 retained 了几十 MB 的游离 DOM 子树。

一句话总结

内存泄漏不是”忘了释放内存”,而是忘了断开引用:先按七类场景(定时器 / 监听 / 游离 DOM / 闭包 / 全局 / 第三方实例 / 无界缓存)定位嫌疑,再用”任务管理器看趋势 → 三快照 Comparison 找增量 → Retainers 追引用链”三步锁定代码行,最后在框架的 cleanup 钩子里把引用一个个断掉。

本文由作者按照 CC BY 4.0 进行授权