Skip to content

内存泄漏防范:闭包使用中的陷阱与解决方案

内存泄漏是什么

想象你在经营一个仓库。随着业务增长,你不断往仓库里添加货物。如果你只记得放入货物,却忘记定期清理那些已经不需要的旧货物,仓库最终会被堆满,新货物将无处可放。JavaScript 中的内存泄漏就像这个场景——程序不断分配内存,但某些已经不再需要的内存却无法被垃圾回收机制回收,导致可用内存越来越少,最终影响程序性能甚至崩溃。

闭包虽然强大,但如果使用不当,很容易成为内存泄漏的源头。这是因为闭包会持有对外部作用域变量的引用,即使这些变量在逻辑上已经不再需要,但只要闭包还存在,这些变量占用的内存就无法被释放。

JavaScript 的垃圾回收机制

在了解内存泄漏之前,我们需要先理解 JavaScript 是如何管理内存的。

标记清除算法

JavaScript 主要使用"标记-清除"(Mark-and-Sweep)算法进行垃圾回收:

javascript
// 垃圾回收的基本原理
function demonstrateGC() {
  // 这个对象在函数作用域内
  let temporaryData = {
    largeArray: new Array(1000000).fill("data"),
    timestamp: Date.now(),
  };

  // 使用数据
  console.log(temporaryData.timestamp);

  // 函数结束后,如果没有其他引用
  // temporaryData 会被标记为可回收
}

demonstrateGC();
// 函数执行完毕,temporaryData 失去所有引用
// 在下次垃圾回收时,其内存会被释放

引用计数的问题

早期的 JavaScript 引擎使用引用计数,但这种方法有循环引用的问题:

javascript
// 循环引用示例(在旧浏览器中会导致内存泄漏)
function createCircularReference() {
  const objA = {};
  const objB = {};

  // 创建循环引用
  objA.ref = objB;
  objB.ref = objA;

  // 即使函数结束,由于相互引用
  // 引用计数永远不会为0
  // (现代引擎已经解决了这个问题)
}

现代 JavaScript 引擎通过标记-清除算法解决了循环引用问题,但闭包带来的内存泄漏仍然需要开发者注意。

闭包导致的常见内存泄漏

意外保留大对象的引用

闭包会保留对其外部作用域所有变量的引用,即使只使用了其中一个:

javascript
// 问题代码
function createProcessor() {
  // 一个大型数据对象
  const largeData = {
    users: new Array(100000).fill({ name: "User", data: "x".repeat(1000) }),
    config: {
      /* 大量配置 */
    },
    cache: new Map(),
  };

  // 假设我们只需要其中一个小属性
  const configValue = largeData.config.someValue;

  // 返回的闭包会保留对整个 largeData 的引用
  return function process(item) {
    // 即使这里只使用 configValue
    // 整个 largeData 对象都无法被回收
    return item + configValue;
  };
}

const processor = createProcessor();
// 现在内存中保留着整个大型 largeData 对象

// 解决方案:只保留需要的数据
function createProcessorFixed() {
  const largeData = {
    users: new Array(100000).fill({ name: "User", data: "x".repeat(1000) }),
    config: { someValue: 42 },
    cache: new Map(),
  };

  // 提取需要的值
  const configValue = largeData.config.someValue;

  // largeData 在函数结束后可以被回收
  // 闭包只保留 configValue
  return function process(item) {
    return item + configValue;
  };
}

定时器中的闭包

定时器是造成内存泄漏的常见原因之一:

javascript
// 问题代码
function startPolling(userData) {
  // userData 可能是一个大对象
  const data = {
    user: userData,
    cache: new Map(),
    history: [],
  };

  // 定时器创建闭包,持有 data 的引用
  setInterval(function () {
    // 即使不再需要 data,它也会一直存在
    console.log("Polling...");
    data.history.push(Date.now());
  }, 1000);

  // 没有方法清除定时器
  // data 永远不会被回收
}

