人人都会AI编程

useEffect:副作用收集、调度与清理机制

更新时间:2026-07-11

useEffect 是 React 中处理副作用的核心 Hook。理解它的内部机制,能帮助你避免常见的闭包陷阱、无限循环和内存泄漏问题。

副作用是什么

在 React 的渲染过程中,组件函数应该是一个“纯函数”——给定相同的 state 和 props,必须返回相同的 UI 描述。任何不能放在渲染阶段执行的操作都属于副作用,例如:

  • 发起网络请求
  • 操作 DOM(如设置 document.title)
  • 订阅事件或数据源
  • 设置定时器

useEffect 提供了一种声明式的方式来描述这些副作用,让 React 在正确的时机执行它们。

副作用的收集:链表结构

React 在组件函数执行时,并不立即执行副作用,而是将副作用回调收集起来,挂载在 Fiber 节点的 updateQueue 上。这个过程大致如下:

  1. 当组件函数执行时,遇到 useEffect(callback, deps)
  2. React 创建一个 Effect 对象,包含 callbackdepsdestroy(清理函数)。
  3. 将这个 Effect 对象链接到当前 Fiber 节点的 updateQueue 末尾,形成一个单向链表

所有 useEffect 都按调用顺序被依次添加到链表中,这也就是为什么 Hooks 必须按顺序调用且不能放在条件语句中——React 完全依靠调用顺序来区分不同的 Effect。

依赖对比:决定是否重新执行

在每次渲染完成后,React 会遍历该 Fiber 节点的 Effect 链表,检查每个 Effect 的依赖项是否发生变化。对比逻辑非常简单:

// 简化的依赖对比
function areDepsEqual(prevDeps, nextDeps) {
  if (prevDeps === null) return false; // 没有提供依赖项,每次都执行
  for (let i = 0; i < prevDeps.length; i++) {
    if (Object.is(prevDeps[i], nextDeps[i])) continue;
    return false;
  }
  return true;
}
  • 如果依赖项数组为 undefined(即不传第二个参数),则每次渲染后都会执行 Effect。
  • 如果依赖项数组为空 [],则只在首次挂载后执行一次。
  • 如果依赖项数组中的值与上一次不同,则标记该 Effect 需要重新执行。

调度的时机:Commit 阶段之后

React 的渲染分为两个阶段:Render 阶段(可中断)和 Commit 阶段(同步不可中断)。useEffect 的副作用回调在 Commit 阶段完成后、浏览器绘制之前异步调用

具体时机是:React 将 DOM 更新应用到屏幕上之后,再异步调度所有需要执行的 Effect 回调。这样设计的好处是:

  • 确保 Effect 中读取的 DOM 是最新的。
  • 避免阻塞浏览器绘制,保证用户交互的流畅性。

相比之下,useLayoutEffect 的回调在 DOM 更新后、浏览器绘制前同步执行,适用于需要同步读取 DOM 布局信息的场景。

清理机制:上一次 Effect 的销毁

如果 Effect 返回一个函数,React 会将其存储为“清理函数”。下一次 Effect 需要重新执行时,会先调用上一次的清理函数,再执行新的 Effect 回调。组件卸载时也会调用清理函数。

这种机制用于解除上一次的副作用绑定,防止内存泄漏:

useEffect(() => {
  const subscription = dataSource.subscribe();
  
  return () => {
    subscription.unsubscribe(); // 清理函数
  };
}, [dataSource]);

执行流程如下:

  1. 首次渲染后:执行 Effect 回调,保存返回的清理函数。
  2. 依赖变化触发重新渲染:
  • 先调用上一次的清理函数。
  • 再执行新的 Effect 回调,保存新的清理函数。
  1. 组件卸载:调用最后一次的清理函数。

深入理解:与闭包的关联

useEffect 的回调函数形成了一个闭包,它捕获的是当次渲染时的 state 和 props 值。这是闭包陷阱的根源:

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

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 永远是 0!
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖
}

因为空依赖意味着 Effect 只在首次渲染后执行一次,回调中的 count 永远是初始值 0。要解决这个问题,需要正确声明依赖,或者使用函数式更新 setCount(c => c + 1)

常见误区与最佳实践

  • 不要遗漏依赖项:如果你在 Effect 中使用了组件内部的变量,请将它加入依赖数组。React 官方推荐使用 eslint-plugin-react-hooksexhaustive-deps 规则自动检查。
  • 避免在 Effect 中做无谓的工作:如果某个值的变化并不需要触发 Effect,可以将它移出依赖,或使用 useRef 存储不需要触发重渲染的变量。
  • 清理函数务必完整:设置定时器要返回一个清除定时器的函数;订阅事件要返回取消订阅的函数。任何未清理的副作用都可能导致内存泄漏或意外行为。

理解了 useEffect 的收集、调度和清理机制,你就能精准控制副作用的生命周期,写出健壮且可预测的 React 组件。