当你写出无泄漏、无多余渲染、无过期闭包的 effect 时,React 函数组件的开发体验会变得前所未有的流畅。希望这篇指南能帮你跨过
useEffect的常见门槛,写出更可靠的 React 应用。
前言
useEffect 是 React 函数组件中最常用、也最容易写出 bug 的 Hook 之一。它让我们能在函数组件中执行副作用(side effects),从而与外部世界交互。本文将系统地梳理 useEffect 的核心知识、典型场景、常见误区以及最佳实践,帮助你真正驾驭它。
1. 什么是副作用?为什么需要 useEffect?
React 的核心原则之一是 组件必须是纯函数:对于相同的 props 和 state,组件应该渲染出相同的 UI。但在实际应用中,我们不可避免地需要做一些"不纯"的事情,例如:
- 从服务器获取数据
- 手动操作 DOM
- 订阅外部事件(如键盘、滚动、WebSocket)
- 设置定时器
- 打印日志或发送埋点
这些操作会影响组件以外的世界,被称为副作用 。在类组件中,我们通过 componentDidMount、componentDidUpdate 和 componentWillUnmount 来管理副作用;而在函数组件中,所有副作用都统一交给 useEffect 处理。
2. 基本语法
jsx
useEffect(setup, dependencies?)
setup:一个函数,用于执行副作用逻辑。它可以返回一个清理函数(cleanup),在组件卸载或下一次副作用执行前调用。dependencies(可选) :一个数组,包含所有在副作用中使用到的、来自组件内部的响应式值(如 props、state、以及由它们派生的变量)。React 会使用Object.is对依赖项进行浅比较,只有依赖项发生变化时,副作用才会重新执行。
3. 依赖项数组的三种形态
3.1 不传第二个参数
jsx
useEffect(() => {
console.log('每次渲染后都会执行');
});
每次组件完成渲染(包括首次渲染和每次更新)后,副作用都会执行。这种用法很少见,容易导致性能问题或无限循环,通常需要明确指定依赖项。
3.2 传入空数组 []
jsx
useEffect(() => {
console.log('仅在组件挂载时执行一次');
}, []);
副作用只在组件首次挂载后运行一次,清理函数也只在卸载时运行。这类似于 componentDidMount + componentWillUnmount 的组合。
3.3 传入具体的依赖项
jsx
useEffect(() => {
console.log(`count 变为 ${count}`);
}, [count]);
只有当 count 发生变化时,副作用才会重新执行。这是最推荐、最精细的控制方式。
4. 副作用的清理
很多副作用需要在组件卸载或者下一次副作用执行前被清理掉,比如清除定时器、取消订阅、中止网络请求等。useEffect 通过让 setup 函数返回一个清理函数来实现这一点。
jsx
useEffect(() => {
const timer = setInterval(() => {
console.log('ticking...');
}, 1000);
// 返回清理函数
return () => {
clearInterval(timer);
console.log('清除定时器');
};
}, []);
执行时机:
- 组件卸载时,执行上一次副作用的清理函数。
- 依赖项变化、即将执行下一次副作用之前,会先执行上一次的清理函数。
这也意味着:每一次副作用都能拿到"属于它自己"的 state 和 props ,避免了类组件中常见的"需要在 componentDidUpdate 里手动比较"的问题。
5. 典型使用场景
5.1 数据获取
jsx
useEffect(() => {
let cancelled = false;
async function fetchData() {
const res = await fetch(`/api/user/${userId}`);
const json = await res.json();
if (!cancelled) {
setUser(json);
}
}
fetchData();
return () => {
cancelled = true; // 防止组件卸载后设置状态
};
}, [userId]);
注意:useEffect 的回调不能直接声明为 async ,因为异步函数默认返回 Promise,而 useEffect 期望返回一个清理函数或 undefined。因此需要在内部定义异步函数并调用。
5.2 事件监听
jsx
useEffect(() => {
const handleResize = () => setWindowSize(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
5.3 手动操作 DOM(如聚焦输入框)
jsx
useEffect(() => {
inputRef.current?.focus();
}, []);
如果 DOM 操作不涉及外部观察者,也不需要清理。
5.4 订阅外部 Store(如 Redux、Zustand、EventEmitter)
jsx
useEffect(() => {
const unsubscribe = externalStore.subscribe(handleChange);
return unsubscribe;
}, []);
6. 常见误区与陷阱
6.1 遗漏依赖项,导致"闭包陷阱"
jsx
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 永远打印初始值 0
}, 1000);
return () => clearInterval(timer);
}, []); // 注意:依赖数组为空,但内部使用了 count
因为副作用被"固化"在了初始渲染的闭包中,count 永远是第一次渲染时的值。正确的做法是将 count 加入依赖:
jsx
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, [count]);
或者使用函数式更新 setCount(c => c + 1) 来避免直接读取 count。
6.2 将对象、数组、函数直接作为依赖,引发无限循环
jsx
const options = { page: 1, size: 10 };
useEffect(() => {
fetchData(options);
}, [options]); // 每次渲染 options 都是新对象,导致副作用无限执行
React 对依赖项进行浅比较,而每次渲染都会创建新的引用类型值。解决方法:
- 改为使用基础类型值作为依赖;
- 使用
useMemo或useCallback保持引用稳定; - 或者采用比较 ref 的方式来跳过不必要的执行(慎用)。
6.3 在 useEffect 中根据 state 计算另一个 state
jsx
// ❌ 错误:用 effect 同步派生状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
这种写法不仅多余,还会导致额外的渲染。派生状态应该在渲染期间直接计算,而不是通过 effect:
jsx
const fullName = `${firstName} ${lastName}`;
如果计算开销大,可以用 useMemo。
6.4 严格模式下的"双重调用"
在 React 18 的严格模式(<StrictMode>)下,effects 会在开发环境中执行两次 (setup → cleanup → setup),目的是帮助你发现未正确清理的副作用。这不是 bug,生产环境下仍然只执行一次。因此,请务必为所有需要清理的副作用编写清理函数。
6.5 异步 effect 的写法错误
jsx
// ❌ 直接传递 async 函数
useEffect(async () => {
const data = await fetchSomething();
setData(data);
}, []);
正确做法见 5.1 节,在内部定义并调用异步函数。
7. useEffect 与 useLayoutEffect
useEffect在浏览器完成布局和绘制之后异步执行,不会阻塞屏幕更新,适用于大多数副作用。useLayoutEffect在 DOM 变更之后、浏览器绘制之前同步执行,适用于需要同步读取/修改 DOM 布局的场景(例如测量元素尺寸、避免闪烁)。
两者语法完全相同,但选择上应优先使用 useEffect,只在必要时切换为 useLayoutEffect。
8. 最佳实践
-
一个 useEffect 只做一件事
把不相关的逻辑拆分成多个
useEffect,每个都有自己的依赖数组,让代码更清晰、更易维护。 -
始终填写完整的依赖数组
不要故意省略依赖来"优化"执行时机,这会为 bug 埋下伏笔。使用
eslint-plugin-react-hooks的exhaustive-deps规则可以自动补全和检查。 -
优先使用函数式更新和稳定引用
当副作用依赖的状态变更非常频繁时,考虑使用
setState(prev => ...)来避免在依赖中引入 state;对于函数或对象,用useMemo/useCallback保持其稳定性。 -
关注清理逻辑
任何创建了订阅、定时器、绑定了事件的副作用都应该返回清理函数。
-
提取自定义 Hook
通用、可复用的副作用逻辑可以封装为自定义 Hook(如
useFetch、useEventListener、useInterval),让组件代码更简洁。
9. 总结
useEffect 用声明式的方式将副作用与组件状态绑定起来,让我们可以更清晰地表达"在什么情况下需要做什么事"。掌握它的关键在于:
- 理解依赖数组的工作原理与比较机制;
- 为每一个副作用负责到底(包括清理);
- 避免用 effect 实现可以用纯计算解决的问题;
- 利用工具和规则保持代码的健壮性。