前端内存泄漏是指程序中已分配的堆内存由于某种原因未能被释放,导致内存占用持续增长,最终可能引发页面卡顿甚至浏览器崩溃。尽管 JavaScript 拥有自动垃圾回收(GC)机制,但以下常见场景仍会导致内存泄漏:
1. 意外的全局变量
-
场景:在函数内部未使用
var、let或const声明变量,导致其被挂载到window对象上成为全局变量。 -
后果:全局变量在页面生命周期内一直存在,除非手动删除或页面关闭,否则不会被 GC 回收。
-
示例:
1function leak() { 2 data = "some large data"; // 忘记写 var/let/const 3}
2. 定时器未清理 (setInterval / setTimeout)
- 场景:启动了定时器(尤其是
setInterval),但在组件销毁或页面跳转时没有调用clearInterval或clearTimeout。 - 后果:定时器回调函数及其引用的变量会一直被持有,即使相关 DOM 已移除,内存也无法释放。这在单页应用(SPA)中尤为常见。
- 解决:在组件卸载(如 Vue 的
beforeUnmount、React 的useEffect清理函数)时务必清除定时器。
3. 闭包引用不当
-
场景:闭包函数引用了外部作用域的大对象,且该闭包本身被长期持有(例如挂载在全局事件监听器、定时器或单例模式中)。
-
后果:只要闭包存在,它引用的外部变量就无法被回收。
-
示例:
1function createLeak() { 2 const hugeData = new Array(1000000).fill('*'); 3 return function() { 4 console.log(hugeData.length); // 闭包持有了 hugeData 5 }; 6} 7const leakFunc = createLeak(); 8// 如果 leakFunc 一直不被置为 null 或移除,hugeData 永远无法回收
4. 未移除的事件监听器
- 场景:给 DOM 元素添加了事件监听器(
addEventListener),但在移除该 DOM 元素前,没有手动移除对应的监听器(removeEventListener)。 - 后果:事件监听器会持有对 DOM 元素及回调函数中引用变量的引用,导致 DOM 树虽然从页面上消失,但在内存中依然存在(Detached DOM trees)。
- 注意:使用框架(如 React/Vue)时通常会自动处理,但在直接操作 DOM 或使用第三方库时需格外小心。
5. DOM 引用泄漏 (Detached DOM Trees)
- 场景:JavaScript 代码中持有了对已移除 DOM 节点的引用(例如存储在数组、对象或闭包中)。
- 后果:即使 DOM 节点已从文档树中移除,由于 JS 侧仍有引用,GC 无法回收该节点及其子树。
- 典型表现:Chrome DevTools 中的 "Detached DOM tree"。
6. 缓存未清理
- 场景:使用了对象或 Map 作为缓存存储大量数据,但没有设置上限或清理策略(如 LRU)。
- 后果:随着时间推移,缓存无限增长,占用大量内存。
- 解决:使用
WeakMap或WeakSet(允许 GC 回收键值),或实现定期清理机制。
7. 第三方库或框架使用不当
- 场景:某些重型库(如地图库、图表库、富文本编辑器)初始化后创建了复杂的内部结构和事件监听,如果在组件销毁时未正确调用其提供的
destroy()或dispose()方法。 - 后果:库内部持有的引用无法释放,造成严重泄漏。
如何检测与排查?
-
Chrome DevTools Memory 面板:
- Heap Snapshot(堆快照) :对比不同时间点的快照,查找应被回收但未回收的对象(如 Detached DOM nodes)。
- Allocation instrumentation on timeline:实时查看内存分配情况。
-
Performance Monitor:观察 JS Heap 使用率是否随时间单调递增。
最佳实践总结
- 严格声明变量:始终使用
let或const。 - 及时清理:组件销毁时清除定时器、移除事件监听、取消网络请求。
- 弱引用:对于缓存场景,优先考虑
WeakMap/WeakSet。 - 框架生命周期:充分利用 Vue/React/Angular 的生命周期钩子进行资源清理。
- 定期审查:在代码 Review 中关注长生命周期对象对短生命周期对象的引用。