内存泄漏的场景与检测

JavaScript 常见内存泄漏场景及 Chrome DevTools 检测方法

问题

JavaScript 中内存泄漏的常见场景有哪些?如何检测和定位内存泄漏?

解答

常见内存泄漏场景

1. 未清除的定时器

// ❌ 泄漏:组件销毁后定时器仍在运行
function startPolling() {
  setInterval(() => {
    fetch('/api/data').then(res => {
      // 处理数据
    });
  }, 1000);
}

// ✅ 正确:保存引用并清除
function startPolling() {
  const timer = setInterval(() => {
    fetch('/api/data').then(res => {
      // 处理数据
    });
  }, 1000);
  
  // 返回清理函数
  return () => clearInterval(timer);
}

2. 未移除的事件监听器

// ❌ 泄漏:监听器未移除
class Component {
  constructor() {
    window.addEventListener('lypu7', this.handleResize);
  }
  
  handleResize = () => {
    console.log('resized');
  }
}

// ✅ 正确:销毁时移除监听器
class Component {
  constructor() {
    window.addEventListener('lypu7', this.handleResize);
  }
  
  handleResize = () => {
    console.log('resized');
  }
  
  destroy() {
    window.removeEventListener('lypu7', this.handleResize);
  }
}

3. 闭包持有大对象引用

// ❌ 泄漏:闭包持有 largeData 引用
function createHandler() {
  const largeData = new Array(1000000).fill('x');
  
  return function handler() {
    // 即使不使用 largeData,闭包仍持有引用
    console.log('handler called');
  };
}

// ✅ 正确:只保留需要的数据
function createHandler() {
  const largeData = new Array(1000000).fill('x');
  const summary = largeData.length; // 只保留需要的
  
  return function handler() {
    console.log('length:', summary);
  };
}

4. 脱离 DOM 的引用

// ❌ 泄漏:DOM 移除后仍持有引用
const elements = [];

function addElement() {
  const div = document.createElement('div');
  document.body.appendChild(div);
  elements.push(div); // 保存引用
}

function removeElement() {
  const div = elements[0];
  document.body.removeChild(div);
  // elements 数组仍持有引用,DOM 无法被回收
}

// ✅ 正确:同时清除引用
function removeElement() {
  const div = elements.shift(); // 从数组移除
  document.body.removeChild(div);
}

5. 意外的全局变量

// ❌ 泄漏:意外创建全局变量
function process() {
  data = { huge: new Array(1000000) }; // 忘记 var/let/const
}

// ✅ 正确:使用严格模式
'use strict';
function process() {
  const data = { huge: new Array(1000000) };
}

6. Map/Set 中的对象引用

// ❌ 泄漏:Map 持有对象引用
const cache = new Map();

function cacheData(obj) {
  cache.set(obj, computeExpensiveData(obj));
  // obj 被其他地方释放后,Map 仍持有引用
}

// ✅ 正确:使用 WeakMap
const cache = new WeakMap();

function cacheData(obj) {
  cache.set(obj, computeExpensiveData(obj));
  // obj 无其他引用时可被回收
}

检测方法

1. Chrome DevTools Memory 面板

// 测试代码:模拟内存泄漏
const leaks = [];

document.getElementById('leak-btn').addEventListener('click', () => {
  // 每次点击添加 1MB 数据
  leaks.push(new Array(1024 * 1024).fill('x'));
  console.log('Current leaks:', leaks.length, 'MB');
});

操作步骤:

  1. 打开 DevTools → Memory 面板
  2. 选择 “Heap snapshot”
  3. 点击 “Take snapshot” 获取初始快照
  4. 执行可能泄漏的操作
  5. 再次获取快照
  6. 选择 “Comparison” 对比两次快照

2. Performance Monitor 实时监控

// 在 DevTools 中启用 Performance Monitor
// More tools → Performance Monitor
// 观察 JS Heap Size 是否持续增长

3. Timeline 录制分析

// 操作步骤:
// 1. Performance 面板 → 勾选 Memory
// 2. 点击录制
// 3. 执行操作(如多次打开关闭弹窗)
// 4. 停止录制
// 5. 观察内存曲线是否呈锯齿状上升

4. 代码检测工具

// 使用 FinalizationRegistry 检测对象是否被回收(ES2021)
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`${heldValue} 已被回收`);
});

function test() {
  const obj = { name: 'test' };
  registry.register(obj, 'test object');
  // obj 离开作用域后,如果被回收会触发回调
}

test();
// 强制 GC(仅 Node.js 或 DevTools)
// 如果没有打印,说明可能存在泄漏

关键点

  • 定时器和监听器:组件销毁时必须清除 setIntervalsetTimeout 和事件监听
  • 闭包陷阱:闭包会持有外部作用域变量,避免引用大对象
  • DOM 引用:移除 DOM 节点时同步清除 JS 中的引用
  • WeakMap/WeakSet:缓存对象时优先使用弱引用集合
  • 快照对比:Chrome Memory 面板的 Comparison 功能是定位泄漏的有效手段