本文通过 4 个可交互的案例 + React 源码分析,彻底解释为什么 React 要求 Hooks 调用顺序必须一致。
前言
React 官方文档中有一条铁律:
Only call Hooks at the top level – Don't call Hooks inside loops, conditions, or nested functions. Instead, always use Hooks at the top level of your React function, before any early returns.
翻译过来就是:只在顶层调用 Hooks,不要在循环、条件或嵌套函数中调用。
但你有没有想过:
- 为什么 Hooks 必须在顶层调用?
- 如果不遵守会怎样?
- React 内部是怎么实现的?
今天我们通过 4 个案例,从崩溃到静默错乱,结合 React 源码,彻底揭秘 Hooks 规则的底层原因。
1. 核心原理:Hooks 链表机制
在深入案例之前,我们先理解一个关键概念:Hooks 在 Fiber 上以链表形式存储。
1.1 Fiber 与 Hooks 的关系
每个函数组件对应一个 Fiber 节点,Fiber 的 memoizedState 属性指向 Hooks 链表的头节点:
Fiber Node
│
└── memoizedState ──▶ Hook1 ──▶ Hook2 ──▶ Hook3 ──▶ null
│ │ │
▼ ▼ ▼
useState useEffect useState
(0) (Effect) ("Tom")
1.2 关键源码:updateWorkInProgressHook
当组件更新时,React 通过 updateWorkInProgressHook() 获取当前 Hook:
源码链接:react/packages/react-reconciler/src/ReactFiberHooks.js#L1001
function updateWorkInProgressHook(): Hook {
let nextCurrentHook: null | Hook;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
if (current !== null) {
nextCurrentHook = current.memoizedState; // 从链表头开始
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next; // 👈 按位置遍历链表
}
// ...
}
核心逻辑:React 通过 currentHook.next 按位置遍历链表,不检查 Hook 的类型。
这意味着:如果 Hooks 的调用顺序改变了,React 仍然会按照原来的链表位置去读取数据,导致数据错位!
2. 案例一:useState 和 useEffect 互换(崩溃)
我们先来看一个最极端的例子:不同类型的 Hooks 互换会直接导致 React 崩溃。
2.1 效果演示
在线体验:
点击 Toggle Flag 后,React 直接崩溃!
2.2 源码分析:为什么会崩溃?
崩溃原因在于 updateEffectImpl 函数:
源码链接:react/packages/react-reconciler/src/ReactFiberHooks.js#L2632
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = updateWorkInProgressHook();
const effect: Effect = hook.memoizedState; // 👈 期望是 Effect 对象
const inst = effect.inst; // 👈 访问 .inst
if (currentHook !== null) {
const prevEffect: Effect = currentHook.memoizedState;
const prevDeps = prevEffect.deps; // 👈 访问 .deps
// ...
}
}
两种 Hook 的 memoizedState 结构完全不同:
| Hook | memoizedState 类型 |
|---|---|
| useState | 直接值:0, "Tom", [1,2,3] |
| useEffect | Effect 对象:{ tag, create, deps, inst, next } |
当 useEffect 从位置 3(原来是 useState)读取 memoizedState 时:
- 拿到的是数字
0 - 访问
0.deps→undefined - 后续逻辑出错,React 崩溃!
3. 如果顺序变化,一定会崩溃吗?
看到这里你可能会问:是不是只要 Hooks 顺序变化就一定会崩溃?
答案是:不一定!
阅读源码我们可以发现,崩溃的根本原因是 update 阶段调用的函数不同,导致 memoizedState 的结构不兼容。
但如果两个 Hook 在 update 阶段调用相同的底层函数,它们就可以"兼容"地互换——虽然不会崩溃,但会出现数据错乱或执行时机错乱。
接下来我们看几个"不崩溃但出问题"的案例。
4. 案例二:两个 useState 互换(数据互换)
4.1 源码分析:为什么不会崩溃?
两个 useState 在 update 阶段都调用同一个函数 updateReducer(是的,useState 底层就是 useReducer):
源码链接:react/packages/react-reconciler/src/ReactFiberHooks.js#L1937
function updateState<S>(initialState): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, initialState); // 👈 调用 updateReducer
}
两个 useState 调用相同的函数,memoizedState 都是直接值,结构兼容,所以不会崩溃。
4.2 效果演示
在线体验:
4.3 现象分析
- 初始状态正常:name='Tom', age=18
- 点击 "Toggle Flag" → 值互换了!name=18, age='Tom'
- 点击 "Age + 1" → name 变成 19!
- 点击 "Name + !" → age 变成 "Tom!"!
4.4 原因解析
Toggle 前 (flag=false): Toggle 后 (flag=true):
┌─────────────────┐ ┌─────────────────┐
│ 链表位置 2 │ │ 链表位置 2 │
│ memoizedState=18│ ←── age │ memoizedState=18│ ←── name (错了!)
└─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ 链表位置 3 │ │ 链表位置 3 │
│ memoizedState= │ │ memoizedState= │
│ "Tom" │ ←── name │ "Tom" │ ←── age (错了!)
└─────────────────┘ └─────────────────┘
链表位置不变,但变量名对应错了!
5. 案例三:useEffect 和 useLayoutEffect 互换(执行时机错乱)
5.1 源码分析:为什么不会崩溃?
useEffect 和 useLayoutEffect 在 update 阶段都调用同一个函数 updateEffectImpl:
源码链接:react/packages/react-reconciler/src/ReactFiberHooks.js#L2696
function updateEffect(create, deps): void {
updateEffectImpl(PassiveEffect, HookPassive, create, deps); // 👈 调用 updateEffectImpl
}
function updateLayoutEffect(create, deps): void {
return updateEffectImpl(UpdateEffect, HookLayout, create, deps); // 👈 同样调用 updateEffectImpl
}
两者的区别仅在于传入的 hookFlags 不同(HookPassive vs HookLayout),这决定了 effect 的执行时机。
Effect 的类型由 tag 标识决定:
源码链接:react/packages/react-reconciler/src/ReactHookEffectTags.js#L17
export const Layout = /* */ 0b0100; // useLayoutEffect
export const Passive = /* */ 0b1000; // useEffect
5.2 效果演示
在线体验:
5.3 现象:cleanup 和 create 执行顺序混乱
正常顺序应该是:
layoutEffect cleanup → layoutEffect 执行 → effect cleanup → effect 执行
但 Toggle 后可能出现:
useEffect cleanup → useLayoutEffect 执行 → useLayoutEffect cleanup → ...
5.4 原因解析
- 位置 4 原来是
useEffect(tag=Passive),现在调用useLayoutEffect(tag=Layout) - 新的 tag 变了,但
inst(包含 cleanup 函数)被复用 - 在 Layout 阶段执行 cleanup 时,找到位置 4 的 effect(tag=Layout)
- 执行
inst.destroy,但这个 cleanup 其实是旧的 useEffect 的 cleanup!
为什么第二次点击"恢复正常"? 因为第一次执行 create 后,inst.destroy 被更新为正确的 cleanup 函数了。
6. 案例四:useState 和 useReducer 互换(静默错乱)
6.1 源码分析:为什么不会崩溃?
useState 和 useReducer 在 update 阶段都调用同一个函数 updateReducer:
源码链接:react/packages/react-reconciler/src/ReactFiberHooks.js#L1937
function updateState<S>(initialState): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, initialState); // 👈 直接调用 updateReducer
}
useState 本质上就是一个特殊的 useReducer,使用内置的 basicStateReducer。两者在 update 阶段调用相同的函数,所以不会因为类型不匹配而崩溃。
6.2 效果演示
在线体验:
6.3 现象:不崩溃但数据错乱!
- 点 "Add Item" 几次 → items = ['item-1', 'item-2']
- 点 "Toggle Flag" → count 变成数组!items 变成 0!
- 点 "Count + 1" → items 变成 1!
6.4 原因解析
虽然两者调用相同的函数,但 memoizedState 的值类型不同(数字 vs 数组),导致数据互换:
Toggle 前: Toggle 后:
位置2: memoizedState=['item-1'] 位置2: memoizedState=['item-1'] ← count 读取
位置3: memoizedState=0 位置3: memoizedState=0 ← items 读取
7. 其他 Hooks 说明
其他 Hooks 的 memoizedState 结构也各不相同:
| Hook | memoizedState 结构 |
|---|---|
| useState | 直接值:0, "Tom", [1,2,3] |
| useReducer | 直接值:与 useState 相同 |
| useEffect | Effect 对象:{ tag, create, deps, inst, next } |
| useLayoutEffect | Effect 对象:与 useEffect 相同,但 tag 不同 |
| useRef | Ref 对象:{ current: value } |
| useMemo | 元组:[computedValue, deps] |
| useCallback | 元组:[callback, deps] |
| useContext | 无 memoizedState(不存储在链表中) |
任意 Hooks 互换都可能出现问题,只是问题的表现形式不同:
- 类型不兼容:直接崩溃(如 useState ↔ useEffect)
- 类型兼容但语义不同:静默错乱(如 useState ↔ useReducer)
- 同类型但执行时机不同:执行顺序错乱(如 useEffect ↔ useLayoutEffect)
8. 总结
核心要点
-
Hooks 按链表位置匹配,不按类型匹配
React 通过currentHook.next遍历链表,完全依赖调用顺序。 -
不同 Hooks 的 memoizedState 结构不同
useState 存直接值,useEffect 存 Effect 对象,结构不兼容会导致崩溃。 -
违反规则可能导致三种后果:
- 崩溃:update 阶段调用不同函数(useState ↔ useEffect)
- 数据错乱:调用相同函数但值混淆(useState ↔ useReducer)
- 执行时机错乱:tag 不同导致阶段错位(useEffect ↔ useLayoutEffect)
最佳实践
-
永远在组件顶层调用 Hooks,不要在循环、条件或嵌套函数中调用。
-
使用 ESLint 插件:
eslint-plugin-react-hooksnpm install eslint-plugin-react-hooks --save-dev配置
.eslintrc:{ "plugins": ["react-hooks"], "rules": { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" } } -
如果需要条件性逻辑,把条件放在 Hook 内部:
// ❌ 错误 if (condition) { useEffect(() => { ... }, []); } // ✅ 正确 useEffect(() => { if (condition) { // ... } }, [condition]);
理解了 Hooks 的链表机制,你就能明白为什么 React 要强制这条规则——这不是设计上的任意限制,而是底层实现的必然要求。