人人都会AI编程

29.3 定时器、事件监听的内存泄漏问题

更新时间:2026-07-10

在 React 组件中,如果你使用了 setIntervalsetTimeoutaddEventListener 等浏览器 API,但未在组件卸载时正确清理,就会导致内存泄漏——已卸载的组件仍占据内存,其回调函数仍可能尝试更新状态,进而引发 React 警告或未知错误。

这类问题在单页应用中尤其常见,因为页面不刷新,组件频繁挂载/卸载,未清理的定时器会不断累积。

问题本质

  • 定时器:即使组件已销毁,setInterval 依然周期性执行回调,回调中可能引用已卸载组件的状态或 Props,导致无法被垃圾回收。
  • 全局事件监听windowdocument 等全局对象上的事件监听器(如 scrollresizekeydown)会持有组件内部函数的引用,阻止组件内存释放。

典型案例

1. 未清理的 setInterval

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setSeconds(s => s + 1); // 每秒更新一次
    }, 1000);
    // 注意:这里没有返回清理函数!
  }, []);

  return <div>已过 {seconds} 秒</div>;
}

问题:当 Timer 组件卸载后,setInterval 仍在运行,且不断尝试调用 setSeconds,React 会在控制台警告“Can't perform a React state update on an unmounted component”。更严重的是,这个定时器永远无法被清除,成了真正的内存泄漏。

2. 未解绑的事件监听

function ScrollMonitor() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handleScroll = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handleScroll);
    // 没有在清理函数中 removeEventListener
  }, []);

  return <div>当前滚动位置:{scrollY}px</div>;
}

ScrollMonitor 组件被移除(如路由跳转)后,handleScroll 依然绑定在 window 上,它引用的 setScrollY 和组件闭包都不会释放。

正确做法:在 useEffect 清理函数中释放资源

React 的 useEffect 允许返回一个清理函数,它会在组件卸载前或下次 effect 执行前调用。这是处理任何需要手动释放的资源的标准位置。

修复 setInterval 泄露

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    return () => clearInterval(id); // 组件卸载时清除定时器
  }, []);

  return <div>已过 {seconds} 秒</div>;
}

修复事件监听泄露

function ScrollMonitor() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handleScroll = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handleScroll);

    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return <div>当前滚动位置:{scrollY}px</div>;
}

进阶场景与陷阱

1. 依赖变化的定时器

如果你的定时器依赖于某些变化的 Props 或 State,需要谨慎处理。可以使用 useRef 保存最新值,避免频繁重建定时器:

function DelayedAlert({ message }) {
  const messageRef = useRef(message);
  messageRef.current = message; // 始终指向最新 props

  useEffect(() => {
    const id = setTimeout(() => {
      alert(messageRef.current);
    }, 3000);
    return () => clearTimeout(id);
  }, []); // effect 只执行一次,但通过 ref 拿到最新 message
}

2. 在严格模式下的双重调用

React 18 的开发环境 Strict Mode 会故意两次调用 useEffect 来帮助发现清理问题。如果你的清理逻辑不完备,可能会导致定时器被意外清除或双倍注册。务必确保 effect 和清理函数是“对称的”:每次 effect 执行前,React 都会先调用上一次的清理函数。因此,上面的代码即便在 Strict Mode 下也工作良好,因为第二次调用时会先清除第一次创建的定时器。

3. 避免在 setInterval 中直接使用闭包变量

如果直接在 setInterval 回调里使用外部 state/props,可能会捕获旧值。推荐使用函数式更新(如 setSeconds(s => s + 1))或 useRef,来避免闭包陷阱。

实际开发中的自查清单

  • [ ] 每个 setTimeout/setInterval 是否有对应的 clearTimeout/clearInterval 在清理函数中?
  • [ ] 每个 addEventListener 是否有对应的 removeEventListener
  • [ ] 是否在 useEffect 依赖数组正确设置了依赖项(以便重新绑定/解绑)?
  • [ ] 是否使用了 useRef 来避免不必要的重绑定?
  • [ ] 如果是全局监听(如 websocket、observer),是否在组件卸载时断开连接?

总结

定时器和事件监听是 React 内存泄漏的重灾区。记住一条铁律:每个 useEffect 中申请的外部资源,都必须在返回的清理函数中释放。这不仅避免了内存泄漏,也防止了卸载后更新状态的报错。养成“谁订阅,谁取消”的习惯,会让你的 React 应用更健壮。