如果 Hooks 顺序变了会怎样?4 个案例带你看清 React 的真实反应

374 阅读6分钟

本文通过 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 效果演示

useState 和 useEffect 互换

在线体验:

点击 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 结构完全不同

HookmemoizedState 类型
useState直接值:0, "Tom", [1,2,3]
useEffectEffect 对象:{ tag, create, deps, inst, next }

当 useEffect 从位置 3(原来是 useState)读取 memoizedState 时:

  1. 拿到的是数字 0
  2. 访问 0.depsundefined
  3. 后续逻辑出错,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 效果演示

两个 useState 交换顺序

在线体验:

4.3 现象分析

  1. 初始状态正常:name='Tom', age=18
  2. 点击 "Toggle Flag" → 值互换了!name=18, age='Tom'
  3. 点击 "Age + 1" → name 变成 19!
  4. 点击 "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 源码分析:为什么不会崩溃?

useEffectuseLayoutEffect 在 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 效果演示

useEffect 与 useLayoutEffect 交换

在线体验:

5.3 现象:cleanup 和 create 执行顺序混乱

正常顺序应该是:

layoutEffect cleanup → layoutEffect 执行 → effect cleanup → effect 执行

但 Toggle 后可能出现:

useEffect cleanup → useLayoutEffect 执行 → useLayoutEffect cleanup → ...

5.4 原因解析

  1. 位置 4 原来是 useEffect(tag=Passive),现在调用 useLayoutEffect(tag=Layout)
  2. 新的 tag 变了,但 inst(包含 cleanup 函数)被复用
  3. 在 Layout 阶段执行 cleanup 时,找到位置 4 的 effect(tag=Layout)
  4. 执行 inst.destroy,但这个 cleanup 其实是旧的 useEffect 的 cleanup

为什么第二次点击"恢复正常"? 因为第一次执行 create 后,inst.destroy 被更新为正确的 cleanup 函数了。

6. 案例四:useState 和 useReducer 互换(静默错乱)

6.1 源码分析:为什么不会崩溃?

useStateuseReducer 在 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 效果演示

useState 与 useReducer 交换

在线体验:

6.3 现象:不崩溃但数据错乱!

  1. 点 "Add Item" 几次 → items = ['item-1', 'item-2']
  2. 点 "Toggle Flag" → count 变成数组!items 变成 0!
  3. 点 "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 结构也各不相同:

HookmemoizedState 结构
useState直接值:0, "Tom", [1,2,3]
useReducer直接值:与 useState 相同
useEffectEffect 对象:{ tag, create, deps, inst, next }
useLayoutEffectEffect 对象:与 useEffect 相同,但 tag 不同
useRefRef 对象:{ current: value }
useMemo元组:[computedValue, deps]
useCallback元组:[callback, deps]
useContext无 memoizedState(不存储在链表中)

任意 Hooks 互换都可能出现问题,只是问题的表现形式不同:

  • 类型不兼容:直接崩溃(如 useState ↔ useEffect)
  • 类型兼容但语义不同:静默错乱(如 useState ↔ useReducer)
  • 同类型但执行时机不同:执行顺序错乱(如 useEffect ↔ useLayoutEffect)

8. 总结

核心要点

  1. Hooks 按链表位置匹配,不按类型匹配
    React 通过 currentHook.next 遍历链表,完全依赖调用顺序。

  2. 不同 Hooks 的 memoizedState 结构不同
    useState 存直接值,useEffect 存 Effect 对象,结构不兼容会导致崩溃。

  3. 违反规则可能导致三种后果

    • 崩溃:update 阶段调用不同函数(useState ↔ useEffect)
    • 数据错乱:调用相同函数但值混淆(useState ↔ useReducer)
    • 执行时机错乱:tag 不同导致阶段错位(useEffect ↔ useLayoutEffect)

最佳实践

  1. 永远在组件顶层调用 Hooks,不要在循环、条件或嵌套函数中调用。

  2. 使用 ESLint 插件eslint-plugin-react-hooks

    npm install eslint-plugin-react-hooks --save-dev
    

    配置 .eslintrc

    {
      "plugins": ["react-hooks"],
      "rules": {
        "react-hooks/rules-of-hooks": "error",
        "react-hooks/exhaustive-deps": "warn"
      }
    }
    
  3. 如果需要条件性逻辑,把条件放在 Hook 内部

    // ❌ 错误
    if (condition) {
      useEffect(() => { ... }, []);
    }
    
    // ✅ 正确
    useEffect(() => {
      if (condition) {
        // ...
      }
    }, [condition]);
    

理解了 Hooks 的链表机制,你就能明白为什么 React 要强制这条规则——这不是设计上的任意限制,而是底层实现的必然要求