// 解决方案:返回清理函数
function startPollingFixed(userData) {
  const data = {
    user: userData,
    cache: new Map(),
    history: [],
  };

  const timerId = setInterval(function () {
    console.log("Polling...");
    data.history.push(Date.now());
  }, 1000);

  // 返回清理函数
  return function cleanup() {
    clearInterval(timerId);
    // 帮助垃圾回收
    data.history = [];
    data.cache.clear();
  };
}

// 使用
const cleanup = startPollingFixed({ id: 1, name: "John" });

// 不需要时清理
setTimeout(() => {
  cleanup(); // 释放资源
}, 10000);

事件监听器泄漏

未清理的事件监听器是最常见的内存泄漏源:

javascript
// 问题代码
class UserProfile {
  constructor(userId) {
    this.userId = userId;
    this.data = new Array(10000).fill("user data");

    // 创建事件监听器,形成闭包
    document.addEventListener("click", () => {
      // 这个闭包持有对整个 this 的引用
      // 即使 UserProfile 实例已经"销毁"
      // 它仍然无法被回收
      console.log(`User ${this.userId} clicked something`);
      console.log(this.data.length);
    });
  }
}

// 创建实例
let profile = new UserProfile(123);

// 即使删除引用
profile = null;
// UserProfile 实例仍然无法被回收,因为事件监听器还在

// 解决方案1:保存监听器引用并在适当时机移除
class UserProfileFixed {
  constructor(userId) {
    this.userId = userId;
    this.data = new Array(10000).fill("user data");

    // 保存监听器引用
    this.handleClick = () => {
      console.log(`User ${this.userId} clicked`);
    };

    document.addEventListener("click", this.handleClick);
  }

  destroy() {
    // 清理事件监听器
    document.removeEventListener("click", this.handleClick);

    // 清理数据
    this.data = null;
  }
}

// 使用
const profileFixed = new UserProfileFixed(123);

// 不需要时调用 destroy
profileFixed.destroy();

// 解决方案2:使用 AbortController
class UserProfileBetter {
  constructor(userId) {
    this.userId = userId;
    this.data = new Array(10000).fill("user data");
    this.abortController = new AbortController();

    document.addEventListener(
      "click",
      () => {
        console.log(`User ${this.userId} clicked`);
      },
      { signal: this.abortController.signal }
    );
  }

  destroy() {
    // 一次性移除所有使用此信号的监听器
    this.abortController.abort();
    this.data = null;
  }
}

DOM 引用泄漏

保持对已删除 DOM 元素的引用会阻止其被回收:

javascript
// 问题代码
class DataTable {
  constructor() {
    this.rows = [];
    this.cellCache = new Map();
  }

  addRow(data) {
    const row = document.createElement("tr");
    const cell = document.createElement("td");
    cell.textContent = data;
    row.appendChild(cell);

    // 保存DOM引用
    this.rows.push(row);
    this.cellCache.set(data, cell);

    document.querySelector("#table").appendChild(row);
  }

  clearTable() {
    // 从DOM中移除
    document.querySelector("#table").innerHTML = "";

    // 但 this.rows 和 this.cellCache 仍然持有引用
    // DOM元素无法被回收
  }
}

// 解决方案:清理所有引用
class DataTableFixed {
  constructor() {
    this.rows = [];
    this.cellCache = new Map();
  }

  addRow(data) {
    const row = document.createElement("tr");
    const cell = document.createElement("td");
    cell.textContent = data;
    row.appendChild(cell);

    this.rows.push(row);
    this.cellCache.set(data, cell);

    document.querySelector("#table").appendChild(row);
  }

  clearTable() {
    // 清理DOM
    document.querySelector("#table").innerHTML = "";

    // 清理所有引用
    this.rows = [];
    this.cellCache.clear();
  }

  destroy() {
    this.clearTable();
    // 完全清理
    this.rows = null;
    this.cellCache = null;
  }
}

闭包中累积数据

在闭包中不断累积数据而不清理:

