React 内存泄漏诊断:在组件卸载后利用清理函数移除未闭合的闭包监听器实践

欢迎来到“React 代码病房”的第一堂课。我是你们的住院医师,今天我们要讨论的不是癌症,而是比癌症更令人头疼、更隐蔽、更让人在深夜里抱着电脑崩溃的东西——内存泄漏

很多人以为 React 的垃圾回收机制是万能的。天真!太天真了。React 的垃圾回收就像是收废品的大爷,他只负责捡你扔掉的东西。但如果你把一个定时器、一个事件监听器或者一个 WebSocket 连接留在了房间里,那个大爷是不会进去关灯的。它们会像僵尸一样,在你组件已经“死”了之后,依然在后台疯狂地消耗着你的 CPU 和内存。

今天,我们要学的是如何给这些僵尸“下葬”。

第一部分:闭包的幽灵

首先,我们要理解一个核心概念:闭包

在 React 中,闭包无处不在。当你定义一个函数并在 useEffect 里使用它,或者把一个函数作为 props 传给子组件时,你就创造了一个闭包。

想象一下,你的组件是一个公寓楼(Class Component 或者就是一个函数组件实例)。你住在这个公寓里,你有一些私人的家具(state),你有一个窗户(DOM)。

// 例子:一个简单的计数器
const MyComponent = () => {
  const [count, setCount] = React.useState(0);

  const handleClick = () => {
    console.log(`你点击了 ${count} 次`); // 这里就是一个闭包
    setCount(count + 1);
  };

  return <button onClick={handleClick}>点击我 ({count})</button>;
};

当你点击按钮时,handleClick 函数被调用了。它“记得”了当时的 count 是多少。这很好,很方便。

但是,如果这个组件被卸载了,handleClick 这个函数还在吗?

如果它还在,并且它被挂载到了某个地方(比如 window 上的某个事件监听器),那么这个函数就会带着它对旧 count 的引用,像一个幽灵一样飘在内存里。

最可怕的是什么?最可怕的是,这个幽灵函数可能还会试图去调用 setCount

第二部分:经典案例——Window 事件监听器

这是新手最容易犯的错误,也是导致“僵尸组件”最多的源头。

错误示范:忘了关灯

让我们看一个典型的错误代码。这代码看起来完美无缺,对吧?

useEffect(() => {
  const handleResize = () => {
    // 假设我们要根据窗口大小更新状态
    console.log("窗口大小变了");
    setWidth(window.innerWidth);
  };

  // 1. 注册监听器
  window.addEventListener('resize', handleResize);

}, []); // 空依赖数组,意味着这个 effect 只在组件挂载时运行一次

// 2. 组件卸载...

发生了什么?
组件挂载了,监听器被加上了。用户调整了浏览器窗口大小。handleResize 被触发,调用了 setWidth。一切正常。

然后,用户离开了这个页面(组件卸载了)。

但是! window.removeEventListener('resize', handleResize) 这行代码在哪里?它不存在!

现在,handleResize 这个闭包函数依然紧紧抓着 window 对象不放。哪怕你的组件已经被 React 从 DOM 树里拔出来了,被从内存里回收了,handleResize 依然在监听整个浏览器窗口的每一次 resize 事件。

每次 resize,它就会尝试执行 setWidth。而 MyComponent 已经不在了,setWidth 试图访问一个不存在的组件实例。React 会抛出一个警告:“Can’t perform a React state update on an unmounted component.”

后果:

  1. 控制台报错:满屏的红色警告,看着心烦意乱。
  2. 内存泄漏:那个闭包函数 handleResize 一直占着内存,因为它引用了 window
  3. 性能下降:虽然只是个 resize,但如果你的 handleResize 里做了复杂的计算,或者触发了多次 setState,这会拖慢整个浏览器的渲染。

正确示范:必须关灯

React 为我们提供了一个“退房协议”,那就是 useEffect 的返回函数。

useEffect(() => {
  const handleResize = () => {
    console.log("窗口大小变了");
    setWidth(window.innerWidth);
  };

  // 1. 注册监听器
  window.addEventListener('resize', handleResize);

  // 2. 返回一个清理函数(退房协议)
  return () => {
    console.log("组件要走了,我要关掉监听器了");
    window.removeEventListener('resize', handleResize);
  };
}, []); 

原理:
React 会在组件卸载之前,或者在依赖项变化导致 effect 重新运行之前,调用这个返回的函数。

所以,当组件卸载时,你的清理函数会执行,把灯关掉。监听器被移除,闭包函数被回收。世界清静了。

第三部分:定时器——最顽固的僵尸

如果说事件监听器是鬼,那定时器就是那种怎么赶都赶不走的流氓。

场景:一个倒计时

useEffect(() => {
  let timerId = null;

  const startTimer = () => {
    console.log("开始倒计时");
    timerId = setInterval(() => {
      console.log("Tick...");
      // 假设我们在更新一些状态
    }, 1000);
  };

  startTimer();

  return () => {
    console.log("清理定时器");
    if (timerId) {
      clearInterval(timerId);
      timerId = null;
    }
  };
}, []);

