我抓了 5 个内存泄漏现行,最狠的一个 91MB

0 阅读6分钟

功能全都正常,控制台一条报错都没有,页面的内存却一路往上走——用到第三个小时,浏览器标签页崩了。这就是内存泄漏麻烦的地方:它不是代码写错了,是代码「以为扔掉了,其实没扔」。

我把前端最常见的 5 个泄漏现场写成一个实验脚本,在 Node 24(V8,和 Chrome 同引擎)里逐个跑了一遍。每个读数都是强制 GC 两次之后的真实 heapUsed,换个环境数字会变,结论不变。先看总账:

#现场实测涨幅一句话
1定时器忘 clear+76.3MB50 个轮询任务,谁也没停
2监听器只加不减+76.7MBNode 在第 11 个时就警告过你
3无上限缓存 Map+91.9MB外部引用全删了,heap 一动不动
4小回调拽大对象+91.6MB23 字节的函数,拽着一整页数据
5模块级全局累积+30.5MB模块不死,它就不死

现场 1:50 个忘清的定时器

function startPolling() {
  const payload = new Array(200000).fill('x');
  return setInterval(() => { payload.length; }, 60000);
}
const timers = [];
for (let i = 0; i < 50; i++) timers.push(startPolling());
// 基线 heap = 3.4MB → 79.7MB(+76.3MB)

timers.forEach(clearInterval);
// GC 后 heap = 3.4MB(+0.0MB,完全回落)

每个轮询任务都拽着一份 20 万条的数据,没人 clear,GC 就谁也不敢动。组件卸载时忘了清 interval、轮询任务重连时旧的不去新的照来,都是这个现场的变体。

修复不需要技巧,需要的是纪律:定时器和组件生命周期绑死,卸载函数里统一清。

onUnmounted(() => {
  timers.forEach(clearInterval);
  timers.length = 0;
});

现场 2:监听器只加不减

const bus = new EventEmitter();
for (let i = 0; i < 2000; i++) {
  const ctx = { rows: new Array(5000).fill(0) };
  bus.on('tick', () => ctx.rows.length);
}
// listenerCount = 2000,heap +76.7MB
bus.removeAllListeners('tick');
// GC 后 +0.1MB

跑这段代码时 Node 自己先跳出来了:

(node:74633) MaxListenersExceededWarning: Possible EventEmitter
memory leak detected. 11 tick listeners added to [EventEmitter].

同一事件挂第 11 个监听器时 Node 就会警告(默认上限 10),只不过警告走 stderr,页面上看不见。浏览器端 addEventListener 同理:每次进页面绑一次、从不 removeEventListener,监听器连同它捕获的上下文一起攒着。

现代浏览器里最省事的修法是 AbortController,一个信号统一拆:

const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
window.addEventListener('scroll', onScroll, { signal: controller.signal });

controller.abort(); // 一次拆干净

现场 3:无上限缓存,删了引用也没用

这个现场有全实验最反直觉的一个读数。往 Map 里塞 3000 条缓存,然后把所有外部引用全部置 null:

const cache = new Map();
let keys = [];
for (let i = 0; i < 3000; i++) {
  const k = { id: i };
  cache.set(k, new Array(4000).fill(0));
  keys.push(k);
}
// heap +91.9MB

keys = null;          // 外部引用全放
// heap = 95.4MB,纹丝不动 ← 关键读数

cache.clear();
// GC 后 +0.0MB

keys = null 之后 heap 一个字节都没掉。你以为引用删干净了,Map 里的键本身还持着强引用——数据既查不到也扔不掉,纯粹挂着占内存。缓存没有淘汰策略,就是慢性泄漏。

对照组,同样的数据放 WeakMap:

const cache = new WeakMap();
let keys = [];
for (let i = 0; i < 3000; i++) {
  const k = { id: i };
  cache.set(k, new Array(4000).fill(0));
  keys.push(k);
}
keys = null;
// GC 后 heap 直接回落 91.8MB

