从一个问题开始
为什么 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 标记解决了竞态条件问题:如果数据请求还在进行中组件就卸载了,取消标记会被设为 true,setData 就不会在组件卸载后被调用。
这一章想说的
useState 不是简单的状态存储,而是通过组件级别的状态队列在多次渲染之间保持状态连续性。函数式更新 setCount(prev => prev + 1) 是因为状态更新可能批量执行,直接用 count + 1 会产生闭包捕获旧值的问题。
useEffect 的依赖数组不是可选的,而是必要的——它决定了什么时候该重新运行副作用。缺少依赖会导致闭包陷阱:回调函数捕获了过期的值。清理函数是处理副作用生命周期的重要机制,在组件卸载和 Effect 重新运行之前都会被调用。