🎯 核心摘要
useEffect 是 React Hooks 中最强大也最容易误用的 Hook 之一。本文深入解析 useEffect 的执行机制、依赖数组的正确使用方式、清理函数的编写技巧,以及 8 个实际开发中经常遇到的场景和解决方案。无论你是刚接触 React 的新手,还是有一定经验的开发者,都能从中获得启发,避免常见的陷阱。
📚 原文要点
1. useEffect 的本质
useEffect 用于处理组件中的"副作用",即那些不需要在渲染期间执行的操作,比如数据获取、订阅、手动 DOM 操作等。
2. 依赖数组的作用
依赖数组决定了 useEffect 何时重新执行。空数组表示只在挂载时执行一次,包含变量则表示当这些变量变化时重新执行。
3. 清理函数的重要性
返回的清理函数在组件卸载或下次 effect 执行前调用,用于取消订阅、清除定时器等,避免内存泄漏。
🔍 技术点解析
| 技术点 | 说明 | 应用场景 |
|---|---|---|
| useEffect 基础 | 处理组件副作用 | 数据获取、订阅、DOM 操作 |
| 依赖数组 | 控制 effect 执行时机 | 避免不必要的重新执行 |
| 清理函数 | 清理副作用 | 取消订阅、清除定时器 |
| 执行时机 | 渲染完成后执行 | 保证 DOM 已更新 |
| 严格模式 | 开发环境双重执行 | 帮助发现清理问题 |
💡 补充信息
背景知识
useEffect 是 React 16.8 引入的 Hooks 之一,它的出现让函数组件能够处理副作用,不再局限于纯 UI 渲染。在 class 组件时代,我们需要在 componentDidMount、componentDidUpdate、componentWillUnmount 等多个生命周期中分散处理逻辑,而 useEffect 将这些统一到一个 API 中。
最佳实践
- 每个 effect 关注单一职责 - 不要把所有逻辑塞进一个 useEffect
- 正确设置依赖数组 - 使用 ESLint 插件检查依赖项
- 及时清理副作用 - 尤其是订阅和定时器
- 避免在 effect 中更新状态导致循环 - 仔细检查依赖项
常见问题
- 无限循环 - 依赖数组包含状态,effect 中又更新该状态
- 过时闭包 - 依赖数组不完整,effect 中使用旧值
- 清理函数遗漏 - 导致内存泄漏或意外行为
📝 完整文章
深入理解 React useEffect:从入门到精通的 8 个关键技巧
一、为什么 useEffect 让人又爱又恨?
在我接触 React Hooks 的初期,useEffect 是让我最头疼的 API。明明按照文档写了代码,却总是遇到各种奇怪的问题:
- 为什么我的 effect 执行了两次?
- 为什么数据请求一直在循环?
- 为什么我拿到的状态值总是旧的?
相信很多开发者都有过类似的困惑。今天,我们就来彻底搞懂 useEffect,让你在实际开发中游刃有余。
二、useEffect 到底是什么?
简单来说,useEffect 用于处理组件中的副作用。那么什么是副作用?
副作用指的是那些不需要在渲染期间执行的操作,它们"影响"了组件之外的事情。常见的副作用包括:
- 向服务器发送数据请求
- 订阅或取消订阅事件
- 手动操作 DOM
- 设置或清除定时器
- 日志记录
在 class 组件时代,这些逻辑分散在不同的生命周期方法中:
javascript
class MyComponent extends React.Component {
componentDidMount() {
// 组件挂载后执行
this.subscribeToChat();
}
componentDidUpdate(prevProps) {
// 组件更新后执行
if (prevProps.roomId !== this.props.roomId) {
this.subscribeToChat();
}
}
componentWillUnmount() {
// 组件卸载前执行
this.unsubscribeFromChat();
}
render() {
return <div>{this.props.content}</div>;
}
}
而使用 useEffect,我们可以将这些逻辑统一到一个地方:
javascript
function MyComponent({ roomId }) {
useEffect(() => {
// 这个函数在每次渲染后执行
const connection = createConnection(serverUrl, roomId);
connection.connect();
// 返回的清理函数在下次 effect 执行前或组件卸载时调用
return () => {
connection.disconnect();
};
}, [roomId]); // 依赖数组:当 roomId 变化时重新执行
return <div>Connected to {roomId}</div>;
}
三、useEffect 的执行时机
理解 useEffect 的执行时机至关重要。记住这个核心原则:
useEffect 在渲染完成后执行
这意味着:
- React 先执行你的组件函数,生成虚拟 DOM
- React 将虚拟 DOM 同步到真实 DOM
- 浏览器完成屏幕绘制
- useEffect 开始执行
这个执行顺序保证了你在 effect 中可以访问到最新的 DOM,但也意味着 effect 中的操作不会阻塞渲染。
严格模式下的双重执行
在开发环境中,如果你使用了 React.StrictMode,useEffect 会执行两次。这是故意的!目的是帮助你发现清理函数编写是否正确。
javascript
useEffect(() => {
console.log('Effect 执行');
return () => {
console.log('清理函数执行');
};
}, []);
// 开发环境输出:
// Effect 执行
// 清理函数执行
// Effect 执行
不要担心,生产环境中不会这样。但请确保你的清理函数能够正确处理这种情况。
四、依赖数组:useEffect 的灵魂
依赖数组是 useEffect 最核心也最容易出错的部分。让我们通过几个场景来理解它。
场景 1:只在挂载时执行一次
javascript
useEffect(() => {
// 只在组件首次挂载时执行
fetchInitialData();
}, []); // 空依赖数组
适用于:初始化数据、设置全局订阅等只需要执行一次的操作。
场景 2:当特定变量变化时执行
javascript
useEffect(() => {
// 当 userId 变化时重新执行
fetchUserData(userId);
}, [userId]); // 依赖数组包含 userId
适用于:需要根据 props 或 state 变化重新执行的操作。
场景 3:每次渲染后都执行
javascript
useEffect(() => {
// 每次渲染后都执行(谨慎使用!)
console.log('渲染完成');
}); // 没有依赖数组
警告:这种写法很少使用,因为可能导致性能问题或无限循环。
场景 4:多个依赖项
javascript
useEffect(() => {
// 当 userId 或 roomId 任意一个变化时执行
connectToChat(userId, roomId);
}, [userId, roomId]);
五、8 个关键技巧与实战场景
技巧 1:正确清理副作用
忘记清理是常见的错误来源。尤其是定时器和订阅:
javascript
// ❌ 错误示例:没有清理定时器
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
}, []);
// ✅ 正确示例:清理定时器
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
技巧 2:避免无限循环
这是新手最容易踩的坑:
javascript
// ❌ 错误示例:无限循环
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // effect 中更新依赖的状态
}, [count]); // count 变化触发 effect,effect 又更新 count...
// ✅ 正确示例:只在需要时更新
useEffect(() => {
if (count < 10) {
setCount(count + 1);
}
}, [count]);
技巧 3:使用函数式更新
当新状态依赖于旧状态时,使用函数式更新:
javascript
// ✅ 推荐写法
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // 使用函数式更新,避免依赖 count
}, 1000);
return () => clearInterval(timer);
}, []); // 不需要依赖 count
技巧 4:异步操作的正确处理
useEffect 本身不能是 async 函数,但可以在内部使用 async:
javascript
// ❌ 错误:useEffect 不能是 async
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
// ✅ 正确:在内部使用 async
useEffect(() => {
const fetchData = async () => {
const data = await api.getData();
setData(data);
};
fetchData();
}, []);
// ✅ 更优雅:使用 IIFE
useEffect(() => {
(async () => {
const data = await api.getData();
setData(data);
})();
}, []);
技巧 5:处理竞态条件
当快速切换依赖项时,可能出现竞态条件:
javascript
// ❌ 可能的问题:旧请求的结果覆盖新请求
useEffect(() => {
fetchComments(postId).then(setComments);
}, [postId]);
// ✅ 正确:使用清理函数取消过时的请求
useEffect(() => {
let cancelled = false;
fetchComments(postId).then(data => {
if (!cancelled) {
setComments(data);
}
});
return () => {
cancelled = true;
};
}, [postId]);
技巧 6:自定义 Hook 封装
将可复用的 effect 逻辑提取为自定义 Hook:
javascript
// 自定义 Hook:处理窗口大小
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return size;
}
// 使用
function MyComponent() {
const { width, height } = useWindowSize();
return <div>Window size: {width} x {height}</div>;
}
技巧 7:多个 effect 分离关注点
不要把所有逻辑塞进一个 effect:
javascript
// ❌ 不推荐:一个 effect 做太多事
useEffect(() => {
fetchUserData(userId);
document.title = `User: ${userName}`;
const timer = setInterval(logActivity, 1000);
return () => clearInterval(timer);
}, [userId, userName]);
// ✅ 推荐:分离为多个 effect
useEffect(() => {
fetchUserData(userId);
}, [userId]);
useEffect(() => {
document.title = `User: ${userName}`;
}, [userName]);
useEffect(() => {
const timer = setInterval(logActivity, 1000);
return () => clearInterval(timer);
}, []);
技巧 8:使用 ESLint 插件
安装并使用 eslint-plugin-react-hooks,它会自动检查依赖数组:
bash
npm install eslint-plugin-react-hooks
javascript
// ESLint 会警告你遗漏的依赖
useEffect(() => {
console.log(userId, userName);
}, []); // ⚠️ React Hook useEffect has missing dependencies: 'userId', 'userName'
六、常见陷阱与解决方案
陷阱 1:过时的闭包
javascript
// ❌ 问题:count 总是 0
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 永远是 0
setCount(count + 1); // 永远是 1
}, 1000);
}, []);
// ✅ 解决 1:使用函数式更新
useEffect(() => {
const timer = setInterval(() => {
setCount(c => {
console.log(c);
return c + 1;
});
}, 1000);
}, []);
// ✅ 解决 2:添加依赖(但会频繁重置定时器)
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]);
陷阱 2:对象/数组作为依赖
javascript
// ❌ 问题:config 每次都是新引用,effect 不停执行
const [config, setConfig] = useState({ theme: 'dark', lang: 'zh' });
useEffect(() => {
applyConfig(config);
}, [config]); // config 引用每次都变
// ✅ 解决 1:使用具体属性
useEffect(() => {
applyConfig(config);
}, [config.theme, config.lang]);
// ✅ 解决 2:使用 useMemo 稳定引用
const config = useMemo(() => ({ theme: 'dark', lang: 'zh' }), []);
七、总结
useEffect 是 React Hooks 中最强大的工具之一,但也需要谨慎使用。记住以下要点:
- 明确 effect 的目的 - 每个 effect 只做一件事
- 正确设置依赖数组 - 使用 ESLint 检查
- 不要忘记清理 - 尤其是定时器和订阅
- 理解执行时机 - effect 在渲染后执行
- 善用自定义 Hook - 封装可复用逻辑
掌握这些技巧后,你会发现 useEffect 不再神秘,而是你开发中的得力助手。