页面刚上线时一切流畅,跑了几天后开始卡顿,刷新一下又能恢复正常,可过几天又变卡。这种问题如果排除了接口和渲染瓶颈,很大概率是内存泄漏。JavaScript 虽然有自动垃圾回收,但一些写法会让回收器误以为对象还需要,导致内存只增不减。这篇文章想聊点实用的:日常项目里最常见的八种内存泄漏模式,以及怎么用 Chrome DevTools 一步步定位到具体代码。

我见过不少同事把这类问题归咎于“浏览器优化不够”,但实际上,问题几乎都藏在一些不起眼的编码习惯里。下面按常见程度从高到低写,每一节都尽量给出一个短小的代码样例。
从全局变量和定时器开始:最常见的两个坑
模式一:意外的全局变量
在非严格模式下,给未声明的变量赋值会自动挂到 window 上,这几乎是唯一一种不需要你写任何额外代码就能发生的泄漏。
function loadUser(id) {
user = fetchUser(id); // 少写了 const / let
return user;
}
这里的 user 被挂到全局对象上,页面不刷新就会一直存在。假如 loadUser 被频繁调用,每次返回的数据还会不断覆盖,但先前的大对象引用不会立刻消失,堆积也很可观。
更隐蔽的形式是在函数里用 this 赋值。普通函数调用时 this 指向 window,如果写的是 this.foo = something,同样会污染全局作用域。只要代码处于非严格模式,这些写法都能成功执行,所以建议整个项目默认开启 strict mode。
模式二:被遗忘的定时器回调
let el = document.getElementById('refresh');
let timer = setInterval(() => {
update(el);
}, 1000);
// 组件销毁时没有 clearInterval(timer)
定时器在动作时会维持对回调函数的引用,回调闭包内所有变量也都不会被回收。这个问题在 SPA 路由切换时留下了很多后患,尤其是仪表盘、数据大屏这类需要轮询的页面。页面切走了,定时器若没有 clear,就会一直运行,被引用住的旧 DOM、旧 state 全成了泄漏。
这里有一个很容易被轻视的点:就算回调体很小,定时器本身的存在也意味着整个函数作用域被保留。如果回调里引用了一个很大的对象,那么即使定时器每次只访问对象的一个字段,整个对象也无法被释放。
事件监听器、Observer 和 DOM 引用:回收机制的死角
模式三:事件监听器绑定后没有解绑
在框架普及后,事件监听器的自动清理已经做得不错,但只要还有手写 DOM 操作,或者使用的旧插件接管了事件,问题仍不可避免。
function openModal() {
document.addEventListener('keydown', onKeyDown);
}
function closeModal() {
// 没有 removeEventListener('keydown', onKeyDown)
}
一个典型的场景是自定义弹窗组件。打开弹窗时在 document 上注册一个键盘监听,用来处理 Esc 键关闭,关闭弹窗时却忘了移除这个全局监听。用户每打开一次,就多一个监听器,同时监听器引用的旧弹窗 DOM 还会残留。
这种问题在 React 的 useEffect、Vue 的 beforeDestroy 里总结过很多次,但依然会有遗漏。建议把全局事件的添加和清理写在同一个函数里,或者使用 AbortController 管理,这样不容易漏。
模式四:Observer 创建了却没有断开
const observer = new MutationObserver(mutationList => {
update(mutationList);
});
observer.observe(document.getElementById('app'), {
childList: true,
subtree: true
});
// 组件卸载时忘记 observer.disconnect()
Modern Web API 里有一批 Observer,例如 MutationObserver、IntersectionObserver、ResizeObserver。它们和事件监听器非常像,都有生命周期,却很容易被忽略。很多人会记得观察,却不记得 disconnect 或 unobserve。
以 ResizeObserver 为例,一个页面有多个图表组件,每次挂载都创建一个 ResizeObserver 来监听容器尺寸。如果组件销毁时没有调用 disconnect,这些观察器就会随着容器一起留在内存里。而且每次窗口尺寸变化,都会触发对应的回调整体 update,造成不必要的计算。
模式五:DOM 节点已经移除,但引用还留在对象容器里
const wrapper = document.querySelector('.wrapper');
const myMap = new Map();
myMap.set('key', wrapper);
wrapper.remove();
// myMap 仍然持有 wrapper,整棵子树和事件处理器都不会释放
这个模式和事件监听类似,但更容易被忽略。因为事件监听常常被文档提及,而“把 DOM 节点作为 Map 的 key”看起来只是数据结构问题。
实际项目中,经常为了记录某个区块的可见状态而把元素放入一个 Set 或 Map,之后又用 remove() 从页面上移除节点,但忘了从容器里清掉条目。于是节点本身、它的后代节点、绑定的事件回调,都通过这条引用链存活下来。
对这种需求,优先考虑用 WeakMap 来映射节点和数据,这样当节点不再被页面其他地方使用时,WeakMap 的弱引用不会阻止回收。
模式六:闭包长期占有一个用不到的大作用域
let listener;
function setup() {
const hugeData = new Array(10000).fill('x');
listener = function() {
console.log(hugeData);
};
}
setup();
闭包本身不是坏东西,问题出在“存活时间过长”和“捕获范围过大”同时具备。上面这个例子中,listener 被赋值到全局,闭包捕获了 hugeData,于是即使 setup 早已执行完,hugeData 依然驻留在内存里。
更多的场景是:某个小回调只为了读一个配置项,却捕获了大数组或整个组件实例作为上下文。这种情况下,闭包所保住的不仅是它直接引用的变量,整个词法环境都会被保留。要缓解这一点,可以在创建闭包前先裁剪数据,或者避免在长生命周期对象中定义少字段用的闭包。
从开发习惯里挖漏:console.log 和无限缓存
模式七:无上限的缓存和 Map
const snapshotCache = new Map();
let index = 0;
setInterval(() => {
const largeObj = new Array(10000).fill('leak');
snapshotCache.set(index++, largeObj);
}, 1000);
缓存是所有内存泄漏模式中最“理直气壮”的一种,因为缓存看起来就应该有数据。但没有容量上限的缓存,本质上就是一条不断在膨胀的链表。
比较常见的用法:后台系统里,用户每次切换 Tab 都把当前查询结果存下来,方便回退。起初只有几条数据,页面还很流畅。但一整天操作下来,缓存里可能存了几十个页面快照,每个快照里可能包含几百行数据,内存自然就上去了。
像这种场景,用 WeakMap 不一定合适,因为你想缓存的是和某个查询 key 关联的数据,key 是字符串,也不是对象。比较实际的做法是给缓存设置容量,比如 100 条,达到上限时删除最久未使用的条目。这也是 LRU 缓存的保守实现。
模式八:console.log 留下的影子引用
严格说,console.log 不算真正的内存泄漏,因为如果控制台没有打开,引用通常会被释放。但在实际开发中,很多团队会把 console.log 留在生产代码里,而用户或 QA 经常开着 DevTools,于是引用就一直存活。
console.log(this.someLargeList); // 这条日志没有任何业务价值,但列表对象被 Console 端引用
印象里有一个项目,为了排查一个线上偶发问题,在接口回调里加了一行 console.log 打印完整响应,顺手就留在了生产环境。后来页面在 Chrome 里越用越卡,每次打开 DevTools 都能看到堆内存持续上涨,找不到原因。直到有人注意到控制台在频繁输出,才意识到是日志本身在“保存”这些响应对象。
最稳妥的方案是在生产构建时移除所有 console 方法,或者在打包工具中设置环境变量判断。如果需要线上日志,尽量只输出可序列化的摘要,而不是引用完整对象。
用 Chrome DevTools 把泄漏定位到具体代码
排查内存泄漏不需要很深的原理,关键是知道该用哪个工具、怎样读结果。下面是一套从确认症状到定位嫌疑对象的流程。
先用 Performance Monitor 判断有没有泄漏趋势
打开 DevTools 的 Performance Monitor,勾选 JavaScript heap size、DOM node count、Event listeners 三项。在页面里正常做一段时间操作,如果堆大小或 DOM 节点数量呈阶梯式上升,而且没有回落的迹象,基本可以确定有泄漏。
再用 Heap Snapshot 找引用链
接下来是重头戏。在 Heap Snapshot 面板抓一个快照,然后进行会导致页面变慢的操作,再拍第二个快照。用“对比(Comparison)”视图,看新增对象的数量和类型。如果某个构造函数创建了大量实例,点击它就能看到很明显的保留者路径,也就是到底是谁在引用着它。
以上面的无上限缓存为例,你会在快照里看到一个巨大的 Map,展开它的 storage 字段,就能看到 key 和 value 的排列,顺着 value 可以找到对应的业务数据对象,再反推是哪个 Map 在增长。
用 Allocation Timeline 快速定位分配点
如果没有明确的增长对象,可以用 Allocation Timeline(现有版本中集成在 Memory 面板)录制一段时间,它会显示每个函数的调用和分配的内存。找到分配量最大的函数,往往就是泄漏的源头。
三个工具各有侧重,适合在不同阶段使用。我用下面这个表格做了简单对比:
| 工具 | 核心作用 | 适用阶段 | 注意事项 |
|---|---|---|---|
| Performance Monitor | 实时监控堆、DOM、监听器数量 | 确认是否存在趋势 | 定位不精确 |
| Heap Snapshot | 分析堆快照中的对象和引用链 | 识别已存在的泄漏对象 | 需要手动比较 |
| Allocation Timeline | 记录分配过程,查看函数分配量 | 定位高频分配代码 | 录制开销较大 |
实际排查时,通常先从 Performance Monitor 判断方向,再通过 Heap Snapshot 定位对象,最后用 Allocation Timeline 确认具体代码位置。三步走下来,大多数泄漏都能被找到。
三个常见误区和一组值得养成的习惯
误区一:只有复杂应用才会泄漏
实际上,像意外全局变量这类问题,一个简单轮询页面就能出现。内存泄漏和项目规模没有必然联系,反而是一些小项目因为缺少代码评审,留下的隐患更多。
误区二:WeakMap / WeakRef 可以直接解决所有缓存问题
WeakMap 虽然在 key 上是弱引用,但如果 value 是一个对象,而这个对象又引用了 key,就会形成很强的“key -> value -> key”链路,GC 没法回收。而且 WeakMap 没有遍历方法,不适合 LRU、过期时间等场景。正确做法是根据具体缓存需求选择不同的策略,不要盲目使用。
误区三:只要不崩溃就说明不需要管
内存泄漏的连续 GC 会让页面卡顿、动画掉帧、移动端发烫,甚至带来更大的性能隐患。通常要等到阻塞主线程的次数多了,用户才会明显感知。
一组落地建议
- 在组件销毁阶段统一清理:定时器、事件监听、Observer、WebSocket,使用统一的生命周期函数集中管理。
- 给专门做缓存的 Map 或数组设置容量上限,至少做到先进先出。
- 生产构建中移除所有 console.*,线上日志改用可序列化的摘要。
- 把上面这八种模式加入代码评审 checklist,尤其是新提交的代码。
- 定期用 Performance Monitor 或自定义埋点记录 performance.memory.usedJSHeapSize,在开发环境做趋势对比。
说到底,内存泄漏的关键不在“清除”,而在“生命周期”
刚学前端的时候,我也觉得内存泄漏是少数人才能遇到的高级问题。后来真正排查过几次才发现,大部分情况都是由一些很日常的疏忽积累起来的。八种模式听上去很多,但核心其实只有一个:一个对象如果比你的预期活得更久,那它就会持续占用内存。所以预防比排查更值得投入精力。在写代码时就想清楚每个对象、每条回调、每份缓存的生命周期,配合 Chrome DevTools 做一些定期的堆快照对比,内存泄漏就不会成为团队里反复出来折磨人的问题了。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/544/