同样的 3000 条,外部引用一放,WeakMap 版本 GC 后直接回落 91.8MB。键的生命周期跟对象走,对象没人用了,条目自动消失。

修复方向:键是可丢的对象就用 WeakMap;键必须字符串(路由、URL 之类),就上带容量上限的 LRU,超过上限踢最旧的。

现场 4:23 字节的回调,拽着 91MB

const registry = []; // 模拟全局回调队列
for (let i = 0; i < 30; i++) {
  const pageData = new Array(400000).fill(0);
  registry.push(() => pageData.length);
}
// 30 个回调,源码文本 23 字节
// heap +91.6MB

每个回调的源码就一行,序列化出来 23 字节。但闭包捕获的是整个作用域,pageData 那 40 万条数据全被这 30 个「小函数」拽着——平均一个 23 字节的回调,拽着 3MB 数据不放。

这个现场最阴:看代码时回调小到不值得注意,真正的大对象看起来早就「用完了」。全局的事件总线、任务队列、待办回调列表,只要有长命的注册表收回调,就是重灾区。

修复:注册表用完就清;往全局注册表 push 回调之前想一句——它捕获了什么。

现场 5:模块级全局,只增不减

// operation-log.js
export const seen = []; // 模块作用域

export function track(op) {
  seen.push({ id: op.id, detail: op.payload });
}
// 200 次调用后 heap +30.5MB

模块作用域的数组活到进程死(SPA 里就是活到标签页关掉)。操作历史、已读列表、去重记录,只要只进不出,就是匀速泄漏——单次看不出来,挂一晚上就是几十 MB。

修复用环形缓冲,上限固定:

const MAX = 200;
const seen = [];
export function track(op) {
  seen.push({ id: op.id, detail: op.payload });
  if (seen.length > MAX) seen.shift(); // 或者双指针覆盖
}

怎么抓:两个实测能用的定位法

泄漏的判定特征就一条:GC 后的基线不回落,只涨不落。我把 600 条缓存 × 5 轮重复写入,每轮 GC 后读数:

轮次12345
heap 增量+9.1MB+18.3MB+27.5MB+36.8MB+46.0MB

每轮涨 9.1~9.2MB,一轮不多一轮不少,5 轮 46MB,没有一次回落。正常内存曲线是锯齿(涨上去、GC 落下来),泄漏是直线爬坡。这是判别两种曲线的方法:

  • 浏览器里看曲线:DevTools Performance 面板勾选 Memory 录制,操作几轮后看 heap 曲线是锯齿还是直线。官方文档明确给了这条路径,还覆盖「分离的 DOM 树」这类浏览器特有泄漏(节点从页面移除了,引用还在)。
  • 三快照法:Memory 面板先拍 Snapshot 1 → 做一轮可疑操作 → Snapshot 2 → 重复同一操作 → Snapshot 3,对比两轮之间多出来的对象,重复操作还线性增长的就是凶手。Node 侧 node --inspect 接上同一个 DevTools 照样能拍。

实验脚本 40 行,node --expose-gc 直接跑,换任何环境都能复现这批结论。

速查表:症状 → 根因 → 修复

症状根因一行修复
页面挂着不动内存也涨定时器没 clear卸载清单统一 clear
同一事件越绑越多监听器只加不减AbortController 一次拆
缓存越占越多不释放Map 强引用无上限WeakMap 或 LRU 上限
小函数多的页面涨得快闭包捕获大对象注册表用完就清
长会话匀速上涨模块级数组累积环形缓冲限容量
曲线锯齿变直线GC 后基线不回落三快照法锁定增量对象
移除的节点还占内存detached DOM 被引用快照里搜 Detached

5 个现场里,现场 3 是我测之前也没想到的:引用删干净和内存释放是两件事,中间隔着一个还持强引用的容器。你抓到过的最隐蔽的泄漏是哪一种,评论区报个现场——被这个问题坑过的人,比承认的多。