useEffect 是 React 中处理副作用的核心 Hook。理解它的内部机制,能帮助你避免常见的闭包陷阱、无限循环和内存泄漏问题。
副作用是什么
在 React 的渲染过程中,组件函数应该是一个“纯函数”——给定相同的 state 和 props,必须返回相同的 UI 描述。任何不能放在渲染阶段执行的操作都属于副作用,例如:
- 发起网络请求
- 操作 DOM(如设置 document.title)
- 订阅事件或数据源
- 设置定时器
useEffect 提供了一种声明式的方式来描述这些副作用,让 React 在正确的时机执行它们。
副作用的收集:链表结构
React 在组件函数执行时,并不立即执行副作用,而是将副作用回调收集起来,挂载在 Fiber 节点的 updateQueue 上。这个过程大致如下:
- 当组件函数执行时,遇到
useEffect(callback, deps)。 - React 创建一个
Effect对象,包含callback、deps和destroy(清理函数)。 - 将这个
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]);
执行流程如下:
- 首次渲染后:执行 Effect 回调,保存返回的清理函数。
- 依赖变化触发重新渲染:
- 先调用上一次的清理函数。
- 再执行新的 Effect 回调,保存新的清理函数。
- 组件卸载:调用最后一次的清理函数。
深入理解:与闭包的关联
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-hooks的exhaustive-deps规则自动检查。 - 避免在 Effect 中做无谓的工作:如果某个值的变化并不需要触发 Effect,可以将它移出依赖,或使用
useRef存储不需要触发重渲染的变量。 - 清理函数务必完整:设置定时器要返回一个清除定时器的函数;订阅事件要返回取消订阅的函数。任何未清理的副作用都可能导致内存泄漏或意外行为。
理解了 useEffect 的收集、调度和清理机制,你就能精准控制副作用的生命周期,写出健壮且可预测的 React 组件。