一个”合理”的错误
几乎所有用过 React Hooks 的开发者,都见过这个错误:
React Hook "useState" is called conditionally. React Hooks must be calledin the exact same order in every component render.但你有没有想过——为什么?
React 完全可以在运行时动态判断哪些 hook 需要执行,不是吗?为什么非要”每次渲染顺序一致”这种听起来很脆弱的约定?
这篇文章会从 React 内部的实现机制出发,告诉你这个”规则”到底是怎么来的,以及为什么它不是一个规则,而是一个必然结果。
Hooks 不在 React 里,而在 fiber 上
要理解 Hooks 的限制,首先要理解 React 中的一个核心数据结构——fiber 节点。
每一个 React 组件实例,在内存里都对应一个 fiber 节点。这个节点保存了组件需要的一切信息:
// 简化后的 fiber 节点const fiber = { tag: FunctionComponent, // 组件类型 memoizedState: null, // <-- hooks 链表挂在这里 updateQueue: null, // 待处理更新 stateNode: null, // DOM 节点引用 // ... 其他属性};关键就在 memoizedState 上。当你第一次调用一个 hook 时,React 不会把它”存到组件里”,而是挂到这个 fiber 节点的链表上。
链表的诞生
来看一个简单的组件:
function App() { const [count, setCount] = useState(0); const [name, setName] = useState("hello"); const ref = useRef(null);
useEffect(() => { document.title = `${count} - ${name}`; });
return <div>{count}</div>;}第一次渲染时,React 内部发生的事情是这样的:
- 调用
useState(0)— 创建一个 hook 节点{ state: 0, next: null },赋值给fiber.memoizedState - 调用
useState('hello')— 创建第二个 hook 节点,挂到上一个的next上 - 调用
useRef(null)— 创建第三个 hook 节点,继续追加到链表末尾 - 调用
useEffect(cb)— 创建第四个 hook 节点
最终得到的链表:
fiber.memoizedState │ ▼┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐│ useState │ │ useState │ │ useRef │ │ useEffect ││ state: 0 │ ──► │ state:hello │ ──► │ current:null│ ──► │ hook:cb ││ next: ●─────┤ │ next: ●─────┤ │ next: ●─────┤ │ next: null │└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ index=0 index=1 index=2 index=3每个 hook 节点内部还有一个自己的 queue 结构(用于处理多个 setState),但这里你只需要记住一条规则:
Hooks 按调用顺序,串成了一个单向链表。顺序即身份。
重新渲染时发生了什么?
当组件因为 setCount 触发重新渲染,React 再次执行 App()。这一次,React 不会创建新的 hook 节点,而是遍历已有的链表:
// 伪代码:React 内部处理 hooks 的逻辑function updateWorkInProgressHook() { let hook;
if (currentlyRenderingFiber.memoizedState === null) { // 首次渲染:创建新节点 hook = createHook(); } else { // 更新阶段:从链表读取下一个节点 hook = nextCurrentHook; nextCurrentHook = hook.next; }
return hook;}关键在这里:
- 首次渲染:调用
useState(0)→ 创建节点 0,调用useState('hello')→ 创建节点 1 … - 更新渲染:调用
useState(0)→ 从链表取节点 0(count),调用useState('hello')→ 从链表取节点 1(name)…
React 不通过名字来识别 hook,它只通过调用顺序(index)来匹配。
条件语句如何破坏一切
现在想象你在组件里写了条件判断:
function App() { const [count, setCount] = useState(0);
if (count === 0) { const [name, setName] = useState("hello"); // 条件调用 }
const ref = useRef(null);
return <div>{count}</div>;}首次渲染(count = 0)
链表构建正常:
index 0: useState(0) [count]index 1: useState('hello') [name]index 2: useRef(null) [ref]更新渲染(count 变为 1)
用户点击按钮,count 变为 1,组件重新渲染。这一次:
首次调用:useState(0) → 匹配链表的 index 0 [count] ✅ 正确条件为 false,跳过 useState → index 1 [name] 被跳过useRef(null) → 匹配链表的 index 1 [name] ❌ 错误!ref 变量拿到了 name 的状态,useState('hello') 在链表里根本没被执行。 后续的所有 hook 全部错位。
更致命的是,如果条件语句改变了 hook 的总数:
- 首次渲染:3 个 hook
- 更新渲染:2 个 hook
React 遍历到链表末尾后发现还有没匹配上的节点,会直接报错:
Rendered fewer hooks than expected.反过来,条件导致 hook 变多也会报错:
Rendered more hooks than during the previous render.为什么不用别的方案?
你可能想问:为什么不用键值对(key-value)来存储 hook?这样不就不怕顺序变了吗?
从 API 设计的角度来看,确实可以用 useState('count', 0) 这种带 key 的方式。但这种方案有几个问题:
- 开发体验:每次调用 hook 都要手动指定 key,繁琐且容易冲突
- Tree-Shaking:React 团队希望 hooks 是普通函数调用,方便构建工具做死代码消除
- 实现复杂度:键值对存储意味着 hook 的查找从简单的数组索引变为哈希查找,更新 phase 的性能会下降
- 自定义 hooks:如果两个自定义 hook 内部都用了
useEffect,key 如何自动生成而不冲突?
最终的 trade-off 是:
按调用顺序匹配 → O(1) 性能、零模板代码、完美 tree-shaking → 代价是”不能条件调用”
这个选择非常 React——优先保证开发体验和运行时性能,然后用 lint 规则来约束开发者。
正确的做法
既然条件调用不被允许,那应该怎么做?
方案一:在组件层级做条件判断
把条件逻辑上提到组件级别:
function App() { const [count, setCount] = useState(0);
return ( <div> {count} {count === 0 && <NameInput />} </div> );}
function NameInput() { const [name, setName] = useState("hello"); return <input value={name} onChange={(e) => setName(e.target.value)} />;}拆分后,每个组件的 hook 调用顺序在各自的作用域内始终一致。
方案二:将默认值放到组件外
当你只是在某些条件下需要一个有状态的变量时:
function App() { const [count, setCount] = useState(0);
// ❌ 错误:条件调用 if (count > 0) { const [flag, setFlag] = useState(false); }
// ✅ 正确:始终调用,逻辑内移 const [flag, setFlag] = useState(false);
// 只在需要时使用 const flagValue = count > 0 ? flag : undefined;}方案三:使用 null 兜底
function App() { const [count, setCount] = useState(0); const [name, setName] = useState(count === 0 ? "default" : "other"); // setName 仍然会被调用,但值是条件控制的}ESLint 插件的原理
React 官方提供的 eslint-plugin-react-hooks 中的 rules-of-hooks 规则,可以在编译前就检测出条件调用。
它的检测逻辑不依赖 React 运行时,而是基于**抽象语法树(AST)**的分析:
// 简化版的检测逻辑function checkNode(node) { // 检查函数是否以 "use" 开头 if (isHookName(node.callee?.name)) { // 检查是否在条件语句内 if (isInsideConditional(node)) { reportError("Hook is called conditionally"); } // 检查是否在循环内 if (isInsideLoop(node)) { reportError("Hook is called in a loop"); } }}这套静态分析无法覆盖 100% 的场景(比如动态条件判断),但对于绝大多数常见的条件调用模式,它可以做到精准拦截。
总结
Hooks 不能使用条件控制语句,根本原因在于 React 选择了一种基于链表的顺序匹配方案来存储和读取 hook 的状态:
| 概念 | 直觉理解 | 技术实现 |
|---|---|---|
| 调用顺序 | 第几个 hook | 链表的 index |
| 状态存储 | 存在组件里 | 存在 fiber.memoizedState 链表上 |
| 状态匹配 | 通过名字找 | 通过位置找 |
这种设计带来了极佳的性能和简洁的 API,代价就是你必须遵守:
在所有渲染中,以完全相同的顺序调用完全相同的 hooks。
这看起来是一个”规则”,实际上它是一个数据结构的直接推论。理解了 fiber 链表之后,这个”规则”就不再是死记硬背的教条,而是自然而然的事情了。