上周排查一个后台项目:开一下午标签页,风扇开始起飞,页面卡到点按钮都要等半秒。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 面板三板斧

  1. Performance Monitor 看趋势:先确认 JS heap size 是否「只涨不跌」,排除正常的 GC 锯齿;
  2. Heap Snapshot 对比:操作前拍一张、反复执行可疑操作后拍第二张,选择「Objects allocated between Snapshot 1 and 2」,按 Retained Size 排序;
  3. Retainers 链定位元凶:点开可疑对象,看下方是谁引用着它——十有八九是某个定时器回调、闭包上下文或被缓存的数组。

补充一个直观工具:Allocation instrumentation on timeline,录制期间蓝色柱代表「分配了且尚未释放」的内存,操作复现时非常好用。

写在最后

泄漏不可怕,可怕的是没有发现泄漏的手段。把 Performance Monitor 挂到开发习惯里,长会话页面(后台、编辑器、聊天类)上线前跑一轮 snapshot 对比,就能拦住绝大多数问题。至于那个后台项目——解绑了一个被遗忘的 setInterval,内存曲线立刻变成了漂亮的锯齿波。