React 要求 Hooks 必须遵守两条“铁律”:
- 只在最顶层调用,不在条件、循环或嵌套函数中调用。
- 只在 React 函数组件或自定义 Hook 中调用。
这两条规则并非 React 团队随意设定的编码风格,而是由 Hooks 的底层实现机制决定的。理解这一机制后,你不仅会记住规则,还能真正理解为什么不遵守就会出问题。
Hooks 的存储结构:链表节点与顺序索引
React 在每个函数组件对应的 Fiber 节点上维护了一条Hooks 链表。每次调用 useState、useEffect 等 Hook 时,React 都会创建一个 Hook 节点,并将其链接到链表的末尾。
一个 Fiber 节点上的 Hooks 链表结构示意:
Fiber 节点 (fiber)
├── 第一个 Hook 节点 (state: 'count')
│ └── 下一个
├── 第二个 Hook 节点 (state: 'name')
│ └── 下一个
└── 第三个 Hook 节点 (effect, 清理函数...)
在组件首次渲染(mount)时,React 按调用顺序逐个创建这些 Hook 节点并构建链表。后续重渲染(update)时,React 会从头遍历这条链表,按相同的顺序取出之前的 Hook 节点来获取上一次的值,并执行逻辑。
这里的关键在于:React 依赖调用顺序来关联前后两次渲染的 Hook。链表上的节点是按调用顺序排列的,没有任何显式的“名称”或“标识”来匹配。因此,如果某次渲染时 Hooks 的调用顺序发生了变化,就会导致链表的“对位”错乱。
错误示例:条件调用导致顺序错乱
假设你在条件语句中使用了 Hook:
function Form({ isLogin }) {
if (isLogin) {
// ❌ 仅在 isLogin 为 true 时调用
const [email, setEmail] = useState('');
}
const [password, setPassword] = useState('');
// ...
}
- 首次渲染(isLogin 为 true):链表顺序为
[email-Hook] → [password-Hook]。 - 重渲染(isLogin 变为 false):
email-Hook的调用被跳过,链表读取顺序变成[password-Hook]。React 会用上一次email-Hook的状态去匹配这次第一个调用(password-Hook),造成状态错乱:password的值变成了旧的email值。
这种错误会导致难以排查的 bug,比如状态错位、值在渲染间跳变、内存泄漏等。
为什么不能提取到普通函数调用
自定义 Hook 本质上是组合了原有 Hooks 的函数,但只要自定义 Hook 的调用发生在顶层,内部 Hooks 的顺序仍然是确定的。如果将 useState 等直接放在普通函数中,并在组件里条件调用这个普通函数,同样会破坏顺序。
// ❌ 普通函数内部用 Hook,然后在组件中条件调用
function useMyState() {
return useState(0);
}
function App() {
if (condition) {
const [value] = useMyState(); // 第一次渲染有,第二次没有 → 顺序错乱
}
}
这也是为什么 React 会通过 lint 规则(react-hooks/rules-of-hooks)强制 Hook 必须出现在函数组件或自定 Hook 的顶层,且其名称必须以 use 开头,方便静态检测。
底层源码层面的逻辑(简化版)
在 React 内部,每次调用 useState 大致会执行这样的逻辑(以伪代码呈现):
function useState(initialValue) {
const hook = mountWorkInProgressHook(); // 创建或获取当前 Hook 节点
if (isInitialRender) {
hook.memoizedState = initialValue;
}
// 返回状态和更新函数...
}
mountWorkInProgressHook 会维护一个指针 currentlyRenderingFiber 和一个链表尾指针 workInProgressHook。每次调用时,它把当前 Hook 追加到链表末尾,然后将指针移到下一个位置。在更新阶段,updateWorkInProgressHook 会从头遍历链表,利用调用顺序一一对应。一旦调用顺序不同,就会取出错误的 Hook 节点。
为什么 Hooks 不能放在条件中,但可以用 if-return 提前退出?
React 允许组件早期返回 null,但不影响 Hooks 的顺序,因为条件返回是在 Hooks 调用之后执行的:
function User({ user }) {
const [count, setCount] = useState(0); // 始终先调用
useEffect(() => { ... }); // 始终先调用
if (!user) return null; // 提前返回不影响 Hook 调用顺序
return <div>{user.name}</div>;
}
只要 Hooks 的调用路径在每次渲染时完全相同(不管数据如何变化),顺序就是稳定的。这正是“不要在循环或条件中调用 Hook”的本意。
实际开发中的避坑方法
- 开启 ESLint 插件
eslint-plugin-react-hooks,它会自动检测违反规则的代码。 - 把条件逻辑写在 Hook 内部,比如将
if判断放入useEffect的依赖变化内部或使用三元运算符决定 Hook 的初始值,而不是控制 Hook 是否被执行。 - 需要条件性副作用时,使用
useEffect的依赖数组或内部判断,而非条件渲染 Hook 本身。
// ✅ 正确:Hook 始终在顶层,条件逻辑内置于 effect 中
useEffect(() => {
if (isLogin) {
// 仅在 isLogin 为 true 时执行
}
}, [isLogin]);
理解 Hooks 的链表存储和顺序依赖后,你就会明白上述铁律实际上是为了保证状态在重渲染间的正确对应。它们不是主观的约束,而是 React 内部机制的必然要求。