useState 与 useEffect 深度掌握

第五编 · 第四章:useState 与 useEffect 深度掌握的深入分析


从一个问题开始

为什么 useEffect 的依赖数组里少了某个值,会导致 bug?

很多人能背出这个规则,但说不清为什么。理解 Why 比背 What 重要得多,因为它能帮你判断在哪些场景下依赖数组的设计是有意义的。

要理解 useEffect 的依赖数组,必须先理解 useState 是怎么工作的。


useState 的基本机制

useState 的基本用法是这样的:

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

调用 useState(0) 返回一个数组,第一个元素是当前状态值,第二个元素是更新函数。useState 的参数是状态的初始值。

但这个描述只是表面。真正的问题是:React 怎么在多次渲染之间保持状态的?

答案是:每个组件实例都有自己的状态队列

当组件第一次渲染时,useState 会初始化状态,并返回一个初始值。同时,React 会在内部维护一个"待处理状态"的队列。每次后续渲染,useState 不会重新初始化,而是从队列里取出"下一个状态值"返回。

这意味着,同一个组件的多次渲染之间,状态是连续的。你可以把 useState 看成是一个"在渲染之间保持状态的机制",而不是"每次渲染重新创建的变量"。


函数式更新

setCount 有两种调用方式:

setCount(5);              // 直接赋值
setCount(prev => prev + 1); // 函数式更新

第二种方式看起来有点奇怪,为什么不直接用 setCount(count + 1)

因为状态更新是异步的。React 在事件处理函数里的 setState 会批量处理,以提高性能。这意味着在同一个事件处理函数里连续调用 setCount(count + 1) 三次,count 的值可能不会被更新三次。

看一个具体例子:

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

  function handleClick() {
    setCount(count + 1);  // count 此时是 0
    setCount(count + 1);  // count 此时还是 0,不是 1
    setCount(count + 1);  // count 此时还是 0
  }

  return <button onClick={handleClick}>{count}</button>;
}

点击一次按钮,count 只增加了 1,而不是 3。因为三次 setCount(count + 1) 中的 count 都是闭包捕获的首次渲染时的值。

函数式更新解决了这个问题:

function handleClick() {
  setCount(prev => prev + 1); // prev 是最新的 pending 状态
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
}

每次 setCount 调用都会基于最新的状态值计算,结果是 count 增加了 3。


useEffect 的执行时机

useEffect 的基本用法是这样的:

useEffect(() => {
  // 副作用操作
  document.title = `Count is ${count}`;

  return () => {
    // 清理操作
  };
}, [count]);

useEffect 的第二个参数是依赖数组。React 会比较依赖数组里的值是否变化——如果没变化,Effect 不会重新运行;如果变化了,先运行清理函数(如果有),再运行新的 Effect。

这和类组件生命周期的逻辑完全不同。类组件的生命周期是按"时机"组织的:componentDidMount 在组件挂载后运行一次,componentDidUpdate 在每次更新后运行。useEffect 是按"依赖"组织的:依赖变化了才运行,没变化就不运行。


为什么依赖数组很重要

考虑一个常见 bug:

useEffect(() => {
  fetchUser(userId).then(setUser);
}, []); // 漏掉了 userId

这里 userId 从 props 里来,但没有被放进依赖数组。这意味着 Effect 只在组件首次渲染时运行一次,之后 userId 变了也不会重新获取数据。

这导致一个很隐蔽的 bug:切换用户后,页面显示的还是旧用户的数据,因为 Effect 没有重新运行。

正确写法是:

useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]); // userId 变化就重新运行

依赖数组和闭包的关系

理解了闭包,才能真正理解依赖数组为什么会出错。

JavaScript 的闭包是指:一个函数能访问它定义时所在的词法作用域里的变量。当你在 useEffect 的回调里引用 count 时,你引用的是定义这个回调时的那个 count,而不是"当前渲染时的 count 值"。

如果 useEffect 的依赖数组里没有 count

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 这个 count 永远是首次渲染时的值
  }, 1000);
  return () => clearInterval(timer);
}, []); // 依赖数组为空,只运行一次

这个 setInterval 的回调形成了一个闭包,捕获了首次渲染时的 count。无论后续 count 怎么变化,这个回调里的 count 永远是 0。

count 放进依赖数组能解决这个问题:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 每次 count 变化,这个 Effect 重新运行
  }, 1000);
  return () => clearInterval(timer);
}, [count]);

但这样做有个问题:每次 count 变化都会创建一个新的 setInterval,旧的被清理。这在某些场景下是合理的,但在其他场景下可能导致不必要的重置。

更好的方式是使用函数式更新来处理这种场景,或者把变化频率高的值和不变化的值分开管理。


useEffect 的清理函数

useEffect 可以返回一个函数作为清理函数:

useEffect(() => {
  const subscription = subscribe(handleChange);
  return () => subscription.unsubscribe();
}, [handleChange]);

清理函数在两种情况下运行:

第一种:Effect 重新运行之前。因为依赖变化了,旧的 Effect 需要清理,然后新的 Effect 才能运行。

第二种:组件卸载时。如果组件从 DOM 上移除了,React 会运行最后的清理函数,防止在已卸载组件上继续执行副作用。

React 18 的开发模式下,组件会"假装"卸载再重新挂载一次,来帮开发者暴露缺少清理函数的 Effect。所以如果你看到 Effect 运行了两次,不一定是 bug——可能是 Strict Mode 在帮你发现问题。


async/await 在 useEffect 里的坑

useEffect 的回调不能是 async 函数。这不是因为 React 不支持,而是因为 async 函数会隐式返回一个 Promise,而 useEffect 的返回值应该是清理函数,不是 Promise。

一个常见的错误写法:

// 错误:async 函数作为 useEffect 回调
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

这个写法不会报错,但会有问题:async 函数返回的 Promise 会被 React 忽略,清理函数的返回值也会被忽略。正确的写法是:

// 正确:在 useEffect 内部使用 async
useEffect(() => {
  let cancelled = false;

  async function fetchData() {
    const data = await fetchData();
    if (!cancelled) setData(data);
  }

  fetchData();

  return () => { cancelled = true; };
}, []);

cancelled 标记解决了竞态条件问题:如果数据请求还在进行中组件就卸载了,取消标记会被设为 truesetData 就不会在组件卸载后被调用。


这一章想说的

useState 不是简单的状态存储,而是通过组件级别的状态队列在多次渲染之间保持状态连续性。函数式更新 setCount(prev => prev + 1) 是因为状态更新可能批量执行,直接用 count + 1 会产生闭包捕获旧值的问题。

useEffect 的依赖数组不是可选的,而是必要的——它决定了什么时候该重新运行副作用。缺少依赖会导致闭包陷阱:回调函数捕获了过期的值。清理函数是处理副作用生命周期的重要机制,在组件卸载和 Effect 重新运行之前都会被调用。