React useEffect 完全指南:我在 3 个项目中踩坑后总结的血泪经验
依赖数组写错、清理函数忘记、无限重渲染——这些坑我都帮你踩过了
前言
我第一次被 useEffect 坑,是在一个电商项目的购物车页面。
场景很简单:用户添加商品后,需要实时更新购物车角标数字。我这样写的:
function CartIcon({ userId }) {
const [count, setCount] = useState(0);
useEffect(() => {
fetchCartCount(userId).then(setCount);
}, []); // 当时我觉得,userId 不会变,所以依赖数组写空
}
结果呢?用户切换账号后,角标显示的还是上一个用户的购物车数量。
我花了 3 个小时才找到问题:依赖数组写错了。
但这还不是最惨的。
在另一个项目中,我忘记清理定时器,导致组件卸载后还在执行代码,内存泄漏让页面越来越卡。测试报告里全是"页面卡顿"、"浏览器崩溃"的 bug。
还有一次,我在 useEffect 里用了状态值,结果拿到了旧值,排查了一整天才发现是闭包陷阱。
这篇文章,就是我用无数个加班夜晚换来的 useEffect 血泪总结。我会告诉你:
- 依赖数组的 5 个常见错误(我都踩过)
- 清理函数什么时候执行(90% 的人理解错了)
- 如何避免无限重渲染(这个坑太深了)
- 2026 年的最佳实践(别再用 2019 年的写法了)
如果你也被"为什么 effect 总是执行?"、"为什么拿到的是旧值?"这些问题困扰过,继续往下看。
一、useEffect 的核心作用
什么是副作用?
简单说,副作用就是会让组件和外部世界产生交互的操作。
比如:
- 调用 API 获取数据
- 手动操作 DOM
- 设置定时器
- 添加事件监听
- 写入 localStorage
这些都是"副作用",因为它们超出了 React 的渲染范围。
useEffect 的基本用法
useEffect(() => {
// 这里写副作用代码
fetch('/api/data').then(...);
// 可选:返回清理函数
return () => {
// 组件卸载或依赖变化时执行
cleanup();
};
}, [dependencies]); // 依赖数组
核心思想:useEffect 让 React 知道你的组件需要在渲染后"做些额外的事"。
依赖数组的三种写法
| 依赖数组 | 执行时机 | 使用场景 | 踩坑指数 |
|---|---|---|---|
[] | 仅挂载时执行一次 | 初始化、订阅 | ⭐⭐⭐ |
[dep] | dep 变化时执行 | 响应状态变化 | ⭐⭐ |
| 不传 | 每次渲染后执行 | 极少使用 | ⭐⭐⭐⭐⭐ |
我的建议:90% 的场景用 [dep],10% 用 [],几乎不用"不传"。
二、依赖数组的正确用法(重点)
坑 1:遗漏依赖项(我踩过最多次)
// ❌ 我当年的写法,bug 找了我一下午
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 始终是 0!
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖数组是空的
return <div>{count}</div>;
}
// ✅ 正确写法
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 现在能拿到最新的 count 了
}, 1000);
return () => clearInterval(timer);
}, [count]); // 添加 count 到依赖数组
return <div>{count}</div>;
}
问题表现:定时器里打印的 count 永远是初始值 0,不会更新。
原因:依赖数组是空的,effect 只在组件挂载时执行一次。那时候 count 是 0,所以定时器里的闭包捕获的也是 0。
我的血泪教训:ESLint 的 react-hooks/exhaustive-deps 规则会警告你遗漏的依赖。别忽略它,它大概率是对的。
坑 2:依赖项写太多导致无限循环
// ❌ 这样写会无限重渲染
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const config = { api: '/api/user' }; // 每次渲染都创建新对象
useEffect(() => {
fetchUser(userId, config).then(setUser);
}, [userId, config]); // config 每次都变,导致无限循环
return <div>{user?.name}</div>;
}
// ✅ 正确写法
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId, { api: '/api/user' }).then(setUser);
}, [userId]); // 对象直接写在 effect 里,不作为依赖
return <div>{user?.name}</div>;
}
问题表现:页面卡死,控制台全是"Maximum update depth exceeded"。
原因:config 对象在每次渲染时都会创建新的引用,导致 effect 不停执行。
我的解决方法:
- 把对象/数组直接写在 effect 里
- 用
useMemo保持引用稳定 - 只依赖原始值(string、number)
坑 3:函数作为依赖项
// ❌ 函数每次渲染都变
function SearchInput() {
const [query, setQuery] = useState('');
const handleSearch = (q) => { // 每次渲染都创建新函数
console.log('search:', q);
};
useEffect(() => {
const timer = setTimeout(() => {
handleSearch(query); // handleSearch 在依赖数组里
}, 300);
return () => clearTimeout(timer);
}, [query, handleSearch]); // handleSearch 每次都变!
return <input onChange={e => setQuery(e.target.value)} />;
}
// ✅ 正确写法
function SearchInput() {
const [query, setQuery] = useState('');
useEffect(() => {
const handleSearch = (q) => { // 函数写在 effect 里
console.log('search:', q);
};
const timer = setTimeout(() => {
handleSearch(query);
}, 300);
return () => clearTimeout(timer);
}, [query]);
return <input onChange={e => setQuery(e.target.value)} />;
}
我的建议:如果函数只在 effect 里用,就写在 effect 里面。如果需要传递给子组件,用 useCallback 包裹。
坑 4:state 更新依赖旧值
// ❌ 拿到的是旧值
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // count 是闭包里的旧值
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
// ✅ 正确写法
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // 用函数式更新
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
核心原则:如果 state 更新依赖于之前的值,用函数式更新 setCount(prev => prev + 1)。
坑 5:异步函数直接作为 effect 回调
// ❌ 这是禁止的
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
// ✅ 正确写法
useEffect(() => {
async function fetchDataInEffect() {
const data = await fetchData();
setData(data);
}
fetchDataInEffect();
}, []);
原因:effect 回调不能是 async 函数,因为它需要返回清理函数(或 undefined)。
三、清理函数的正确使用
什么时候需要清理?
需要清理的场景:
- 定时器(
setInterval、setTimeout) - 事件监听(
addEventListener) - 网络请求(AbortController)
- 订阅(WebSocket、Observable)
- 手动 DOM 操作
我踩过的坑:
// ❌ 忘记清理定时器
useEffect(() => {
setInterval(() => {
console.log('tick');
}, 1000);
// 没有返回清理函数!
}, []);
// 结果:组件卸载后定时器还在跑,内存泄漏
// ✅ 正确写法
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
return () => {
clearInterval(timer); // 清理定时器
};
}, []);
清理函数的执行时机
清理函数会在以下时机执行:
- 组件卸载时
- 依赖项变化,effect 重新执行前
useEffect(() => {
console.log('effect 执行');
return () => {
console.log('清理函数执行');
};
}, [dep]);
// 执行顺序:
// 1. 组件挂载:effect 执行
// 2. dep 变化:清理函数执行 → effect 执行
// 3. 组件卸载:清理函数执行
我的经验:把清理函数想象成"上一次的收尾工作",在下一次 effect 执行前,先把上一次的尾巴收干净。
四、实战场景
场景 1:数据获取(处理竞态条件)
背景:在一个文章详情页,用户可能快速切换不同文章。如果网络请求返回顺序和发起顺序不一致,会显示错误的数据。
function Article({ id }) {
const [article, setArticle] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false; // 关键:忽略标志
fetchArticle(id).then(data => {
if (!ignore) {
setArticle(data);
setLoading(false);
}
});
return () => {
ignore = true; // 组件卸载或 id 变化时,忽略之前的请求
};
}, [id]);
if (loading) return <div>加载中...</div>;
return <div>{article.title}</div>;
}
效果:即使用户快速切换文章,也不会出现"闪回"问题(旧数据覆盖新数据)。
我踩过的坑:之前我不知道这个技巧,测试报告里经常有"显示的文章内容和标题不符"的 bug。后来加了 ignore 标志,问题解决了。
场景 2:事件监听(别忘了清理)
背景:需要监听窗口大小变化,实时更新布局。
function ResponsiveComponent() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize); // 清理
};
}, []);
return <div>窗口宽度:{width}</div>;
}
不清理的后果:组件多次挂载卸载后,handleResize 会被调用多次,性能越来越差。
场景 3:定时器(最常见的内存泄漏)
背景:倒计时组件,每秒更新剩余时间。
function Countdown({ targetTime }) {
const [remaining, setRemaining] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
const now = Date.now();
const diff = targetTime - now;
setRemaining(Math.max(0, diff));
}, 1000);
return () => {
clearInterval(timer); // 必须清理!
};
}, [targetTime]);
return <div>剩余:{remaining}ms</div>;
}
我踩过的坑:有一次我忘记清理定时器,用户在一个页面来回切换了几十次,浏览器直接卡死。控制台全是"Can't perform a React state update on an unmounted component"的警告。
场景 4:WebSocket 连接(订阅与取消订阅)
背景:实时聊天室,需要保持 WebSocket 连接。
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket(`ws://example.com/${roomId}`);
ws.onmessage = (event) => {
setMessages(prev => [...prev, JSON.parse(event.data)]);
};
ws.onopen = () => {
console.log('连接已建立');
};
return () => {
ws.close(); // 清理连接
};
}, [roomId]);
return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}
不清理的后果:切换聊天室后,旧房间的 WebSocket 还在接收消息,新房间会收到两个房间的消息。
五、性能优化技巧
1. 避免不必要的 effect 执行
// ❌ 对象作为依赖,每次都变
function Component({ config }) {
useEffect(() => {
// ...
}, [config]); // config 每次都是新引用
}
// ✅ 用 useMemo 保持引用稳定
function Parent() {
const config = useMemo(() => ({ api: '/api' }), []);
return <Child config={config} />;
}
2. 使用自定义 Hook 封装逻辑
// 这是我用得最多的自定义 Hook 之一
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
fetch(url).then(res => res.json()).then(data => {
if (!ignore) {
setData(data);
setLoading(false);
}
});
return () => { ignore = true; };
}, [url]);
return { data, loading };
}
// 使用
function MyComponent() {
const { data, loading } = useFetch('/api/data');
// ...
}
好处:
- 逻辑复用
- 代码整洁
- 易于测试
3. 使用 useEffectEvent 处理非响应式依赖(React 19 新特性)
// React 19 新 API,解决闭包陷阱
import { useEffect, useRef, useEffectEvent } from 'react';
function ChatRoom({ roomId }) {
const onMessage = useEffectEvent((message) => {
// 这个函数不会触发 effect 重新执行
console.log('收到消息:', message);
});
useEffect(() => {
const ws = new WebSocket(`ws://example.com/${roomId}`);
ws.onmessage = (event) => {
onMessage(event.data); // 调用不会触发重新执行
};
return () => ws.close();
}, [roomId]); // 只需要依赖 roomId
}
我的建议:如果你在用 React 19,可以用这个新 API 解决闭包陷阱问题。
六、最佳实践总结
| 场景 | 推荐方案 | 注意事项 |
|---|---|---|
| 数据获取 | useEffect + ignore 标志 | 处理竞态条件 |
| 事件监听 | useEffect + 清理函数 | 别忘了 removeEventListener |
| 定时器 | useEffect + clearInterval | 必须清理 |
| WebSocket | useEffect + close | 切换房间时清理 |
| 依赖数组 | 完整依赖 | 用 ESLint 检查 |
| 状态更新 | 函数式更新 | setCount(prev => prev + 1) |
核心原则(我每条都是用教训换来的)
- 依赖数组必须完整 - 用 ESLint 插件自动检查
- 及时清理副作用 - 尤其是定时器和订阅
- 避免在 effect 中创建对象/数组作为依赖 - 用 useMemo 保持引用稳定
- 异步函数不要直接作为 effect 回调 - 在 effect 内部定义 async 函数
- 状态更新依赖旧值时用函数式更新 -
setCount(prev => prev + 1)
我的个人建议
经过三年的 useEffect 使用经验,我有以下几点建议:
- 装上 ESLint 插件 - 它能帮你避免 90% 的依赖项错误
- 每个 effect 只做一件事 - 不要把多个不相关的逻辑塞进一个 effect
- 清理函数一定要写 - 尤其是定时器、事件监听、网络请求
- 遇到闭包陷阱用函数式更新 - 或者用 React 19 的 useEffectEvent
- 复杂逻辑封装成自定义 Hook - 代码会更清晰
七、工具推荐
1. ESLint 插件(必装)
npm install -D eslint-plugin-react-hooks
配置:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
我的体验:这个插件帮我避免了至少 30+ 个潜在 bug,强烈建议每个 React 项目都装上。
2. React DevTools
主要功能:
- 查看组件渲染次数
- 分析 effect 执行时机
- 调试 Hook 依赖
使用技巧:打开"Highlight Updates"功能,可以看到哪些组件在重渲染。
总结
写了两年的 useEffect,我有三点最深的体会:
- 依赖数组别偷懒 - 少写一个依赖项,bug 找你好几天
- 清理函数一定要写 - 尤其是定时器、事件监听、网络请求
- 复杂逻辑封装成 Hook - 代码会清晰很多
如果你刚开始学 useEffect,我的建议是:
- 先掌握基本用法,理解依赖数组和清理函数
- 装上 ESLint 插件,让它帮你检查错误
- 多写多练,踩几个坑就学会了
- 遇到问题先看官方文档
最后,别被各种"最佳实践"吓到。先写出能跑的代码,再优化——这是我从无数个项目中学到的真理。
参考资料
- React 官方文档 - useEffect: react.dev/reference/r…
- React 官方文档 - 同步 effect: react.dev/learn/synch…
- React 官方博客 - 深入 useEffect: overreacted.io/a-complete-…
- patterns.dev - React Hooks 模式:www.patterns.dev/react/hooks
觉得文章对你有帮助?
- 👍 点赞支持一下,让我更有动力创作
- ⭐ 收藏备用,下次遇到类似问题快速找到
- 📢 分享给团队伙伴,一起提升代码质量
- 💬 评论区聊聊:你在使用 useEffect 时遇到过哪些坑?
你的每一次互动,都是我继续创作的动力!
关于作者
前端开发 8 年,踩过无数坑,写过不少烂代码。现在在一家创业公司负责前端架构,日常和 React/Vue 打交道。
我的目标:分享最实用的前端技巧,帮助大家少踩坑,早点下班摸鱼🐟。
关注我,获取更多前端实战内容!