javascript
// 问题代码
function createLogger() {
  const logs = [];

  return {
    log(message) {
      // logs 数组会无限增长
      logs.push({
        message,
        timestamp: Date.now(),
        stackTrace: new Error().stack,
      });
    },

    getLogs() {
      return logs;
    },
  };
}

const logger = createLogger();

// 使用一段时间后,logs 可能变得非常大
for (let i = 0; i < 1000000; i++) {
  logger.log(`Log message ${i}`);
}

// 解决方案:限制数据大小
function createLoggerFixed() {
  const maxLogs = 1000;
  let logs = [];

  return {
    log(message) {
      logs.push({
        message,
        timestamp: Date.now(),
      });

      // 保持在限制内
      if (logs.length > maxLogs) {
        logs = logs.slice(-maxLogs);
      }
    },

    getLogs() {
      return [...logs]; // 返回副本
    },

    clear() {
      logs = [];
    },
  };
}

// 更好的解决方案:使用循环buffer
function createLoggerBetter() {
  const maxLogs = 1000;
  const logs = new Array(maxLogs);
  let index = 0;
  let count = 0;

  return {
    log(message) {
      logs[index] = {
        message,
        timestamp: Date.now(),
      };

      index = (index + 1) % maxLogs;
      count = Math.min(count + 1, maxLogs);
    },

    getLogs() {
      // 返回实际的日志
      const start = count < maxLogs ? 0 : index;
      const result = [];

      for (let i = 0; i < count; i++) {
        result.push(logs[(start + i) % maxLogs]);
      }

      return result;
    },
  };
}

识别内存泄漏

使用浏览器开发工具

现代浏览器提供了强大的内存分析工具:

javascript
// 创建一个会泄漏的场景
class LeakyComponent {
  constructor(id) {
    this.id = id;
    this.data = new Array(100000).fill(`data for ${id}`);

    // 泄漏点:事件监听器
    window.addEventListener("resize", () => {
      console.log(`Component ${this.id} handling resize`);
    });
  }
}

// 测试内存泄漏
let components = [];

function addComponents() {
  for (let i = 0; i < 100; i++) {
    components.push(new LeakyComponent(i));
  }
}

function clearComponents() {
  components = [];
  // 即使清空引用,由于事件监听器,内存不会被释放
}

// 在浏览器控制台:
// 1. 打开 Performance (性能) 标签
// 2. 开始记录
// 3. 调用 addComponents() 几次
// 4. 调用 clearComponents()
// 5. 强制垃圾回收
// 6. 查看内存是否下降

// 使用 Memory (内存) 标签:
// 1. 拍摄堆快照
// 2. 执行操作
// 3. 再次拍摄堆快照
// 4. 比较两个快照

Performance API 监控

javascript
function monitorMemory() {
  if (performance.memory) {
    return {
      usedJSHeapSize: performance.memory.usedJSHeapSize,
      totalJSHeapSize: performance.memory.totalJSHeapSize,
      jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,

      getUsagePercentage() {
        return ((this.usedJSHeapSize / this.jsHeapSizeLimit) * 100).toFixed(2);
      },
    };
  }

  return null;
}

// 定期检查内存使用
function startMemoryMonitoring(interval = 5000) {
  return setInterval(() => {
    const memory = monitorMemory();
    if (memory) {
      console.log(`Memory usage: ${memory.getUsagePercentage()}%`);
      console.log(`Used: ${(memory.usedJSHeapSize / 1048576).toFixed(2)} MB`);

      // 设置警告阈值
      if (parseFloat(memory.getUsagePercentage()) > 80) {
        console.warn("High memory usage detected!");
      }
    }
  }, interval);
}

// 使用
const monitoringId = startMemoryMonitoring();

// 停止监控
// clearInterval(monitoringId);

防范内存泄漏的最佳实践

及时清理定时器和监听器

javascript
class Component {
  constructor() {
    this.timers = [];
    this.listeners = [];
  }

  addTimer(callback, interval) {
    const timerId = setInterval(callback, interval);
    this.timers.push(timerId);
    return timerId;
  }

