上周排查一个后台项目:开一下午标签页,风扇开始起飞,页面卡到点按钮都要等半秒。Performance 面板一看,JS 堆内存曲线一路上扬,典型的内存泄漏。借着这个案例,把日常开发里最容易遇到的五类泄漏场景整理成文。
先弄懂 GC 怎么工作
现代 JavaScript 引擎用可达性(reachability)判断垃圾:从根(全局对象、当前调用栈等)出发,顺着引用走,能走到的对象是活的,走不到的才可以回收。
泄漏的本质:本该不可达的对象,仍然被某条引用链拽着。
记住这句话,下面的所有场景都是它的变体。
场景一:意外的全局变量
function handler(data) {
cache = { ...data }; // 忘写 let/const,隐式挂到 window
}
非严格模式下,漏声明的赋值会挂到全局对象上,页面不关它就永不释放。防御手段很成熟:开启 strict mode,再让 ESLint 的 no-undef 规则兜底。
场景二:被遗忘的定时器与监听器
SPA 的重灾区。组件销毁了,定时器还攥着闭包里的数据:
useEffect(() => {
const id = setInterval(pollStatus, 3000);
return () => clearInterval(id); // 漏掉这行,pollStatus 引用的数据全部滞留
}, []);
addEventListener 同理,尤其挂在 window / document 上的监听——组件树销毁并不会自动解绑它们。
推荐用 AbortSignal 一次性解绑,比逐个 remove 干净得多:
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
el.addEventListener('click', onClick, { signal: controller.signal });
// 组件卸载时一行解绑全部
controller.abort();
场景三:闭包顺手引用了大对象
function process(bigData) {
const header = bigData.header; // 只需要 header
return () => render(header); // 看似只留 header
}
看起来没问题,但如果写成 return () => render(bigData.header),闭包捕获的是整个 bigData——几 MB 的原始数据就这样被一个回调拽着不放。原则:只把需要的字段提取出来,别让闭包「顺手」引用整个对象。
场景四:游离的 DOM 引用
const submitBtn = document.querySelector('#submit');
// 某次重渲染后
document.querySelector('#form').innerHTML = '';
// submitBtn 仍指向已从文档移除的节点,整个子树都无法回收
表格行、列表项的场景最常见:JS 变量里缓存了节点引用,重渲染后这些节点成了「孤儿」。对策是控制变量生命周期(用完置 null)或尽量缩小引用的作用域。
场景五:无上限的缓存
拿 Map 当缓存、只进不出,是数据量上来后必然爆炸的写法。最简单的 LRU 用 Map 的插入序就能实现:
const cache = new Map();
const MAX = 200;
function setCache(key, value) {
if (cache.has(key)) cache.delete(key); // 重新插入以刷新位置
cache.set(key, value);
if (cache.size > MAX) {
const oldest = cache.keys().next().value;
cache.delete(oldest); // 淘汰最旧的
}
}
追求完备可以直接用现成的 lru-cache 库,或用 WeakMap 处理「以对象为键」的场景。
怎么查:Memory 面板三板斧
- Performance Monitor 看趋势:先确认 JS heap size 是否「只涨不跌」,排除正常的 GC 锯齿;
- Heap Snapshot 对比:操作前拍一张、反复执行可疑操作后拍第二张,选择「Objects allocated between Snapshot 1 and 2」,按 Retained Size 排序;
- Retainers 链定位元凶:点开可疑对象,看下方是谁引用着它——十有八九是某个定时器回调、闭包上下文或被缓存的数组。
补充一个直观工具:Allocation instrumentation on timeline,录制期间蓝色柱代表「分配了且尚未释放」的内存,操作复现时非常好用。
写在最后
泄漏不可怕,可怕的是没有发现泄漏的手段。把 Performance Monitor 挂到开发习惯里,长会话页面(后台、编辑器、聊天类)上线前跑一轮 snapshot 对比,就能拦住绝大多数问题。至于那个后台项目——解绑了一个被遗忘的 setInterval,内存曲线立刻变成了漂亮的锯齿波。