你是否遇到过这样的问题:
- 明明把某个值放进了state里,但在effect里拿到的还是旧值
- 一个定时器反复启动、停止,代码看起来没毛病
- 埋点数据在测试环境正常,上线就乱套了
- 某个功能在本地好用,用户那边却数据混乱
如果是,那你很可能被 useEffect的隐藏陷阱 坑过了。这个陷阱对初级开发者来说很难察觉(因为代码“看起来”没问题),但对应用的稳定性却是致命的。本文将彻底讲清这个陷阱——不需要理解复杂的概念,只需记住几个核心规则。
真相:对useEffect的普遍误解
有句话每个React讲师都说过:“能不用useEffect就别用。”这句话听起来对,但害了很多开发者。大家把重点放在“少用”上,却完全无视了更重要的问题:你无法避免useEffect。
何为副作用?任何超出React纯渲染流程的操作都算:
- 🌐 请求API数据
- 📨 订阅消息队列
- ⏰ 启动定时器
- 🔄 同步非React系统(如第三方库、原生JS)
- 📊 发送埋点数据
除了useEffect,没有其他Hook能做这些事。useState、useMemo、refs甚至signal都不行。
所以问题不是“要不要用”,而是——你必须用,但必须用对。一旦用错,应用就会以各种诡异的方式崩溃。关于如何在函数式组件中正确管理副作用,一直是开发者深入讨论的话题。
Effect中的值为何会“卡住”不更新?
让我们从一个常见代码开始,很多人写过类似的:
useEffect(() => {
const id = setInterval(() => {
trackEvent(“user_activity”);
setActivityCount(c => c + 1);
}, 3000);
return () => clearInterval(id);
}, []);
这段代码想每3秒记录一次用户活动并计数。但现实是,它永远拿到的是同一个数字。
假设activityCount初始值是0。那么无论用户在你的应用里待多久,每3秒打出来的数字都是0。
为什么会这样?一个比喻
想象你有一台时间冻结机。你第一次按下按钮时,它拍了一张世界的“快照”。现在,真实世界继续运转:你赚了钱、长了岁、改了名。但这台时间冻结机还在用旧快照。
useEffect的依赖数组就是这样的“时间冻结机”。
当你的effect第一次运行时,React会“冻结”那一刻的所有值:trackEvent函数、activityCount状态、所有props。然后effect里的代码就一直在用这些冻结的值。
useEffect(() => {
const id = setInterval(() => {
// 这里的activityCount永远是冻结的初始值
console.log(activityCount); // 永远输出0
}, 3000);
}, []); // ← 空依赖是问题根源
为什么会这样设计?
这是React的设计决策。effect被设计成“声明式的副作用”,而不是“命令式的命令”。简单说:你不是在说“每3秒运行一段代码”,而是在说“如果这些依赖项变了,我需要重新同步这个副作用”。
但如果你告诉React“这个effect的依赖是空的”([]),React就认为这个副作用永远不需要重新同步,于是给你一份永久冻结的快照。然后bug就来了。
为什么必须写依赖数组?反面教材
你可能想:那把变化的值加到依赖数组里,问题不就解决了?
useEffect(() => {
const id = setInterval(() => {
trackEvent(“user_activity”);
setActivityCount(c => c + 1);
}, 3000);
return () => clearInterval(id);
}, [trackEvent, activityCount]); // ← 加上了所有使用过的值
理论完美。但实际会发生什么?新问题:无限循环。
每当组件re-render时,trackEvent函数都会被重新创建。依赖数组一旦检测到trackEvent变了,effect就会重新运行(清除旧定时器→创建新定时器)。这个过程会反复发生,你的定时器永远无法正常工作。
这是从一个坑跳到了另一个坑。
React的硬规则
有一条规则写在React官方文档里,不是建议,而是规则:
任何在effect里使用过的值,都必须写在依赖数组里。
这包括:props、state、调用的函数、useMemo得到的值——任何在render时会变化的东西。
如果你用了某个值但没写在依赖数组里,你就是在制造bug。 问题是:满足这个规则很容易导致无限循环。那到底该怎么办?
两种主流解决方案
方法1:将逻辑直接写入Effect内部
最简单的办法——直接在effect里写逻辑,避免引用外部函数:
useEffect(() => {
const id = setInterval(() => {
// 直接在这里写逻辑
console.log(“activity”, activityCount);
setActivityCount(c => c + 1);
}, 3000);
return () => clearInterval(id);
}, [activityCount]); // 只需依赖用到的状态
优点:清晰明了,没有歧义。
缺点:activityCount变化会导致整个effect(及定时器)重启,在某些场景可能浪费性能。
方法2:使用useCallback包裹函数
如果你的函数需要在effect外部复用,可以这样做:
const trackEvent = useCallback(() => {
console.log(“activity”, activityCount);
}, [activityCount]); // trackEvent会记住最新的activityCount
useEffect(() => {
const id = setInterval(trackEvent, 3000);
return () => clearInterval(id);
}, [trackEvent]); // 只依赖trackEvent
useCallback的意思是:只有当函数内部用到的值变化时,这个函数的“身份”才会改变。
优点:effect依赖清晰,结构更优。
缺点:多一层抽象,新手易混淆,且容易用错依赖。
诚恳建议:大多数时候,方法1更简单、更易理解、更不易出错。
危险行为:禁用ESLint警告
你肯定见过这样的代码:
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
onInit();
}, []);
这行注释的意思是:“请忽视ESLint警告,我知道我在干什么。”实际上,这是最危险的做法。
为什么说“禁用等于埋雷”?
ESLint的这条规则是React官方维护的,能自动检测:
- 依赖缺失(→ 闭包陷阱)
- 多余依赖(→ 性能浪费)
- 依赖不完整(→ 数据不同步)
一旦禁用,这些检查全部失效。
真实伤害案例
想象一个电商商品详情页的埋点Effect:
// ❌ 常见的错误写法
useEffect(() => {
const productId = product.id;
const userId = user.id;
sendAnalytics({
event: ‘view_product’,
productId,
userId,
timestamp: Date.now()
});
}, []); // ← 空依赖
问题:用户从商品A切换到商品B时,组件未卸载,只是product.id变了,但effect不会重新运行。结果系统记录的埋点数据全是第一个商品的。这种错误数据若未被及时发现,会影响后续基于数据的商业决策,例如库存和营销策略,凸显了正确数据同步的重要性。
React 19的新方案:useEffectEvent
React 19.2新增了useEffectEvent Hook,专门解决“在mount时运行一次,又要用最新数据”的难题。
const onInitEvent = useEffectEvent(() => {
// 这里能访问到最新的props和state
initializeData();
});
useEffect(() => {
onInitEvent();
}, []); // ← 空依赖数组是安全的
它是什么?
用个比喻:useEffectEvent创建了一个“通道”。
- 通道本身稳定(不会触发effect重运行)
- 但通过通道传递的信息总是最新的
所以,你可以安全地在effect中使用它,同时访问最新状态。
如果你使用React 19+,这应是你的默认选择。
思维转变:Effect是同步声明,而非生命周期
很多人把useEffect看成了“生命周期钩子”(如componentDidMount)。这个思路源于class component。
但function component完全不同。
Effect的真实意义不是“在某个时刻运行代码”,而是 “声明某个同步关系”。
- ❌ 错误思维:“当userId变化时,获取新数据并更新”(命令式)
- ✅ 正确思维:“保持UI中的userList数据和userId的同步”(声明式)
从React 18开始,理解Effect的最好方式是:Effect是一个“同步声明”,不是“命令式的操作”。
编写Effect前必问的6个问题
- 我真的需要Effect吗? 很多数据变换可直接在渲染中计算。
- 我用到的值都写在依赖里了吗? 相信并遵循ESLint的警告。
- 我是不是在制造数据过期的Bug? 如果Effect读取了可变状态,它就必须在依赖中。
- 这个函数非得定义在Effect外面吗? 多数情况下,直接在Effect内部写逻辑更简单。
- 我有没有禁用ESLint? 如果有,请立即停止。修复代码,而非压制警告。
- 这是否是“仅在mount时运行”的场景? 如果是,且使用React 19+,请用
useEffectEvent。
Effect工作流程示意图
┌─────────────────────────────────┐
│ 1. 组件首次渲染 (mount) │
│ • 取props/state快照 │
│ • 执行effect函数体 │
│ • “记住”依赖数组 │
└────────────┬────────────────────┘
↓
┌─────────────────────────────────┐
│ 2. 组件重新渲染 │
│ • 检查依赖数组是否变化 │
│ ├─ 无变化 → 跳过 │
│ └─ 有变化 → 清理并重新执行│
└────────────┬────────────────────┘
↓
┌─────────────────────────────────┐
│ 3. 组件卸载 (unmount) │
│ • 执行清理函数 │
└─────────────────────────────────┘
关键点:Effect看到的是渲染时刻的快照值。如果依赖未变,Effect不运行,其闭包就保持“冻结”。
三条必须记住的铁律
- 用到的值必须加入依赖数组:这是规则,而非建议。否则就是在制造定时炸弹。
- Effect读取的是“快照”值:要获取最新值,必须让依赖触发Effect重新运行。
- 不要禁用ESLint规则:如果需要禁用,说明你的代码有问题。应修复代码,或使用
useEffectEvent。
总结
✅ 好的Effect:依赖清晰完整、包含必要清理、无ESLint警告、逻辑与依赖匹配。
❌ 坏的Effect:空依赖却引用可变值、缺少清理函数、通过注释禁用ESLint、依赖想象而非规则。
最终建议:仔细阅读一遍React官方的useEffect文档。下次编写Effect时,在心中过一遍那6个问题。做到这些,你的React代码质量和应用稳定性将显著提升。