  addEventListener(element, event, handler) {
    element.addEventListener(event, handler);
    this.listeners.push({ element, event, handler });
  }

  destroy() {
    // 清理所有定时器
    this.timers.forEach((timerId) => clearInterval(timerId));
    this.timers = [];

    // 清理所有事件监听器
    this.listeners.forEach(({ element, event, handler }) => {
      element.removeEventListener(event, handler);
    });
    this.listeners = [];
  }
}

// 使用
const component = new Component();

component.addTimer(() => {
  console.log("Timer tick");
}, 1000);

component.addEventListener(document, "click", () => {
  console.log("Document clicked");
});

// 不需要时清理
component.destroy();

使用 WeakMap 和 WeakSet

WeakMap 和 WeakSet 持有的是弱引用,不会阻止垃圾回收:

javascript
// 使用 Map(会阻止垃圾回收)
const regularCache = new Map();

function processUser(user) {
  if (!regularCache.has(user)) {
    // 计算某些昂贵的操作
    regularCache.set(user, computeExpensiveData(user));
  }

  return regularCache.get(user);
}

// 即使 user 对象不再使用,由于 Map 的强引用
// 它也无法被回收

// 使用 WeakMap(允许垃圾回收)
const weakCache = new WeakMap();

function processUserBetter(user) {
  if (!weakCache.has(user)) {
    weakCache.set(user, computeExpensiveData(user));
  }

  return weakCache.get(user);
}

// 当 user 对象不再被其他地方引用时
// WeakMap 的条目会自动被清理

function computeExpensiveData(user) {
  return { processed: true, data: user.name };
}

// 实际应用:跟踪 DOM 元素的元数据
const elementMetadata = new WeakMap();

function attachMetadata(element, data) {
  elementMetadata.set(element, data);
}

function getMetadata(element) {
  return elementMetadata.get(element);
}

// 使用
const button = document.createElement("button");
attachMetadata(button, { clicks: 0, created: Date.now() });

// 当 button 从 DOM 移除且没有其他引用时
// WeakMap 中的条目会自动清理

避免意外的全局变量

javascript
// 问题代码
function createHandler() {
  // 忘记使用 let/const/var,创建了全局变量
  largeData = new Array(1000000).fill("data");

  return function () {
    console.log(largeData[0]);
  };
}

const handler = createHandler();
// largeData 现在是全局变量,永远不会被回收

// 解决方案:使用严格模式
("use strict");

function createHandlerFixed() {
  // 在严格模式下会报错
  // largeData = new Array(1000000).fill('data');

  const largeData = new Array(1000000).fill("data");

  return function () {
    console.log(largeData[0]);
  };
}

合理管理缓存

javascript
// 带过期时间的缓存
class CacheWithExpiration {
  constructor(maxAge = 60000) {
    this.cache = new Map();
    this.maxAge = maxAge;
  }

  set(key, value) {
    this.cache.set(key, {
      value,
      timestamp: Date.now(),
    });
  }

  get(key) {
    const item = this.cache.get(key);

    if (!item) return undefined;

    // 检查是否过期
    if (Date.now() - item.timestamp > this.maxAge) {
      this.cache.delete(key);
      return undefined;
    }

    return item.value;
  }

  cleanup() {
    const now = Date.now();

    for (const [key, item] of this.cache.entries()) {
      if (now - item.timestamp > this.maxAge) {
        this.cache.delete(key);
      }
    }
  }

  clear() {
    this.cache.clear();
  }
}

// LRU 缓存实现
class LRUCache {
  constructor(maxSize = 100) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }

  get(key) {
    if (!this.cache.has(key)) return undefined;

    // 将访问的项移到最后(最近使用)
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);

    return value;
  }

  set(key, value) {
    // 如果键已存在,先删除
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }

    // 添加新项
    this.cache.set(key, value);

    // 如果超过最大大小,删除最老的项
    if (this.cache.size > this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
  }

  clear() {
    this.cache.clear();
  }
}

// 使用
const lruCache = new LRUCache(1000);

