【js篇】JavaScript 内存泄漏

110 阅读4分钟

内存泄漏是 JavaScript 应用性能下降的“隐形杀手”。它不会立刻导致程序崩溃,但会悄无声息地吞噬内存,最终导致页面卡顿、崩溃。

即使 JavaScript 有自动垃圾回收机制,不当的代码仍会导致对象无法被回收。

本文将深入剖析 4 种最常见的内存泄漏场景,并提供实战解决方案


🚨 什么是内存泄漏?

内存泄漏:程序中已分配的内存,由于某些原因无法被释放,导致可用内存逐渐减少。

JavaScript 的垃圾回收器基于“可达性”判断对象是否可回收:

  • 如果一个对象无法从根对象(如 window)访问到,它就会被回收;
  • 反之,只要有任何引用链指向它,它就会一直存在。

内存泄漏的本质:创建了本应释放却无法释放的引用链


元凶 1:意外的全局变量(Accidental Global Variables)

💣 问题代码

function leakyFunction() {
  // 忘记使用 var/let/const —— 意外创建全局变量
  leakyVariable = "I'm a global leak!";
  
  // 在 strict mode 下会报错,但非严格模式下会挂到 window 上
}

leakyFunction();
console.log(window.leakyVariable); // "I'm a global leak!" —— 永不回收

📉 影响

  • 变量成为 window 的属性;
  • 生命周期与页面相同;
  • 多次调用函数会覆盖或累积泄漏。

✅ 解决方案

  1. 使用严格模式('use strict')

    function safeFunction() {
      'use strict';
      // leakyVariable = "boom!"; // TypeError: not defined
    }
    
  2. 始终声明变量

    function goodFunction() {
      const localVar = "I'm safe!";
    }
    
  3. 使用 ESLint 检测

    {
      "rules": {
        "no-implicit-globals": "error"
      }
    }
    

元凶 2:被遗忘的计时器或回调(Forgotten Timers & Callbacks)

💣 问题代码

// 场景 1:setInterval 未清理
let intervalId = setInterval(() => {
  const hugeData = fetchData(); // 每次都创建新对象
  process(hugeData);
}, 1000);

// 忘记 clearInterval(intervalId) → 回调函数一直存在 → hugeData 累积
// 场景 2:事件监听未移除
const button = document.getElementById('start');
button.addEventListener('click', () => {
  // 启动一个长期任务
  const data = new Array(1000000).fill('task-data');
  // 忘记移除监听器 → data 一直被引用
});
// 即使 button 被移除,监听器仍存在(如果没移除)

📉 影响

  • 定时器/回调持续执行;
  • 闭包中的变量无法释放;
  • 内存持续增长。

✅ 解决方案

  1. 及时清理定时器

    let intervalId = null;
    function startTimer() {
      intervalId = setInterval(() => { /* ... */ }, 1000);
    }
    function stopTimer() {
      if (intervalId) {
        clearInterval(intervalId);
        intervalId = null;
      }
    }
    
  2. 移除事件监听器

    const handler = () => { /* ... */ };
    button.addEventListener('click', handler);
    // 不再需要时
    button.removeEventListener('click', handler);
    
  3. 使用 AbortController(现代方案)

    const controller = new AbortController();
    button.addEventListener('click', handler, { signal: controller.signal });
    // 取消所有监听
    controller.abort();
    

元凶 3:脱离 DOM 的引用(Orphaned DOM References)

💣 问题代码

// 缓存 DOM 元素
const element = document.getElementById('myDiv');
const dataCache = new Map();
dataCache.set(element, { metadata: 'important' });

// 后来,DOM 被移除
document.body.removeChild(element); // #myDiv 从页面消失

// 但 JavaScript 中仍持有对 element 的引用
// → element 无法被回收,其所有数据(包括 metadata)也一起泄漏

📉 影响

  • 即使 DOM 已从页面移除,JavaScript 仍持有引用;
  • 尤其在大型单页应用(SPA)中常见;
  • 可能导致整个 DOM 树无法释放。

✅ 解决方案

  1. 使用完及时清理引用

    dataCache.delete(element);
    element = null;
    
  2. 使用 WeakMap(推荐)

    const dataCache = new WeakMap();
    dataCache.set(element, { metadata: 'important' });
    
    // 当 element 被 DOM 移除且无其他引用时
    // WeakMap 会自动将其键值对删除 → 自动回收
    
  3. 避免全局 DOM 缓存


元凶 4:闭包滥用(Closure Abuse)

💣 问题代码

function outerFunction() {
  const hugeArray = new Array(1000000).fill('*');
  const secret = 'sensitive-data';
  
  return function() {
    // 仅需访问 small part
    console.log('Processing...');
    // 但整个 outerFunction 的作用域都被保留
    // hugeArray 和 secret 都无法被回收
  };
}

const inner = outerFunction();
// outerFunction 执行结束
// 但由于闭包,hugeArray 仍在内存中

📉 影响

  • 闭包会保留其词法环境中的所有变量;
  • 即使只使用其中一小部分,整个作用域链都不会被释放;
  • 在频繁调用的函数中尤其危险。

✅ 解决方案

  1. 避免在闭包中保留大对象

    function outerFunction() {
      const smallData = 'needed';
      return function() {
        console.log(smallData); // 只引用必要的数据
      };
    }
    
  2. 使用后主动释放

    function outerFunction() {
      let hugeData = fetchBigData();
      return function() {
        // 使用 hugeData
        // 使用后清理
        hugeData = null;
      };
    }
    
  3. 重构逻辑,减少闭包依赖


🔍 如何检测内存泄漏?

✅ 使用 Chrome DevTools

  1. 打开 Developer Tools → Memory
  2. 使用 Heap Snapshot
    • 拍摄多个时间点的内存快照;
    • 对比找出未释放的对象。
  3. 使用 Record Allocation Timeline
    • 实时监控内存分配;
    • 查看哪些对象在持续增长。

✅ 代码审查清单

  • 是否有未声明的变量?
  • 所有 setInterval 是否都有对应的 clearInterval
  • 事件监听器是否在组件销毁时被移除?
  • 是否使用 WeakMap 缓存 DOM 元素?
  • 闭包中是否引用了不必要的大对象?

💡 结语

“内存泄漏不是‘会不会发生’的问题,而是‘何时被发现’的问题。”

这 4 大元凶:

  1. 意外的全局变量
  2. 被遗忘的计时器
  3. 脱离 DOM 的引用
  4. 闭包滥用

是前端开发中最常见的陷阱。

最佳防御策略

  • 使用严格模式;
  • 及时清理资源;
  • 善用 WeakMap/WeakSet
  • 定期进行内存分析。

掌握这些知识,你就能构建更稳定、更高效的 Web 应用。