useEffect 是 React Hooks 中用于处理**副作用(Side Effects)**的核心钩子。所谓副作用,是指那些会影响组件外部世界或需要在渲染后执行的操作,例如数据获取、订阅事件、手动修改 DOM、设置定时器等。
以下是 useEffect 的详细用法、核心机制及最佳实践。
1. 基本语法
javascript
useEffect(() => {
// 1. 副作用逻辑代码(在组件渲染到屏幕后执行)
return () => {
// 2. 清理函数(可选):在组件卸载或依赖项变化导致重新执行前调用
};
}, [dependencies]); // 3. 依赖数组(可选)
它接收两个参数:
- 副作用函数:包含需要执行的逻辑。
- 依赖数组:控制副作用何时重新执行。
2. 三种核心执行模式
通过改变第二个参数(依赖数组),可以精确控制 useEffect 的执行时机:
A. 仅在组件挂载时执行一次(模拟 componentDidMount)
当依赖数组为**空数组 []** 时,副作用只在组件首次渲染后执行一次。常用于初始化数据、建立连接等。
javascript
useEffect(() => {
console.log('组件已挂载');
fetchData();
}, []); // 空依赖数组
B. 在特定依赖变化时执行(模拟 componentDidUpdate)
当依赖数组中包含具体变量时,只有当这些变量的值发生变化(浅比较)时,副作用才会重新执行。
javascript
const [userId, setUserId] = useState(1);
useEffect(() => {
console.log(`用户ID变为: ${userId}`);
fetchUser(userId);
}, [userId]); // 仅当 userId 变化时执行
C. 每次渲染后都执行
如果省略 第二个参数,副作用会在每次 组件渲染(包括初始挂载和所有更新)后执行。
注意:这通常会导致性能问题,除非确实需要监听每一次渲染。
javascript
useEffect(() => {
console.log('每次渲染都会执行');
});
3. 清理副作用(Cleanup)
如果副作用涉及资源占用(如定时器、事件监听、WebSocket 订阅),必须返回一个清理函数。React 会在以下时机调用它:
- 组件卸载时。
- 依赖项变化,导致副作用重新执行之前(先清理旧的,再执行新的)。
示例:管理定时器
javascript
useEffect(() => {
const timerId = setInterval(() => {
console.log('Tick');
}, 1000);
// 清理函数:清除定时器,防止内存泄漏
return () => {
clearInterval(timerId);
};
}, []);
示例:管理事件监听
javascript
useEffect(() => {
const handleResize = () => {
console.log(window.innerWidth);
};
window.addEventListener('resize', handleResize);
// 清理函数:移除监听器
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
4. 常见陷阱与最佳实践
⚠️ 陷阱一:异步函数直接作为回调
useEffect 的回调函数不能直接声明为 async,因为 async 函数隐式返回一个 Promise,而 useEffect 期望返回 undefined 或清理函数。
**错误写法:**
javascript
// ❌ 错误:useEffect 不支持 async 直接作为回调
useEffect(async () => {
const data = await fetchData();
}, []);
正确写法:
在 useEffect 内部定义并调用异步函数。
javascript
// ✅ 正确
useEffect(() => {
const loadData = async () => {
try {
const data = await fetchData();
setData(data);
} catch (error) {
console.error(error);
}
};
loadData();
}, []);
⚠️ 陷阱二:无限循环
如果在 useEffect 中更新了状态,而该状态又是依赖项之一,且没有正确的终止条件,会导致无限循环。
**错误示例:**
javascript
// ❌ 错误:count 更新 -> 触发渲染 -> useEffect 执行 -> count 再次更新...
useEffect(() => {
setCount(count + 1);
}, [count]);
⚠️ 陷阱三:遗漏依赖项(Stale Closures)
如果副作用中使用了组件内的变量或函数,但未将其加入依赖数组,可能会导致捕获到旧的值(闭包陷阱)。
**建议:**
- 始终将副作用中使用的所有外部变量加入依赖数组。
- 使用 ESLint 插件
eslint-plugin-react-hooks自动检查依赖项。 - 如果依赖项是函数或对象,且每次渲染都重新创建,建议使用
useCallback或useMemo稳定其引用,避免不必要的重复执行。
💡 最佳实践:拆分 Effect
不要试图在一个 useEffect 中处理所有逻辑。应根据不同的关注点拆分为多个 useEffect,每个 Effect 只处理一个独立的副作用。这使得代码更易读、更易维护,且依赖关系更清晰。
javascript
// ✅ 推荐:拆分逻辑
useEffect(() => {
document.title = `User: ${user.name}`;
}, [user.name]);
useEffect(() => {
const socket = connectToChat(user.id);
return () => socket.disconnect();
}, [user.id]);
5. React 18 中的行为变化
在 React 18 的开发环境 下,如果使用了 <StrictMode>,useEffect 会被故意执行两次(挂载 -> 执行 -> 清理 -> 再次挂载 -> 再次执行)。
- 目的:帮助开发者发现未正确编写清理函数的副作用,确保应用在并发渲染下的稳定性。
- 注意 :生产环境中不会执行两次。确保你的副作用是幂等的,并且清理函数能正确释放资源。
总结
表格
| 场景 | 依赖数组 | 执行时机 |
|---|---|---|
| 仅初始化 | [] |
组件挂载后执行一次 |
| 响应变化 | [dep1, dep2] |
挂载后执行一次,且当 dep1 或 dep2 变化时重新执行 |
| 每次渲染 | 无 | 每次组件渲染后都执行(慎用) |
正确使用 useEffect 的关键在于理解它是同步组件状态与外部系统的桥梁,并通过依赖数组精确控制同步的时机。