背景
在 React 中,useState
和 useEffect
是两个最关键的 Hooks。
据不完全统计,约 90% 的组件使用 useState
,而 70% 的组件会用到 useEffect
,这充分说明了它们的重要性。
useEffect
是处理副作用(数据请求、DOM 操作、订阅等),是替代生命周期方法(componentDidMount
、componentDidUpdate
、componentWillUnmount
),是连接 React 与外部系统的桥梁。
最近我在使用 Trae 进行代码检查时,发现它总是提醒我注意 useEffect
的依赖项问题。因此,如何正确使用 useEffect
以确保依赖项设置正确,成为了我必须重点关注的问题。
避免无限循环
依赖项与状态更新
在 useEffect
中直接修改依赖项的状态(如 setState
),导致依赖项变化 → 重新执行副作用 → 再次修改状态,形成循环。
// ❌ 错误示例:每次更新 count 都会触发 effect
useEffect(() => {
setCount(count + 1);
}, [count]);
// ✅ 正确:函数式更新 + 空依赖
useEffect(() => {
setCount(prev => prev + 1);
}, []);
// ✅ 正确:条件阻断循环
useEffect(() => {
if (count < 10) setCount(count + 1);
}, [count]);
// ✅ 正确:根据 count 变化执行副作用,但不会修改 count
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]); // 依赖 count,但副作用不修改 count
依赖项为引用类型
对象或数组依赖项仅进行浅比较,内容变化但引用未变时,effect 不会触发
const [user, setUser] = useState({ id: 1 });
// ❌ 错误:直接修改对象属性
user.name = 'Alice';
setUser(user); // 引用未变,effect 不执行
// ✅ 创建新引用
setUser({ ...user, name: 'Alice' });
正确处理异步操作
避免直接使用 async
函数
useEffect
的回调函数不能是 async
,因其返回 Promise 而非清理函数
// ❌ 错误:直接使用 async 函数
useEffect(async () => {
const data = await fetchData();
}, []);
// ✅ 正确
useEffect(() => {
const loadData = async () => {
const data = await fetchData();
setData(data);
};
loadData();
}, []);
处理加载与错误状态
结合 useState
管理加载状态与错误信息,提升用户体验
// 定义状态变量,用于跟踪加载状态
const [loading, setLoading] = useState(true);
// 定义状态变量,用于存储错误信息
const [error, setError] = useState('');
// 定义状态变量,用于存储获取到的数据
const [data, setData] = useState(null);
useEffect(() => {
// 创建 AbortController 实例,用于在组件卸载时取消请求
const abortController = new AbortController();
const fetchData = async () => {
try {
// 开始加载数据,设置加载状态为 true
setLoading(true);
// 重置错误状态,确保每次新请求开始时清除之前的错误
setError('');
// 发送 GET 请求获取数据,并传入 signal 用于可能的请求取消
const result = await axios.get('/api/data', {
signal: abortController.signal
});
// 请求成功,更新数据状态
setData(result.data);
} catch (err) {
// 检查错误是否由于请求被取消导致,如果不是则设置错误状态
if (!abortController.signal.aborted) {
setError(err.message || '请求失败,请重试');
}
} finally {
// 如果请求没有被取消,则更新加载状态为 false
if (!abortController.signal.aborted) {
setLoading(false);
}
}
};
// 调用数据获取函数
fetchData();
// 清理函数:组件卸载时取消正在进行的请求
return () => abortController.abort();
}, []); // 依赖数组为空,表示仅在组件挂载时执行一次
这里顺带提一下另外一篇文章:# async/await 必须使用 try/catch 吗?
副作用清理与性能优化
清理资源
定时器、事件监听等需在组件卸载或依赖项变化时清理,防止内存泄漏或重复注册事件
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer); // ✅ 清理定时器
}, []);
减少不必要执行
空依赖数组:当 useEffect
的依赖项数组为空([]
)时,它会在组件挂载后的首次渲染时执行一次,模拟类组件中 componentDidMount
的生命周期行为。
useEffect(() => {
console.log('组件挂载');
}, []); // ✅ 空数组
依赖项精确控制:仅当特定变量变化时触发 effect
useEffect(() => {
fetchUserData(userId);
}, [userId]); // ✅ 仅 userId 变化时执行
使用 useReducer
解耦复杂逻辑
当状态更新依赖前值或涉及多步骤时,useReducer
比 useState
更合适
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const timer = setInterval(() => {
dispatch({ type: 'increment' }); // ✅ 稳定的 dispatch
}, 1000);
return () => clearInterval(timer);
}, []);
依赖项传递函数
使用 useCallback
或 useMemo
缓存函数和数据,减少不必要的更新.
组件内定义的函数每次渲染引用不同,需用 useCallback
缓存
const fetchData = useCallback(async () => {
const res = await axios.get(`/api/data?id=${id}`);
setData(res.data);
}, [id]); // ✅ 依赖 id
useEffect(() => {
fetchData();
}, [fetchData]);
缓存计算结果,仅在依赖项变化时重新计算,需用 useMemo
缓存。
// 使用 useMemo 优化:仅在 list 变化时重新计算过滤结果
const filteredList = useMemo(() => {
return list.filter(item => item.price > 100);
}, [list]); // 当 list 发生变化时,filteredList 才会重新计算
// 使用 useEffect,当 filteredList 更新时执行副作用操作(例如记录日志或更新状态)
useEffect(() => {
console.log('Filtered list updated:', filteredList);
// 此处可添加其他需要在 filteredList 更新时执行的逻辑
}, [filteredList]); // 依赖 filteredList
依赖项过多
有时可能会遇到依赖项列表过长的情况,这时需要仔细思考哪些变量真正需要作为依赖。对那些频繁变动但实际上不影响 effect 内逻辑的值,可以考虑通过 useRef
存储。
const [count, setCount] = useState(0);
// 使用 useRef 存储最新的 count 值
const countRef = useRef(count);
// 每次 count 变化时更新 ref 的值
useEffect(() => {
countRef.current = count;
}, [count]);
// 设置一个 effect,用于定时输出最新的 count 值
// 注意:这里我们不直接依赖 count,而是通过 countRef.current 获取最新值,
// 这样依赖项列表就不会因为 count 频繁变化而过长或导致 effect 重新执行
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count 值:', countRef.current);
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖项为空,effect 只在组件挂载时执行一次
分离副作用
如果一个 effect 内部处理的逻辑过于复杂,可以考虑将逻辑拆分成多个 effect,每个 effect 只关注一个责任,便于维护和调试。
const [userData, setUserData] = useState(null);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
// Effect 1:当 userId 变化时,从 API 获取用户数据
useEffect(() => {
async function fetchUserData() {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
setUserData(data);
} catch (error) {
console.error('获取用户数据失败:', error);
}
}
if (userId) {
fetchUserData();
}
}, [userId]);
// Effect 2:监听窗口尺寸变化,更新窗口宽度
useEffect(() => {
function handleResize() {
setWindowWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
// 清理函数:组件卸载时移除事件监听
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // 空依赖数组确保只在挂载和卸载时执行
使用自定义 Hook
将复杂的 effect 逻辑封装成自定义 Hook,可以使组件代码更加清晰,同时也方便复用和测试。
这部分就不提供示例代码了哈。主要思路和目的:
- 封装复杂逻辑:将部分集中功能封装在自定义 Hook 中,使得主组件更加专注于 UI 展示。
- 复用性:同样的 Hook 可在其他需要这部分功能逻辑的组件中复用,避免代码重复。
- 测试方便:自定义 Hook 独立封装后,可以单独编写测试用例,对其异步逻辑和状态管理进行验证。
执行顺序
父子组件 effect 顺序
子组件的 useEffect
先于父组件执行。
// 父组件
useEffect(() => console.log('父 effect'));
// 子组件
useEffect(() => console.log('子 effect'));
// 输出顺序:子 effect → 父 effect
调试
react-devtools
npm install -g react-devtools
react-devtools
然后通过将以下 <script>
标签添加到您网站 <head>
的开头来连接您的网站:
<html>
<head>
<script src="http://localhost:8097"></script>
这会启动一个独立窗口,你可以在浏览器中打开你的 React 应用,然后在 DevTools 中查看组件树、Props、State 以及 Hooks 的状态。
- 在 React DevTools 中选中某个组件。
- 查看组件的 Hooks 部分,你可以直观地看到每个 Hook 的当前值。
- 当组件重新渲染且依赖项发生变化时,你可以观察到对应 Hook 的状态更新情况,从而帮助调试依赖项是否正确设置。
自定义 Hook 打印依赖项变化
import React, { useState, useEffect, useRef } from 'react';
/**
* useTraceUpdate 用于打印 props 的变化情况,帮助调试依赖项
* @param {object} props - 组件的 props 对象
*/
function useTraceUpdate(props) {
const prevProps = useRef(props);
useEffect(() => {
// 使用 Object.entries 遍历所有属性
const changedProps = Object.entries(props).reduce((acc, [key, value]) => {
if (prevProps.current[key] !== value) {
acc[key] = { from: prevProps.current[key], to: value };
}
return acc;
}, {});
if (Object.keys(changedProps).length > 0) {
console.log('依赖项变化:', changedProps);
}
// 更新 prevProps 为最新的 props
prevProps.current = props;
});
}
// 示例组件:使用自定义 Hook 跟踪 props 变化
function ExampleComponent(props) {
// 打印 props 的变化情况
useTraceUpdate(props);
return (
<div>
<h2>示例组件</h2>
<p>当前 prop 值:{JSON.stringify(props)}</p>
</div>
);
}
export default ExampleComponent;
eslint-plugin-react-hooks
建议安装 eslint-plugin-react-hooks 来帮助检查依赖是否正确,会给出提示。
小结
正确使用 useEffect 的关键在于深入理解其执行机制、精心配置依赖数组,以及合理管理清理函数。结合工具和最佳实践,规避常见陷阱,能够显著提升代码的健壮性和可维护性。
希望这份指南能为你高效、正确地使用 useEffect 提供有力支持!