【大前端】React useEffect 详解:从入门到进阶
React useEffect 详解:从入门到进阶
在 React Hooks 出现之前,我们经常通过 生命周期函数(componentDidMount
、componentDidUpdate
、componentWillUnmount
等)来管理副作用逻辑。
自 React 16.8 起,Hooks 引入了 useEffect
,统一了副作用处理逻辑,使函数组件具备了管理副作用的能力。
1. 什么是副作用(Side Effect)?
在 React 中,副作用 指的是那些会影响函数组件之外环境的操作,比如:
- 数据请求(Ajax / fetch / axios)
- DOM 操作(手动修改元素属性)
- 订阅/取消订阅(WebSocket、事件监听器)
- 定时器(
setInterval
、setTimeout
)
这些逻辑如果直接写在函数体里,会导致 多次执行、状态不一致 等问题,因此 React 提供了 useEffect
来专门管理副作用。
2. useEffect 基本语法
useEffect(() => {// 副作用逻辑return () => {// 清理逻辑(可选)};
}, [依赖项]);
参数解析
-
第一个参数:一个函数,包含副作用逻辑。
- 可返回一个清理函数,用于组件卸载或依赖变化时执行清理。
-
第二个参数:依赖数组(dependency array)。
[]
空数组 → 仅在初次渲染执行一次(类似componentDidMount
)。[state, props]
→ 当依赖项变化时执行(类似componentDidUpdate
)。- 省略 → 每次渲染后都执行。
3. 使用场景举例
3.1 模拟 componentDidMount
useEffect(() => {console.log("组件挂载完成");
}, []);
3.2 模拟 componentDidUpdate
useEffect(() => {console.log("count 变化了:", count);
}, [count]);
3.3 模拟 componentWillUnmount
useEffect(() => {const id = setInterval(() => console.log("定时器"), 1000);return () => clearInterval(id); // 清理逻辑
}, []);
4. 常见坑点
4.1 依赖数组遗漏
useEffect(() => {fetch(`/api/user/${id}`);
}, []);
❌ 错误:id
变化时不会重新请求
✅ 正确:
useEffect(() => {fetch(`/api/user/${id}`);
}, [id]);
4.2 无限循环陷阱
useEffect(() => {setCount(count + 1); // 修改状态
}, [count]);
⚠️ 会导致无限循环更新。
👉 解决:需要加条件判断,或用 useRef
缓存不影响渲染的变量。
4.3 异步函数处理
useEffect
不能直接传入 async 函数:
// ❌ 错误写法
useEffect(async () => {const res = await fetchData();
}, []);
✅ 正确写法:
useEffect(() => {async function loadData() {const res = await fetchData();setData(res);}loadData();
}, []);
5. useEffect vs useLayoutEffect
- useEffect:异步执行,不会阻塞浏览器渲染(大多数场景使用它)。
- useLayoutEffect:同步执行,DOM 更新后立即运行,常用于需要精确操作 DOM 的场景。
6. 最佳实践
- 依赖项必须写全:避免因闭包导致取到旧值。
- 拆分 effect:不同逻辑不要写在一个
useEffect
,保证单一职责。 - 避免频繁触发:对频繁变化的值,可以结合
useDebounce
、useThrottle
。 - 合理使用清理函数:确保事件监听、定时器等不会造成内存泄漏。
7. 总结
useEffect
是 React 用来管理副作用的统一接口。- 依赖数组的使用是关键,决定了副作用的执行时机。
- 清理函数是避免内存泄漏的利器。
- 面试常问陷阱:依赖数组遗漏、无限循环更新、异步写法错误。
📌 推荐一个思考题(常见面试题):
为什么下面的 useEffect
打印的总是旧的 count
?
useEffect(() => {const id = setInterval(() => {console.log(count);}, 1000);return () => clearInterval(id);
}, []);
这是一个典型的 闭包陷阱 (stale closure) 问题。我们来拆解一下原因和解决办法:
🔍 原因
你的代码:
useEffect(() => {const id = setInterval(() => {console.log(count);}, 1000);return () => clearInterval(id);
}, []);
关键点:
useEffect
的依赖数组是[]
,所以只会在组件挂载时执行一次。- 当
setInterval
回调函数创建时,它“捕获”了当时的count
变量。 - 之后即使
count
在组件中更新了,回调函数里的count
依旧是老的值,因为闭包绑定的是初始快照。
所以 console.log(count)
打印的总是旧的。
✅ 解决办法
方法 1:把 count
放到依赖数组里
useEffect(() => {const id = setInterval(() => {console.log(count);}, 1000);return () => clearInterval(id);
}, [count]);
这样,每次 count
更新时,useEffect
会重新执行,重新注册一个新的定时器,拿到最新的 count
。
⚠️ 但这种方式会不断清除和重建定时器,有时不太优雅。
方法 2:使用函数式更新 + useRef
利用 useRef
保存最新的 count
:
const countRef = useRef(count);useEffect(() => {countRef.current = count;
}, [count]);useEffect(() => {const id = setInterval(() => {console.log(countRef.current);}, 1000);return () => clearInterval(id);
}, []);
这里定时器只建立一次,但回调里取的是 countRef.current
,它会随着 count
更新而变化。
方法 3:函数式 setState
如果逻辑允许,可以用函数式更新避免依赖旧状态:
setCount(prev => prev + 1);
这样不依赖闭包里的旧值。
📌 总结
-
原因:闭包导致定时器回调捕获的是旧的
count
。 -
解决方案:
- 把
count
放到依赖数组 → 每次更新重建 effect。 - 用
useRef
保存最新值 → 定时器只建一次但能读到最新数据。 - 用函数式
setState
避免依赖旧值。
- 把