功能全都正常,控制台一条报错都没有,页面的内存却一路往上走——用到第三个小时,浏览器标签页崩了。这就是内存泄漏麻烦的地方:它不是代码写错了,是代码「以为扔掉了,其实没扔」。
我把前端最常见的 5 个泄漏现场写成一个实验脚本,在 Node 24(V8,和 Chrome 同引擎)里逐个跑了一遍。每个读数都是强制 GC 两次之后的真实 heapUsed,换个环境数字会变,结论不变。先看总账:
| # | 现场 | 实测涨幅 | 一句话 |
|---|---|---|---|
| 1 | 定时器忘 clear | +76.3MB | 50 个轮询任务,谁也没停 |
| 2 | 监听器只加不减 | +76.7MB | Node 在第 11 个时就警告过你 |
| 3 | 无上限缓存 Map | +91.9MB | 外部引用全删了,heap 一动不动 |
| 4 | 小回调拽大对象 | +91.6MB | 23 字节的函数,拽着一整页数据 |
| 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 后读数:
| 轮次 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 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 是我测之前也没想到的:引用删干净和内存释放是两件事,中间隔着一个还持强引用的容器。你抓到过的最隐蔽的泄漏是哪一种,评论区报个现场——被这个问题坑过的人,比承认的多。