先记住这个答案
React 为组件管理 Hook 状态时,需要在多次渲染之间关联对应的调用。普通 Hook 必须在组件或自定义 Hook 的顶层按一致顺序调用;条件、循环和某些提前返回会改变数量或顺序,使这种关联失效。应把条件移进 Hook 回调,或把条件部分拆成独立组件。React 的 use API 有允许条件与循环调用的特殊规则,不能把这个例外推广到 useState 或 useEffect。
- 普通 Hook 的数量与顺序应在各次渲染中一致
- 条件放在 Effect 内部,或交给独立子组件
- use API 的特殊规则不适用于普通 Hook
从提前返回理解顺序变化
假设组件先调用一个 useState,在 loading 为真时立即 return,而另一个 useEffect 写在 return 之后。第一次渲染可能只调用 state,加载完成后又调用 state 和 Effect。同一个组件经历两种调用结构,React 就可能报出 Hook 数量不匹配,而不是自动猜测哪份数据应该移动到哪里。
“顶层”不只是排版时没有缩进,还要求控制流不会让某个普通 Hook 被有条件地跳过。把 Hook 放进回调、事件或循环,即使某次运行看起来顺序没变,也违背了可稳定分析的调用约定。若循环渲染多个条目,应让每个条目成为独立组件,在自己的函数顶层调用 Hook。
只有启用时才订阅,应把条件放在哪里
假设面板只在 enabled 为真时监听窗口大小。保持 useEffect 顶层调用,在回调开头判断 enabled,未启用就不建立监听;依赖变化时 React 会先清理上一轮监听,再执行新的建立逻辑。条件因此影响外部订阅是否存在,却不改变组件的 Hook 结构。
另一种方式是条件渲染一个专门负责订阅的子组件。父组件决定它是否存在,子组件在挂载期间保持自己的固定调用顺序。两种方式要根据是否需要重置子组件状态选择;无论如何都需要使用同一个监听函数引用正确移除事件,不能只把语法改合法就忽略清理。
use 例外与静态检查的边界
React 提供的 use API 可以在条件和循环中调用,但仍需位于组件或 Hook 的调用上下文中,且不能简单放进 try/catch 消化其控制流。这是该 API 的专门规则,面试里最好点明版本和 API 名称,避免说成“所有名字以 use 开头的函数都绝对不能条件调用”。
eslint-plugin-react-hooks 可以发现大量违反规则的写法,但静态检查并不是代码已经满足所有语义要求的证明。它也不会替你决定状态应该属于哪个组件。遇到警告时应调整控制流或组件边界,不要通过改函数名、压制规则或动态拼接调用来绕开提示。
容易答错的地方
- 把条件调用改成条件执行自定义 Hook 就合法
- 自定义 Hook 内部仍然使用组件的 Hook 调用机制。如果父组件按条件调用这个自定义 Hook,依旧会改变实际的调用序列,封装名称不会改变这一点。
- 认为条件永远不变就应忽略规则
- 即使当前需求让条件看起来恒定,这种写法也难以维护和静态分析。更合适的是调整组件边界,让不同分支各自拥有稳定的生命周期,避免后续需求变化悄悄引入运行错误。
面试官还会怎么问?
为什么把带 Hook 的组件放在 map 里通常没问题?
map 返回的是多个组件元素,每个组件实例都有自己的 Hook 状态和身份。需要稳定的 key 来关联实例;这不同于在同一个函数组件的循环中直接调用数量不定的 Hook。
普通工具函数内部可以调用 useState 吗?
不能把普通工具函数当成组件渲染上下文。需要复用状态逻辑时可编写自定义 Hook,并在合法位置调用;纯工具函数则只处理显式参数和返回值,不依赖 React 的 Hook 调度。
报错 fewer hooks than expected 时先检查什么?
先检查新增的条件分支和提前 return,尤其是某个 Hook 之前是否开始跳过后续代码。然后检查条件调用的自定义 Hook;不要靠重新排序现有 Hook 随机试错。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。