内存泄漏(Memory Leak)指 JS 中不再使用的内存无法被垃圾回收机制(GC)释放,导致内存占用持续升高,最终引发页面卡顿、崩溃,甚至浏览器进程异常。JS 内存泄漏的核心原因是:无用对象仍被可达的引用链指向,GC 判定其 “仍在使用”,无法回收。
以下是 JS 中最常见的 8 类内存泄漏场景,附具体示例、原因分析和解决方案:
一、意外的全局变量
场景
未声明的变量会自动挂载到 window(浏览器)/ global(Node.js)上,成为全局变量;或刻意声明的全局变量,在页面生命周期内未释放。
// 1. 未声明变量(隐式全局)
function fn() {
// 无 var/let/const,自动成为 window.unusedVar
unusedVar = '我是意外的全局变量';
}
fn();
// 2. 显式全局变量(未清理)
window.globalData = {
list: new Array(100000).fill('大量数据')
};
// 页面卸载/功能结束后,未执行 window.globalData = null
原因
全局变量的生命周期与页面 / 进程一致,只要页面不刷新(浏览器)、进程不退出(Node.js),其占用的内存永远不会被 GC 回收。
解决方案
-
严格模式(
use strict):禁止隐式全局变量,未声明变量直接报错; -
避免不必要的全局变量,临时变量用局部变量(
let/const); -
全局变量使用完毕后,手动置为
null解除引用:window.globalData = null; // 释放内存
二、未清理的 DOM 引用
场景
删除 DOM 元素后,JS 仍保留对该 DOM 的引用,导致 DOM 无法被 GC 回收(“僵尸 DOM”)。
// 示例:按钮被移除后,仍被变量引用
const btn = document.getElementById('myBtn');
document.body.removeChild(btn);
// btn 仍指向原 DOM 节点,内存无法释放
console.log(btn); // 仍能打印<button>,未被回收
进阶场景:闭包 / 缓存中的 DOM 引用
function createComponent() {
const div = document.createElement('div');
div.innerHTML = '大量内容';
// 闭包保留 div 引用
return function() {
console.log(div);
};
}
const unusedFunc = createComponent();
// 即使 div 未插入 DOM,也因闭包引用无法回收
原因
DOM 节点的回收条件是:既不在 DOM 树中,也没有任何 JS 引用。只要有 JS 变量指向它,GC 就不会回收。
解决方案
-
DOM 移除后,手动将引用置为
null:const btn = document.getElementById('myBtn'); document.body.removeChild(btn); btn = null; // 解除引用,GC 可回收 -
闭包 / 缓存中引用 DOM 时,功能结束后清理引用:
let unusedFunc = createComponent(); unusedFunc = null; // 解除闭包引用,div 可被回收
三、遗忘的定时器 / 事件监听器
1. 未清除的定时器(setInterval/setTimeout)
// 示例:定时器引用无用对象,且未清除
const data = { list: new Array(100000).fill('数据') };
setInterval(() => {
console.log(data.list); // 定时器持有 data 引用
}, 1000);
// 即使 data 不再使用,也因定时器引用无法回收
2. 未移除的事件监听器
// 示例:DOM 移除后,事件监听器未解绑
const btn = document.getElementById('myBtn');
btn.addEventListener('click', () => {
console.log('点击了按钮');
});
document.body.removeChild(btn);
// 事件监听器仍持有 btn 引用,导致 btn 无法回收
原因
定时器 / 事件监听器会形成持久的引用链:定时器回调 → 内部变量 → 外部对象;事件监听器 → DOM 元素。只要定时器不清除、监听器不移除,引用的对象就无法被 GC 回收。
解决方案
-
定时器使用完毕后,调用
clearInterval/clearTimeout:const timer = setInterval(() => { /* ... */ }, 1000); // 功能结束后清除 clearInterval(timer); -
DOM 移除前,移除对应的事件监听器:
const btn = document.getElementById('myBtn'); const clickHandler = () => { console.log('点击'); }; btn.addEventListener('click', clickHandler); // 移除 DOM 前解绑 btn.removeEventListener('click', clickHandler); document.body.removeChild(btn); btn = null; -
可选方案:使用事件委托(减少监听器数量),或
AbortController管理监听器(现代浏览器)。
四、闭包导致的内存泄漏
场景
闭包意外保留对大对象 / 无用对象的引用,导致内存无法释放。
function heavyFunc() {
// 大对象(占用大量内存)
const bigData = new Array(1000000).fill('大数组');
// 闭包仅使用 smallVar,但仍保留 bigData 引用
const smallVar = '小变量';
return function() {
console.log(smallVar);
};
}
const unusedClosure = heavyFunc();
// unusedClosure 持有 bigData 引用,即使不用 bigData,也无法回收
原因
闭包会捕获外层作用域的所有变量(而非仅使用的变量),只要闭包可达,外层作用域的所有变量都无法被 GC 回收。
解决方案
-
闭包仅引用必要的变量,避免捕获大对象;
-
闭包使用完毕后,手动解除引用:
let unusedClosure = heavyFunc(); unusedClosure = null; // 闭包被释放,bigData 可回收 -
拆分函数,减少闭包捕获的变量范围:
function heavyFunc() { const bigData = new Array(1000000).fill('大数组'); // 拆分出仅返回小变量的函数,避免闭包捕获 bigData return createSmallClosure(); } function createSmallClosure() { const smallVar = '小变量'; return () => console.log(smallVar); }
五、未清理的数组 / 集合引用
场景
数组、对象、Map/Set 等集合中存储了大量无用数据,但未及时清理,导致这些数据无法被回收。
// 示例1:全局数组缓存数据,未清理
window.cacheList = [];
function loadData() {
// 每次调用都添加大量数据
window.cacheList.push(new Array(100000).fill('缓存数据'));
}
// 调用多次后,cacheList 堆积大量无用数据
loadData();
loadData();
// 未执行 window.cacheList = [] 或 cacheList.length = 0
// 示例2:Map/Set 引用未删除
const map = new Map();
const obj = { name: 'test' };
map.set('key', obj);
// obj 不再使用,但未从 map 中删除
// map 仍引用 obj,导致 obj 无法回收
原因
数组 / 集合是 “容器型引用”,只要容器本身可达(如全局数组),其内部所有元素都会被 GC 判定为 “正在使用”,即使元素本身已无用。
解决方案
-
无用数据及时从集合中删除:
// 数组:清空或截断 window.cacheList.length = 0; // 清空数组 // Map/Set:删除指定键 map.delete('key'); -
集合使用完毕后,手动置为
null:window.cacheList = null; map = null; -
用 WeakMap/WeakSet 替代 Map/Set(关键场景):
// WeakMap 的键是弱引用,无其他引用时会被 GC 自动回收 const weakMap = new WeakMap(); const obj = { name: 'test' }; weakMap.set(obj, 'value'); obj = null; // obj 无其他引用,GC 会自动移除 weakMap 中的该键值对
六、DOM 事件委托不当(进阶场景)
场景
给 document/body 等根节点绑定过多事件监听器,且监听器内部引用了大量无用数据。
// 示例:给 document 绑定监听器,且引用大对象
const bigData = new Array(1000000).fill('数据');
document.addEventListener('click', (e) => {
if (e.target.id === 'myBtn') {
console.log(bigData); // 监听器持有 bigData 引用
}
});
// 即使 myBtn 被移除,bigData 仍因监听器引用无法回收
原因
根节点的事件监听器生命周期与页面一致,且监听器内部的引用链不会自动断开,导致关联数据长期占用内存。
解决方案
-
监听器内部仅引用必要数据,避免捕获大对象;
-
功能销毁时,移除根节点的事件监听器:
const clickHandler = (e) => { /* ... */ }; document.addEventListener('click', clickHandler); // 功能结束后移除 document.removeEventListener('click', clickHandler); -
使用
AbortController管理监听器(现代浏览器):const controller = new AbortController(); document.addEventListener('click', (e) => { /* ... */ }, { signal: controller.signal }); // 一键移除所有关联监听器 controller.abort();
七、WebSocket/XMLHttpRequest 未关闭
场景
网络连接(WebSocket、XHR 长连接)未关闭,且连接回调中引用了大量数据。
// 示例:WebSocket 未关闭,且引用大对象
const ws = new WebSocket('wss://example.com');
const bigData = new Array(1000000).fill('数据');
ws.onmessage = (e) => {
console.log(bigData, e.data); // 持有 bigData 引用
};
// 页面跳转/功能结束后,未执行 ws.close()
原因
WebSocket/XHR 连接未关闭时,其回调函数会持续存在,且引用的变量无法被 GC 回收;即使连接断开,未清理的回调引用仍会导致内存泄漏。
解决方案
-
功能结束 / 页面卸载时,关闭网络连接:
ws.close(); // 关闭 WebSocket xhr.abort(); // 终止 XHR 请求 -
连接关闭后,解除回调引用:
ws.onmessage = null; ws.onclose = null;
八、第三方库 / 框架的内存泄漏
场景
使用 React/Vue/ 第三方插件时,未按规范销毁实例,导致框架内部保留无用引用。
// React 示例:组件卸载后未清除定时器
function MyComponent() {
useEffect(() => {
const timer = setInterval(() => { /* ... */ }, 1000);
// 缺少清理函数,组件卸载后定时器仍运行
// return () => clearInterval(timer); // 正确做法:添加清理
}, []);
return <div>组件</div>;
}
// Vue 示例:全局自定义指令未解绑
Vue.directive('my-directive', {
bind(el) {
el.addEventListener('scroll', () => { /* ... */ });
// 未在 unbind 中移除事件
}
});
原因
框架 / 第三方库通常有自己的内存管理逻辑,若未按规范销毁实例、清理回调,会导致内部引用链无法断开。
解决方案
- React:
useEffect中必须返回清理函数(清除定时器、监听器); - Vue:自定义指令在
unbind钩子中清理事件 / 引用,组件在beforeUnmount中销毁实例; - 第三方插件:使用完毕后调用
destroy/dispose方法(如 ECharts 的myChart.dispose())。
九、内存泄漏的排查方法
1. 浏览器 DevTools(Chrome/Firefox)
-
Memory 面板:
- 录制 “堆快照(Heap Snapshot)”,对比多次快照的内存变化;
- 筛选 “Detached DOM nodes”(分离的 DOM 节点),定位未清理的 DOM 引用;
- 查看 “Retainers”(引用链),找到谁在引用无用对象。
-
Performance 面板:
- 勾选 “Memory”,录制页面操作流程;
- 查看内存曲线,若持续上升且不回落,大概率存在泄漏。
2. Node.js 环境
- 使用
node --inspect启动进程,通过 Chrome DevTools 远程调试; - 工具:
clinic.js、heapdump生成堆快照,分析内存泄漏。
3. 通用排查步骤
- 复现泄漏场景(如多次执行某功能、页面跳转);
- 对比堆快照,找到持续增长的对象类型(如 DOM、数组、大对象);
- 追溯该对象的引用链,定位未清理的引用;
- 修复后,验证内存曲线是否恢复正常。
总结
JS 内存泄漏的核心是「无用对象被可达引用链指向」,常见场景可归纳为:
| 泄漏类型 | 核心原因 | 解决方案 |
|---|---|---|
| 意外全局变量 | 全局作用域引用未释放 | 严格模式 + 手动置 null |
| 未清理 DOM 引用 | DOM 移除后仍有 JS 引用 | 解除 DOM 引用 + null |
| 未清理定时器 / 监听器 | 持久引用链未断开 | clearInterval + removeEventListener |
| 闭包泄漏 | 闭包捕获无用大对象 | 解除闭包引用 + 拆分函数 |
| 集合未清理 | 数组 / Map 堆积无用数据 | 清空集合 + WeakMap/WeakSet |
| 网络连接未关闭 | 回调引用数据未释放 | close/abort + 解除回调引用 |
| 框架 / 库泄漏 | 未按规范销毁实例 | 清理函数 + destroy 方法 |
排查和修复内存泄漏的关键是:找到 “谁在引用无用对象”,并断开这条引用链。日常开发中,养成 “使用完毕即清理引用” 的习惯,可大幅减少内存泄漏问题。