useEffect cleanup 在组件卸载时执行

FreeGuideOnline 最新 2026-07-04

理解 useEffect 的清理机制:组件卸载时自动执行

在 React 中,useEffect 是处理副作用的核心 Hook。很多时候,我们的副作用需要在组件从 DOM 中移除时进行“清理”,比如取消网络请求、清除定时器或移除事件监听器。这就是 useEffect 清理函数(cleanup) 的作用。

当依赖项变化导致组件重新渲染时,或当组件卸载时,React 会执行上一次 effect 返回的清理函数。本文将聚焦于组件卸载这一关键场景,帮助你彻底掌握 cleanup 的执行时机与最佳实践。

1. 什么是 useEffect 的清理函数?

清理函数是你在 useEffect 回调函数内部 return 的一个函数。它并不是立即执行,而是由 React 在合适的时机调用:

  • 当组件卸载
  • 在下一次 effect 执行之前(如果依赖项变化导致 effect 重新运行)
useEffect(() => {
  // 副作用逻辑:例如订阅事件
  const handleResize = () => { /* ... */ };
  window.addEventListener('resize', handleResize);

  // 返回清理函数
  return () => {
    window.removeEventListener('resize', handleResize);
    console.log('清理:移除 resize 监听器');
  };
}, []); // 空依赖数组 => 只在挂载时运行 effect,卸载时执行清理

2. 组件卸载时清理的执行流程

当 React 决定将一个组件从 UI 中移除时(条件渲染、路由切换等原因),它会执行以下与副作用相关的步骤:

  1. 调用组件上一次渲染中 useEffect 返回的清理函数
  2. 组件对应的 DOM 节点被移除。

重要提示:清理函数会在组件卸载时保持闭包特性,它可以安全地访问该 effect 定义时的 propsstate

示例:定时器清理

import { useState, useEffect } from 'react';

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);

    // 返回清理函数,用于组件卸载时清除定时器
    return () => {
      clearInterval(intervalId);
      console.log('清除定时器,count 的最终值为:', count);
    };
  }, []); // 空依赖,组件卸载时才清理

  return <p>计数{count}</p>;
}

// 在父组件中条件渲染 Timer:
// {showTimer && <Timer />}
// 当 showTimer 变为 false 时,Timer 卸载,清理函数执行

如果不在卸载时清除定时器,即使组件消失,setInterval 仍会继续运行,不仅可能造成内存泄漏,还会尝试更新已卸载组件的状态(React 开发模式下会有警告)。

3. 清理函数与依赖项的关系

许多初学者会误解:是否只有空依赖数组 [] 才能保证清理在卸载时执行?不是的。 无论依赖数组是什么,只要组件卸载,该 effect 最后一次运行的清理函数必定被执行

但依赖项会影响清理的版本(闭包中的值):

  • 如果依赖数组包含某些变量,每次这些变量变化时,会先运行上一个 effect 的清理函数,然后运行新的 effect。
  • 当组件最终卸载时,它会运行当次渲染对应的那个 effect 所返回的清理函数。
useEffect(() => {
  const id = setTimeout(() => {
    console.log('延迟操作,当前的 userId:', userId);
  }, 1000);

  return () => {
    clearTimeout(id);
    console.log('清理 timeout,此时的 userId:', userId);
  };
}, [userId]); // userId 变化时,先清理前一个 timeout,再设置新的

在上面的代码中,组件卸载时会执行 clearTimeout,并打印当时闭包中的 userId。即使 userId 早已改变,卸载时执行的清理函数依旧是最后一次 effect 的闭包版本

4. 常见需要清理的副作用场景

场景 需要清理的内容
订阅数据源 取消订阅 (如 WebSocket, EventSource)
定时器 clearTimeout / clearInterval
事件监听器 removeEventListener
手动修改 DOM 还原 DOM 状态(如移除插入的节点)
全局变量/第三方库 还原全局设置,销毁实例

实例:WebSocket 连接清理

useEffect(() => {
  const socket = new WebSocket('wss://example.com/chat');

  socket.onmessage = (event) => {
    // 处理消息
  };

  return () => {
    socket.close();
    console.log('WebSocket 连接已关闭');
  };
}, []);

5. 常见陷阱与注意事项

❌ 忘记返回清理函数

useEffect(() => {
  const interval = setInterval(() => { ... }, 1000);
  // 没有 return 清理函数 → 组件卸载后定时器仍在运行
}, []);

❌ 在清理函数中引用过时的状态

useEffect(() => {
  const handleClick = () => {
    console.log('点击时 count:', count);
  };
  document.addEventListener('click', handleClick);
  return () => {
    // 清理时移除的是同一个 handleClick 引用,不用担心
    document.removeEventListener('click', handleClick);
  };
}, []); // 空依赖导致 count 永远输出初始值

上方代码中,由于依赖数组为空,handleClick 闭包中的 count 会永远引用初始值。如果你需要最新的 count,要么将 count 加入依赖数组,要么使用 useRef 或函数式更新。但清理函数本身的引用是稳定的,能够正确移除监听器。

✅ 使用函数式更新避免依赖过时状态

useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1); // 不依赖外部 count
  }, 1000);
  return () => clearInterval(interval);
}, []); // 空依赖安全

6. 开发环境下的双重调用与清理

在 React 18 的严格模式<StrictMode>)中,开发环境下组件会被故意挂载→卸载→再次挂载,以帮助提前暴露清理问题。因此你可能会观察到 effect 运行两次,清理函数执行一次。这属于正常现象,只要你的清理逻辑正确,应用在生产环境中不会有问题。

总结

  • useEffect清理函数在组件卸载时一定会执行,用于释放资源。
  • 清理函数在组件卸载时拥有对应 effect 闭包内的变量快照,设计时需要留意依赖项。
  • 无论依赖数组是否为空,只要组件卸载,最后一次 effect 的清理函数就会被调用。
  • 保证每个产生持久化副作用的 effect 都返回对应的清理函数,是避免内存泄漏的关键习惯。

掌握清理函数的规律,你就能写出稳定、可维护的 React 组件。