function getData(id) {
  // 先查缓存
  let data = lruCache.get(id);

  if (!data) {
    // 缓存未命中,获取数据
    data = fetchDataFromServer(id);
    lruCache.set(id, data);
  }

  return data;
}

function fetchDataFromServer(id) {
  return { id, data: "Some data" };
}

断开循环引用

javascript
// 问题代码
function createLinkedList() {
  const head = { value: 1 };
  const middle = { value: 2 };
  const tail = { value: 3 };

  head.next = middle;
  middle.next = tail;
  middle.prev = head;
  tail.prev = middle;

  // 创建循环
  tail.next = head;
  head.prev = tail;

  return head;
}

// 现代引擎处理得很好,但手动清理更安全
function destroyLinkedList(head) {
  let current = head;
  const visited = new Set();

  while (current && !visited.has(current)) {
    visited.add(current);
    const next = current.next;

    // 断开引用
    current.next = null;
    current.prev = null;

    current = next;
  }
}

// 使用
let list = createLinkedList();
// ... 使用链表 ...
destroyLinkedList(list);
list = null;

测试和验证

创建内存泄漏测试

javascript
// 内存泄漏测试工具
class MemoryLeakTest {
  constructor(name) {
    this.name = name;
    this.initialMemory = null;
    this.finalMemory = null;
  }

  async start() {
    // 先做一次垃圾回收(如果可用)
    if (global.gc) {
      global.gc();
    }

    await this.wait(100);
    this.initialMemory = this.getMemoryUsage();
    console.log(
      `[${this.name}] Initial memory: ${this.formatMemory(this.initialMemory)}`
    );
  }

  async end() {
    // 等待异步操作完成
    await this.wait(100);

    // 强制垃圾回收
    if (global.gc) {
      global.gc();
    }

    await this.wait(100);
    this.finalMemory = this.getMemoryUsage();

    const diff = this.finalMemory - this.initialMemory;
    console.log(
      `[${this.name}] Final memory: ${this.formatMemory(this.finalMemory)}`
    );
    console.log(`[${this.name}] Difference: ${this.formatMemory(diff)}`);

    return diff;
  }

  getMemoryUsage() {
    if (performance.memory) {
      return performance.memory.usedJSHeapSize;
    }

    if (process && process.memoryUsage) {
      return process.memoryUsage().heapUsed;
    }

    return 0;
  }

  formatMemory(bytes) {
    return `${(bytes / 1048576).toFixed(2)} MB`;
  }

  wait(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}

// 使用示例
async function testForMemoryLeaks() {
  const test = new MemoryLeakTest("Event Listener Test");

  await test.start();

  // 执行可能泄漏的操作
  for (let i = 0; i < 1000; i++) {
    const element = document.createElement("div");
    element.addEventListener("click", function () {
      console.log("Clicked");
    });
    // 注意:没有移除监听器
  }

  const leak = await test.end();

  if (leak > 1048576) {
    // 1 MB
    console.warn("Potential memory leak detected!");
  }
}

总结

内存泄漏是 JavaScript 开发中需要特别注意的问题,尤其在使用闭包时:

  1. 主要原因

    • 未清理的定时器和事件监听器
    • 意外保留大对象的引用
    • DOM 引用泄漏
    • 闭包中累积的数据
  2. 识别方法

    • 使用浏览器开发工具的内存分析
    • Performance API 监控
    • 定期拍摄堆快照并比较
  3. 防范措施

    • 及时清理定时器和监听器
    • 使用 WeakMap/WeakSet 处理对象映射
    • 避免意外的全局变量
    • 合理管理缓存大小
    • 断开不需要的引用
  4. 最佳实践

    • 总是提供清理/销毁方法
    • 使用严格模式防止意外全局变量
    • 限制数据结构的最大大小
    • 定期进行内存测试

理解并防范内存泄漏是编写高质量、高性能 JavaScript 应用的关键。通过遵循本文介绍的最佳实践,你可以充分利用闭包的强大功能,同时避免其可能带来的内存问题。