用途
本技能用于在 React 代码中正确编写、审查和调试 useEffect。适用于任何涉及 Effect 的 React 代码:新写一个 Effect、审查包含 Effect 的 diff,或排查疑似由 Effect 引起的问题。
技能明确 Effect 的定位:Effect 用于让组件与外部系统保持同步,它不是生命周期钩子,不是编排工作流的场所,也不是状态机的驱动器。对于“这里到底该不该用 Effect”的判断(派生状态、事件逻辑、Effect 链、向父组件通知、一次性初始化),技能指向 react-principles;本技能只覆盖其未涉及的部分:在已确定需要 Effect 之后,Effect 体内的具体机制,以及官方文档中看似正确的写法在哪里会静默死锁。
写 Effect 前的三问门禁
- 能否在渲染期间计算出来?能则直接计算,不用 state、不用 Effect。
- 是否因用户做了某个操作而运行?是则用事件处理器。
- 是否因组件在屏幕上、且必须跟踪 React 之外的东西而运行?是则用 Effect。其余情况均属误用。
五条不变量
每个编写或批准的 Effect 都必须同时满足以下五条,且可机械检查:
- Effect 体不得设置位于自身依赖数组中的 state。 检查方法:把依赖数组与体内所有
setX(包括.then、await续体、定时器回调中的)求交集,交集必须为空。 - 一个 Effect 只做一件事(一个同步过程)。 若体内同时做两件事(如计时与网络请求、订阅与拉取),应拆分。
- 清理镜像 setup。 每个获取的资源恰有一个释放;不存在只有清理没有 setup 的 Effect。
- setup → cleanup → setup 与单次 setup 不可区分。 用 StrictMode 干跑验证;若第二次 setup 提前返回或工作无法完成,即为损坏。
- 每个非终态都有保证的出口。 对 UI 视为“忙碌”的每个状态,在成功与失败两条路径上各至少有一条通往终态的代码路径。
不变量 1 会产生最严重的 bug,且官方文档未明确写出,技能中给出了推导。
五类陷阱
陷阱 1:自取消 Effect。 最具破坏性的 useEffect bug,外观与文档模式完全一致。示例中 Effect 在体内调用 setPhase("verifying"),而 phase 在依赖数组中,于是 React 重渲染触发清理,清理把 cancelled 置真,随后响应被丢弃,setPhase("ready") 永远到不了,UI 永久卡死。cancelled 标志只有在依赖是来自外部的输入(query、roomId、url、userId)时才是正确的竞态守卫;当依赖是 Effect 自身写入的 state 时,“清理已运行”意味着“我自己推进了状态”,守卫会丢弃流程正在等待的响应。判断方法:清理触发时,是否保证有新的 Effect 运行重做这份工作?若否,忽略守卫在此处就是错的。修复:拆分状态机,使任何 Effect 都不写自己的依赖,每个阶段一个 Effect,各自在结束时交接给下一个。修正后的示例中,Effect A 只负责倒计时,其退出是交接;Effect B 只负责请求,其清理只会在请求自身结果改变阶段或组件卸载时触发。两个 Effect 仍在依赖中列出 phase,这是必需且正确的;变化在于两者都不在自己运行的异步续体仍挂起时写入 phase。
陷阱 2:用 didRun ref 作守卫。 if (startedRef.current) return 在 StrictMode 的 setup → cleanup → setup 循环中,第二次 setup 会命中提前返回,工作永远不发生(违反不变量 4);ref 守卫还会静默破坏依赖真正变化后的合法重跑。若 Effect 需要“只跑一次”,该需求属于依赖数组而非 ref;让 Effect 自己管理生命周期,由清理撤销首次运行。模块级 didInit 标志只保留给真正每次应用加载一次的工作(如 checkAuthToken()),绝不按组件使用。
陷阱 3:间隔器加依赖中的 state。 setInterval(() => setCount(count + 1), 1000) 配合依赖 [count],每次 tick 都会重置间隔器。改用更新函数 setCount(c => c + 1) 即可彻底移除该依赖。与陷阱 1 同形(体内写自己的依赖),但表现为间隔器反复重建而非死锁。凡下一个值由前一个值推导,就传更新函数。
陷阱 4:不响应地读取最新值。 Effect 体读取的每个响应式值都必须是依赖。若要在不重跑的前提下读取最新值,不要对 linter 撒谎,而应把该读取移入 Effect Event(useEffectEvent,自 React 19.2 起稳定):Effect Events 永不进入依赖数组。绝不要用 // eslint-disable-next-line react-hooks/exhaustive-deps 让警告消失;若依赖感觉不对,说明代码结构不对——把对象/函数移入 Effect、把非响应式常量提到组件外、使用更新函数,或使用 Effect Event。
陷阱 5:清理不镜像 setup。 清理在每次依赖变化的重新运行之前都会执行,不只是卸载时。没有对应 setup 逻辑的清理是坏味道;清理若拆除了超出自身 setup 所创建的东西(清共享状态、中止由另一个 Effect 拥有的 controller),会破坏下一次运行。
审计流程
对每个编写或审查的 Effect 执行:
- 列出依赖;列出体内可达的每个
setX(包括await续体、.then、setTimeout/setInterval回调、事件监听器中的)。 - 对两表求交集。非空则进入第 3 步,为空跳到第 5 步。
- 追踪自取消:写出 setup → 体内调用 setX → React 提交 → 清理运行 → 新 setup 运行;再问第 1 次运行启动的异步工作是否仍有完成路径,还是第 2 次运行提前返回而第 1 次结果被丢弃。若为后者,即陷阱 1,拆分该 Effect。
- 确认拆分:拆分后,对依赖的每个 setState 必须要么在体内/定时器回调中同步发生,要么在该次运行最后一个 await 之后发生。
- StrictMode 干跑:setup → cleanup → setup,可观察结果是否与单次 setup 相同?留意 ref 守卫、计数器与非幂等副作用。
- 检查每个 await 边界:若清理在此期间运行会怎样?两个分支都必须让 UI 处于用户可操作的状态。
- 枚举终态:对 UI 渲染为忙碌的每个状态(转圈、禁用、阻塞 onOpenChange 的模态框),找出成功与失败的退出路径。没有出口的忙碌状态就是等待慢网络的死锁。
- 检查锁定:若 UI 在某阶段禁用取消/关闭,该阶段必须有保证的出口,否则一次丢包就会把用户困住且无法恢复。
验证方式
类型检查与 lint 看不到这些 bug——陷阱 1 中的坏代码两者都能通过。Effect 修复只能通过端到端运行流程并观察终态来验证:驱动真实 UI(浏览器自动化或应用本身),并另行调用 API/外部系统确认究竟哪一侧失败。当症状是“卡在待定状态”时,先查客户端状态机再怀疑服务器——Effect 死锁的表现与挂起请求完全相同。