JavaScript 中内存泄漏有哪几种情况

6 阅读9分钟

内存泄漏(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 回收。

解决方案

  1. 严格模式(use strict):禁止隐式全局变量,未声明变量直接报错;

  2. 避免不必要的全局变量,临时变量用局部变量(let/const);

  3. 全局变量使用完毕后,手动置为 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 就不会回收。

解决方案

  1. DOM 移除后,手动将引用置为 null

    const btn = document.getElementById('myBtn');
    document.body.removeChild(btn);
    btn = null; // 解除引用,GC 可回收
    
  2. 闭包 / 缓存中引用 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 回收。

解决方案

  1. 定时器使用完毕后,调用 clearInterval/clearTimeout

    const timer = setInterval(() => { /* ... */ }, 1000);
    // 功能结束后清除
    clearInterval(timer);
    
  2. 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;
    
  3. 可选方案:使用事件委托(减少监听器数量),或 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 回收。

解决方案

  1. 闭包仅引用必要的变量,避免捕获大对象;

  2. 闭包使用完毕后,手动解除引用:

    let unusedClosure = heavyFunc();
    unusedClosure = null; // 闭包被释放,bigData 可回收
    
  3. 拆分函数,减少闭包捕获的变量范围:

    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 判定为 “正在使用”,即使元素本身已无用。

解决方案

  1. 无用数据及时从集合中删除:

    // 数组:清空或截断
    window.cacheList.length = 0; // 清空数组
    // Map/Set:删除指定键
    map.delete('key');
    
  2. 集合使用完毕后,手动置为 null

    window.cacheList = null;
    map = null;
    
  3. 用 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 仍因监听器引用无法回收

原因

根节点的事件监听器生命周期与页面一致,且监听器内部的引用链不会自动断开,导致关联数据长期占用内存。

解决方案

  1. 监听器内部仅引用必要数据,避免捕获大对象;

  2. 功能销毁时,移除根节点的事件监听器:

    const clickHandler = (e) => { /* ... */ };
    document.addEventListener('click', clickHandler);
    // 功能结束后移除
    document.removeEventListener('click', clickHandler);
    
  3. 使用 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 回收;即使连接断开,未清理的回调引用仍会导致内存泄漏。

解决方案

  1. 功能结束 / 页面卸载时,关闭网络连接:

    ws.close(); // 关闭 WebSocket
    xhr.abort(); // 终止 XHR 请求
    
  2. 连接关闭后,解除回调引用:

    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 中移除事件
  }
});

原因

框架 / 第三方库通常有自己的内存管理逻辑,若未按规范销毁实例、清理回调,会导致内部引用链无法断开。

解决方案

  1. React:useEffect 中必须返回清理函数(清除定时器、监听器);
  2. Vue:自定义指令在 unbind 钩子中清理事件 / 引用,组件在 beforeUnmount 中销毁实例;
  3. 第三方插件:使用完毕后调用 destroy/dispose 方法(如 ECharts 的 myChart.dispose())。

九、内存泄漏的排查方法

1. 浏览器 DevTools(Chrome/Firefox)

  • Memory 面板

    1. 录制 “堆快照(Heap Snapshot)”,对比多次快照的内存变化;
    2. 筛选 “Detached DOM nodes”(分离的 DOM 节点),定位未清理的 DOM 引用;
    3. 查看 “Retainers”(引用链),找到谁在引用无用对象。
  • Performance 面板

    1. 勾选 “Memory”,录制页面操作流程;
    2. 查看内存曲线,若持续上升且不回落,大概率存在泄漏。

2. Node.js 环境

  • 使用 node --inspect 启动进程,通过 Chrome DevTools 远程调试;
  • 工具:clinic.jsheapdump 生成堆快照,分析内存泄漏。

3. 通用排查步骤

  1. 复现泄漏场景(如多次执行某功能、页面跳转);
  2. 对比堆快照,找到持续增长的对象类型(如 DOM、数组、大对象);
  3. 追溯该对象的引用链,定位未清理的引用;
  4. 修复后,验证内存曲线是否恢复正常。

总结

JS 内存泄漏的核心是「无用对象被可达引用链指向」,常见场景可归纳为:

泄漏类型核心原因解决方案
意外全局变量全局作用域引用未释放严格模式 + 手动置 null
未清理 DOM 引用DOM 移除后仍有 JS 引用解除 DOM 引用 + null
未清理定时器 / 监听器持久引用链未断开clearInterval + removeEventListener
闭包泄漏闭包捕获无用大对象解除闭包引用 + 拆分函数
集合未清理数组 / Map 堆积无用数据清空集合 + WeakMap/WeakSet
网络连接未关闭回调引用数据未释放close/abort + 解除回调引用
框架 / 库泄漏未按规范销毁实例清理函数 + destroy 方法

排查和修复内存泄漏的关键是:找到 “谁在引用无用对象”,并断开这条引用链。日常开发中,养成 “使用完毕即清理引用” 的习惯,可大幅减少内存泄漏问题。