这里有一个细节,很多老手都会犯迷糊:一定要把 timerId 保存到变量里

如果你在 setInterval 里面又调用了 startTimer,或者在清理函数里重新定义了 timerId,清理函数可能就找不到那个定时器了。

记住:闭包会记住变量,但变量名变了,闭包找不到旧变量了。

第四部分:进阶陷阱——未闭合的闭包

这是本文的精华。有时候,你写了清理函数,你以为你赢了,但闭包依然在作祟。

案例:异步数据获取

假设我们要获取用户数据。

const UserProfile = ({ userId }) => {
  const [user, setUser] = React.useState(null);
  const [loading, setLoading] = React.useState(true);

  useEffect(() => {
    let isMounted = true; // 一个标志位,用来判断组件是否还活着

    const fetchUser = async () => {
      try {
        const response = await fetch(`/api/user/${userId}`);
        const data = await response.json();

        // 关键点在这里!
        if (isMounted) {
          setUser(data);
          setLoading(false);
        }
      } catch (error) {
        console.error(error);
      }
    };

    fetchUser();

    return () => {
      // 组件卸载时清理
      isMounted = false; 
    };
  }, [userId]);

  if (loading) return <div>加载中...</div>;
  return <div>用户名: {user.name}</div>;
};

这看起来很完美,对吧?isMounted 标志位防止了在组件卸载后更新状态。

但是,如果我们不使用 isMounted,或者逻辑稍微复杂一点,就会出问题。

问题场景:

const BadComponent = () => {
  const [count, setCount] = React.useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      // 这里的闭包捕获了初始的 count = 0
      setCount(prev => prev + 1); 
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  return <div>Count: {count}</div>;
};

等等,这看起来是对的啊!setInterval 每秒加 1,组件卸载时清除它。这没问题。

让我们加个更棘手的。闭包陷阱

假设我们想在 useEffect 里访问最新的 count,但我们写错了。

const TrickyComponent = () => {
  const [count, setCount] = React.useState(0);

  useEffect(() => {
    // 危险!这个函数捕获了 count,但不是最新的!
    const increment = () => {
      console.log("闭包里的 count:", count); // 永远是 0
      setCount(count + 1); // 试图把 0 变成 1
    };

    // 每次渲染都重新创建这个函数,但闭包还是旧的 count
    const timerId = setInterval(increment, 1000);

    return () => clearInterval(timerId);
  }, []); // 依赖数组是空的

  return <div>Count: {count}</div>;
};

诊断:
组件挂载,count 是 0。increment 函数被创建,它闭包捕获了 count=0
1秒后,increment 运行,打印 0,尝试 setCount(1)
2秒后,increment 运行,打印 0,尝试 setCount(1)
虽然 UI 显示的 count 会变成 1(因为 React 会合并状态更新),但闭包里的变量永远是 0。这看起来像是 bug,但实际上并没有内存泄漏。

真正的内存泄漏场景:

const GhostComponent = () => {
  const [data, setData] = React.useState(null);

  useEffect(() => {
    let isCancelled = false;

    const loadData = async () => {
      const result = await fetch('/api/data');
      const json = await result.json();

      // 如果组件卸载了,千万不要更新状态!
      if (isCancelled) return; 

      setData(json);
    };

    loadData();

    return () => {
      isCancelled = true; // 标记为已取消
    };
  }, []);

  return <div>Data: {JSON.stringify(data)}</div>;
};

如果我们在 loadData 里没有检查 isCancelled,当组件卸载时,setData 依然会被调用。虽然 React 会在底层处理“unmounted”警告,阻止状态更新,但那个 json 对象、那个 fetch 请求的结果,依然在内存里被保留着,直到垃圾回收器(GC)最终把它们捡走。

更糟糕的是,如果你在 setData 后面还有副作用(比如保存到 localStorage),那些副作用也会执行,导致逻辑错误。

第五部分:第三方库的“绑架”

很多时候,内存泄漏不是你写的,是你用的。

案例:图表库

假设你用了一个很流行的图表库,比如 Chart.js

const ChartView = () => {
  const canvasRef = React.useRef(null);

  useEffect(() => {
    if (canvasRef.current) {
      const ctx = canvasRef.current.getContext('2d');
      const myChart = new Chart(ctx, {
        type: 'line',
        data: { /* ... */ }
      });

      // 错误!忘记销毁图表实例
      // myChart.destroy(); 
    }
  }, []);

  return <canvas ref={canvasRef} />;
};

myChart 这个实例对象被创建出来了。它不仅包含数据,还可能包含对 DOM 节点、动画循环、事件监听器的引用。当你切换页面或卸载组件时,myChart 依然存在。如果你频繁切换这个组件,内存就会像气球一样膨胀起来。

正确做法:

