useEffect 是 React 中最核心的 Hook 之一,用于在函数组件中处理副作用——那些不属于 UI 渲染本身、但需要在组件生命周期特定时机执行的操作。
什么是副作用
副作用是组件渲染之外需要执行的逻辑,典型场景包括:
- 数据获取(发起网络请求)
- 订阅/取消订阅(WebSocket、事件监听)
- 手动修改 DOM
- 设置定时器(setTimeout/setInterval)
- 日志记录、埋点
函数组件本身应该是纯函数——给定相同的 props 和 state,应该返回相同的 UI。副作用打破了这个纯粹性,但却是真实应用不可或缺的部分。useEffect 提供了安全、声明式的方式来编排这些副作用。
基础语法
useEffect(() => {
// 副作用逻辑(在渲染之后执行)
return () => {
// 可选的清理函数(组件卸载或依赖变化前执行)
};
}, [依赖项数组]);
- 第一个参数:副作用函数,在组件渲染到屏幕之后异步执行,不会阻塞浏览器绘制。
- 第二个参数:依赖项数组,决定副作用何时重新执行。
- 返回值:清理函数,在组件卸载或依赖项变化、副作用重新执行之前调用。
依赖项的核心作用
依赖项数组控制着副作用的执行时机,三种常见用法:
1. 不传依赖项:每次渲染都执行
useEffect(() => {
console.log('组件每次更新后都会执行');
});
极少使用,因为通常不需要在每次渲染都触发副作用,容易造成性能浪费或无限循环。
2. 空依赖数组 []:仅在挂载时执行一次
useEffect(() => {
fetch('/api/user').then(res => res.json()).then(setUser);
}, []);
模拟类组件中的 componentDidMount,适合初始化数据请求、添加全局事件监听等只需执行一次的操作。
3. 指定依赖:依赖变化时执行
useEffect(() => {
document.title = `未读消息:${unreadCount}`;
}, [unreadCount]);
只有当 unreadCount 发生变化时,才重新执行副作用。React 使用 Object.is 进行浅比较,因此依赖项中的对象或数组需要保持引用稳定(配合 useMemo/useCallback 或使用基本类型)。
关键原则:依赖项必须诚实地包含副作用内部使用的所有响应式值(props、state、以及由它们派生的变量)。遗漏依赖是 bug 的常见来源,而引入了不必要的变化依赖又会导致副作用频繁执行。
如果安装了 ESLint 插件 eslint-plugin-react-hooks,它会帮你检查遗漏的依赖,并在控制台提示。
清理函数的典型场景
清理函数用于防止内存泄漏,以及在依赖变化时撤销上一次的副作用。常见的清理场景:
清除定时器
useEffect(() => {
const timer = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
不清理会使定时器在组件卸载后继续执行,导致内存泄漏或更新已卸载组件的状态。
取消订阅 / 解绑事件
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
每次 effect 重新执行前,会先运行上一次的清理函数,保证事件监听不会重复绑定。
取消未完成的请求(竞态处理)
useEffect(() => {
let cancelled = false;
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);
当 userId 快速变化时,旧请求的响应可能在新请求之后才返回,导致界面显示错误的数据。通过清理标记可以忽略过期的响应。现代更推荐使用 TanStack Query 等库自动处理这类问题。
取消订阅外部状态
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setCount(store.getState().count);
});
return unsubscribe;
}, []);
常见陷阱与最佳实践
1. 避免在 effect 中执行同步状态更新导致无限循环
如果在 effect 中无条件地更新某个状态,而该状态又是该 effect 的依赖项,就会陷入无限循环。
// ❌ 会导致无限循环
useEffect(() => {
setCount(count + 1);
}, [count]);
确保状态更新仅在特定条件下发生,或不在依赖项中包含该状态。
2. 使用多个 effect 分离不同逻辑
不要把不相关的副作用聚合在一个 effect 里:
// ✅ 好:各自独立
useEffect(() => {
document.title = title;
}, [title]);
useEffect(() => {
fetchData(userId);
}, [userId]);
这样每个 effect 的依赖更清晰,逻辑更内聚,减少 bug 并提高可维护性。
3. 使用 useRef 保存不需要触发重渲染的值
如果在 effect 中需要某些变量但不想把它们加入依赖项,可以将其存入 ref:
const latestProps = useRef(props);
latestProps.current = props;
useEffect(() => {
const timer = setInterval(() => {
console.log(latestProps.current.someValue);
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖,但能始终访问最新的 props
但请谨慎使用,通常更推荐遵循依赖规则。
4. 清理函数中的闭包问题
清理函数捕获的是定义它时的那一轮的 props 和 state,而不是最新的值。如果需要访问最新值,可以用 ref 的方式,或者重新设计清理逻辑。
总结
useEffect用于在函数组件中安全地运行副作用。- 依赖项数组是精确控制执行时机的核心,必须诚实声明。
- 清理函数用于防止内存泄漏和逻辑错误,是开发稳健应用的保障。
- 遵循“一个 effect 一件事”的原则,能显著提升代码质量。
掌握 useEffect 的精髓不在于记忆 API,而在于培养对副作用生命周期的敏感度——知道何时启动、何时撤销,这是 React 开发者从入门到进阶的关键一步。