const ChartView = () => {
  const canvasRef = React.useRef(null);
  const chartInstanceRef = React.useRef(null);

  useEffect(() => {
    if (canvasRef.current) {
      const ctx = canvasRef.current.getContext('2d');
      chartInstanceRef.current = new Chart(ctx, { /* ... */ });
    }

    return () => {
      // 销毁图表,释放资源
      if (chartInstanceRef.current) {
        chartInstanceRef.current.destroy();
        chartInstanceRef.current = null;
      }
    };
  }, []);

  return <canvas ref={canvasRef} />;
};

第六部分:如何像侦探一样诊断内存泄漏

光说不练假把式。既然我们讲了这么多“幽灵”,怎么把它们抓出来呢?我们需要工具。

工具一:Chrome DevTools

  1. 打开 Chrome,进入你的 React 应用。
  2. 按 F12 打开开发者工具。
  3. 点击 Performance 标签,或者 Memory 标签。

“Heap Snapshot” (堆快照) 方法:

这是最常用的方法。

  1. 点击 Memory 面板左上角的 Take Heap Snapshot
  2. 你会得到一个快照 1。
  3. 在应用里做一些操作,比如进入一个页面,然后退出(触发卸载)。
  4. 等待几秒钟,让垃圾回收器(GC)跑一下。
  5. 再次点击 Take Heap Snapshot,得到快照 2。
  6. 对比快照 1 和快照 2。

寻找线索:
在快照 2 的筛选器里,选择 Summary
往下拉,找到 Detached DOM nodes(断开的 DOM 节点)或者 Anonymous functions(匿名函数)。

如果有一个节点数量在增加,那就是内存泄漏了!
点击那个节点,看它的 __proto__,找到它的构造函数。如果是 Anonymous function,这就说明有一个闭包函数没有被释放。

“Allocation sampling” (内存采样) 方法:

这个方法更适合动态监控。

  1. 点击 Start allocation sampling
  2. 进行一段操作(比如点击按钮、滚动页面)。
  3. 点击 Stop
  4. 你会看到一张图表,显示了内存随时间的变化。
  5. 如果图表是一条直线向上走,那就是内存泄漏。

第七部分:终极心法——防御性编程

作为一名资深专家,我总结了一些在 React 中避免内存泄漏的“生存法则”。

1. 始终在 useEffect 中返回清理函数

这是铁律。不管你的 effect 是干什么的(fetch、timer、event、subscribes),只要你注册了什么东西,你就必须在清理函数里注销它。

// 基础模板
useEffect(() => {
  // 1. Setup (注册/启动)

  return () => {
    // 2. Cleanup (注销/停止)
  };
}, [dependencies]);

2. 不要在闭包中保存可变状态

如果你需要在清理函数里访问某个状态,或者需要确保每次渲染都使用最新的状态,使用 useRef

const Component = () => {
  const [count, setCount] = React.useState(0);

  // 使用 ref 来存储最新的值,闭包会捕获这个 ref 的当前值
  const countRef = React.useRef(count);

  React.useEffect(() => {
    countRef.current = count; // 每次渲染都更新 ref
  }, [count]);

  React.useEffect(() => {
    const interval = setInterval(() => {
      // 这里每次都会读取到最新的 count
      console.log(countRef.current);
    }, 1000);

    return () => clearInterval(interval);
  }, []); // 依赖数组是空的,但因为我们用了 ref,所以不需要把 count 放进去
};

3. AbortController —— Fetch 的救星

对于网络请求,现代浏览器提供了一个非常棒的工具 AbortController。它专门用来取消请求。

const FetchComponent = () => {
  const [data, setData] = React.useState(null);
  const abortControllerRef = React.useRef(null);

  useEffect(() => {
    abortControllerRef.current = new AbortController();

    const fetchData = async () => {
      try {
        const response = await fetch('/api/data', {
          signal: abortControllerRef.current.signal
        });
        const json = await response.json();
        setData(json);
      } catch (error) {
        // 如果是被 AbortController 取消的请求,这是正常现象,不要报错
        if (error.name !== 'AbortError') {
          console.error(error);
        }
      }
    };

    fetchData();

    // 清理函数
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);

  return <div>{JSON.stringify(data)}</div>;
};

第八部分:总结(不,我们不说总结)

我们聊了很多。从幽灵般的闭包,到顽固的定时器,再到第三方库的陷阱。

记住,React 的 useEffect 就像是一个守夜人。当你进入房间(组件挂载),你点亮蜡烛(注册监听器、启动定时器)。当你离开房间(组件卸载)时,你必须把蜡烛吹灭。

如果你忘了吹灭蜡烛,黑暗(内存泄漏)就会吞噬一切。

不要害怕闭包,闭包是 JavaScript 的核心特性。不要害怕清理函数,它是 React 带给你的安全网。

下一次,当你点击“卸载”按钮时,闭上眼睛,深呼吸。想象你的组件正在走向一个宁静的坟墓,而那个坟墓里,没有未闭合的函数,没有残留的定时器,只有永恒的、干净的、高效的内存。

好了,今天的讲座到此结束。现在,去检查你的代码吧,把那些幽灵赶出去!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注