陌上人如玉
公子世无双

12.1 为什么 Hooks 不能写在条件里

12.1 为什么 Hooks 不能写在条件里

一句话:因为 React 用「链表 + 调用顺序」把每个 Hook 的状态存起来——Hook 的身份由「第几次调用」决定,一旦顺序或数量变化,状态就会「张冠李戴」。

一、结论先行

问题:为什么 Hooks 不能写在 if / for / 嵌套函数里?

一句话回答:
  因为 React 用「调用顺序」来匹配 Hook 与其状态(链表 + 游标),
  顺序或数量一变,后面的 Hook 就会「读到别人的状态」。

更精确的说法:
  Hook 的「身份」不是「变量名」,也不是「位置」,
  而是「在本组件本次渲染中,第几个被调用的 Hook」。

二、链表结构与调用顺序

React 为每个组件维护一个 Hook 链表(挂在 fiber 的 memoizedState 上):

fiber.memoizedState
   │
   ▼
┌──────────┐   next   ┌──────────┐   next   ┌──────────┐   next
│ hook #0  │ ───────► │ hook #1  │ ───────► │ hook #2  │ ───────► null
│ count=0  │          │ name='a' │          │ ref      │
└──────────┘          └──────────┘          └──────────┘

渲染时 React 用一个【游标】从链表头开始:
  调用第 1 个 Hook → 用 hook #0
  调用第 2 个 Hook → 用 hook #1
  调用第 3 个 Hook → 用 hook #2
  ……
渲染结束后把游标对应的链表保存回去(首次渲染时「建立链表」)。
// React 内部逻辑(简化伪代码)
let currentHook = null;      // 游标:当前处理到哪个 hook 节点
let workInProgressHook = null;

function renderWithHooks(Component, props) {
  currentHook = fiber.memoizedState;      // ⭐ 每次渲染从「链表头」开始
  isFirstRender = currentHook === null;

  const result = Component(props);        // 执行组件函数(Hook 按顺序被调用)

  fiber.memoizedState = workInProgressHook ?? fiber.memoizedState;   // 保存
  return result;
}

function useState(initial) {
  let hook;

  if (isFirstRender) {
    // 首次:创建节点并【接到链表尾部】
    hook = { memoizedState: typeof initial === 'function' ? initial() : initial, queue: [] };
    if (currentHook === null) fiber.memoizedState = hook;     // 链表头
    else currentHook.next = hook;                             // 接尾
    currentHook = hook;
  } else {
    // 更新:按【顺序】读已有节点
    hook = currentHook;                                       // ⚠️ 靠「顺序」对应
    currentHook = hook.next;                                  // 游标后移
    // 消费更新队列…
  }

  const setState = (action) => { /* 入队 + 触发重渲染 */ };

  return [hook.memoizedState, setState];
}
⭐ 关键点:
① 链表是「按调用顺序」串起来的
② 每次渲染都「从头开始」,靠「第几次调用」定位节点
③ **没有任何机制能通过「变量名」或「代码位置」来定位**
   (因为运行时拿不到变量名)

三、破坏顺序的三种后果

情况一:条件包裹(导致状态错位)

function Component({ isLoggedIn }: { isLoggedIn: boolean }) {
  if (isLoggedIn) {
    const [user, setUser] = useState(null);      // ⚠️ 有时调用、有时不调用
  }
  const [theme, setTheme] = useState('light');
  // ...
}
第一次渲染(isLoggedIn = false):
  hooks = [ theme('light') ]                     → hook #0 是 theme

第二次渲染(isLoggedIn = true):
  hooks = [ user(null), theme('light') ]         → hook #0 是 user、hook #1 是 theme
  ↑ React 按顺序读:第 1 个 Hook(user)读到 hook #0 —— 但 hook #0 里存的是 theme!
  → 【状态错位】:user 拿到了 'light'
  → 后续可能报错或行为诡异

情况二:数量不一致(直接报错)

function Component({ show }) {
  const [a] = useState(0);
  if (show) {
    const [b] = useState(1);      // ⚠️ 只有 show 为 true 时才调用
  }
  return <div>{a}</div>;
}
从 show = true 切到 false:
  React 上一轮记录了 2 个 Hook,本轮只调用了 1 个
  → 报错:Rendered fewer hooks than expected.
     This may be caused by an accidental early return statement.

从 show = false 切到 true:
  → 报错:Rendered more hooks than during the previous render.

情况三:提前 return(Hook 数量随数据变化)

function Component({ data }) {
  const [loading, setLoading] = useState(true);

  if (!data) return <Empty />;              // ⚠️ 提前 return

  const [tab, setTab] = useState('info');   // ⚠️ 有时调用、有时不调用
  return <div>{tab}</div>;
}
data 为 null 时:只调用 1 个 Hook
data 存在时:调用 2 个 Hook
→ 两次渲染的 Hook 数量不同 → 报错或状态错乱

✅ 正确写法:所有 Hook 写在最前面,条件 return 放在所有 Hook 之后

四、代码演示(亲身验证错乱)

// ⚠️ 故意写出错误代码,观察行为
function Demo({ flag }: { flag: boolean }) {
  const [a, setA] = useState('A');

  if (flag) {
    const [b, setB] = useState('B');       // 条件 Hook(错误示范)
    console.log('flag=true, b =', b);       // 第一次可能是 'B',切换后可能变成 'A'
  }

  const [c, setC] = useState('C');
  console.log({ a, c });

  return <div>{a} / {c}</div>;
}
观察现象(开发环境):
· 首次 flag=true → a='A'、b='B'、c='C'(看起来正常)
· 然后 flag 变 false → React 只调用 2 个 Hook
  → 报错:Rendered fewer hooks than expected
· 反过来(先 false 再 true)→ Rendered more hooks than during the previous render

⭐ 即使「看起来能跑」,也可能出现「b 拿到了 a 的值」这类静默错乱
   (取决于具体分支与 Hook 类型)。

五、为什么不用「变量名」或「位置」对应?

① 为什么不用「变量名」?
   运行时拿不到调用方的变量名——
   `const [count, setCount] = useState(0)` 编译后只是「一次函数调用」,
   `count` 这个名字在运行时【不存在】。

② 为什么不用「代码位置」(行号)?
   因为「位置」在编译与压缩后会变,而且「同一个 Hook 可能出现在循环里」
   (一次渲染中可能被执行多次),位置无法唯一标识。

③ 所以只能用「调用顺序」——
   它简单、确定、且能覆盖「循环里多次调用同一个 Hook」的情况
   (虽然是违规用法,但机制上能「按顺序」对应)。

✅ 这个设计带来的「代价」就是那条规则:
   每次渲染必须以【相同顺序、相同次数】调用所有 Hook。

六、正确写法的四个场景

场景一:条件逻辑移到 Hook 之后

// ❌
if (isLoggedIn) {
  const [user, setUser] = useState(null);
}

// ✅ 无条件调用,把「条件」用于「渲染结果」
const [user, setUser] = useState(null);
const displayName = isLoggedIn ? user?.name : '访客';

场景二:条件放进 Hook 内部

// ❌ 有条件地调用自定义 Hook
function Comp({ shouldTrack }) {
  if (shouldTrack) {
    const pos = useMousePosition();      // ⚠️
  }
}

// ✅ 无条件调用,条件放 Hook 内部
function useMousePosition(enabled: boolean) {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    if (!enabled) return;                 // ⭐ 条件在内部
    const onMove = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, [enabled]);

  return pos;
}

function Comp2({ shouldTrack }) {
  const pos = useMousePosition(shouldTrack);   // ✅ 无条件调用
}

场景三:提前 return 移到 Hook 之后

// ❌
function Comp({ data }) {
  const [a] = useState(0);
  if (!data) return null;
  const [b] = useState(1);            // ⚠️
}

// ✅
function Comp({ data }) {
  const [a] = useState(0);            // ⭐ 所有 Hook 在前
  const [b] = useState(1);

  if (!data) return null;             // ⭐ 条件 return 在所有 Hook 之后
  return <div>{a}{b}</div>;
}

场景四:「动态数量」用「一个 Hook 管数组/Map」

// ❌ 循环调用 Hook
function Comp({ count }) {
  const refs = [];
  for (let i = 0; i < count; i++) {
    refs.push(useRef(null));           // ⚠️ 数量会变
  }
}

// ✅ 用一个 Hook 承载「动态集合」
function Comp2({ ids }: { ids: string[] }) {
  const refMap = useRef(new Map<string, HTMLElement>());

  const setRef = useCallback((id: string) => (el: HTMLElement | null) => {
    if (el) refMap.current.set(id, el);
    else refMap.current.delete(id);
  }, []);

  return (
    <>
      {ids.map((id) => <div key={id} ref={setRef(id)} />)}
    </>
  );
}
违规写法 正确写法
if (cond) useState() 无条件调用 + 条件用于渲染结果
items.map(() => useRef()) 一个 useRef(new Map())
if (x) return null; useState() 所有 Hook 在前,return 在其后
cond ? useA() : useB() 两个都调用(或把条件放进 Hook 内部)
try { useState() } catch {} 不要在 try/catch 里调用 Hook
在事件处理/普通函数里调用 只在组件体或自定义 Hook 里调用

七、ESLint 与面试追问

// 用 eslint-plugin-react-hooks 自动拦截(见 10.5)
{
  "rules": {
    "react-hooks/rules-of-hooks": "error",       // ⭐ 检查调用位置(必须有)
    "react-hooks/exhaustive-deps": "warn"        // 检查依赖数组
  }
}
Q: 「顺序」为什么比「key」更好?
A: 因为「顺序」是「天然唯一且稳定」的(每次渲染都从头开始按顺序读),
   不需要开发者「给每个 Hook 取唯一标识」;
   而 key 需要「稳定唯一」,用在 Hook 上会让写法极其繁琐。
   React 的选择是「用顺序换简洁」,代价是「必须保证顺序稳定」。

Q: 自定义 Hook 之间会互相影响吗?
A: 不会「互相影响」,但它们的 Hook 会「依次插入宿主组件的 Hook 链表」。
   所以「调用自定义 Hook 的顺序」也必须稳定
   (不能有条件地调用某个自定义 Hook)。

Q: 为什么 React 不在「开发环境自动修复」顺序问题?
A: 因为「顺序」本身就是「语义的一部分」——
   如果 React 帮你去猜「这个 Hook 应该对应哪个节点」,
   那「状态与代码的对应关系」就变得不可预测。
   所以它选择「报错并让你修」而不是「猜测」。

Q: 循环里调用 Hook 一定崩吗?
A: 不一定「立刻崩」,但必然「状态错乱」——
   因为「第二次渲染时循环次数变了」→ 顺序对应关系变了。
   所以规则上禁止,lint 也会报错。

Q: Hooks 状态存在哪里?
A: 存在 fiber 节点的 `memoizedState` 上(一个链表),
   而不是「组件函数的闭包」或「全局变量」。
   (可以理解为「每个组件实例有自己的一条 Hook 链表」。)

八、面试延伸

  1. 「为什么 Hooks 不能写在条件里?」
    因为 React 用「调用顺序」来匹配 Hook 与其状态——每个组件的 Hook 状态是一条链表(挂在 fiber.memoizedState 上),渲染时用一个游标从链表头开始,第几个调用就对应链表的第几个节点。如果某个 Hook「有时调用、有时不调用」,那么「顺序」就变了 → 后面的 Hook 会读到别人的状态(状态错乱),或者数量不一致导致报错(Rendered fewer/more hooks than expected)。

  2. 「React 怎么知道当前是哪个 Hook 的?」
    靠「游标 + 顺序」:每次渲染开始时,游标指向该组件 Hook 链表的头部;每调用一个 Hook,就读取当前节点并把游标后移一位;渲染结束后把更新后的链表保存回去(首次渲染时则「建立链表」)。此外 React 内部维护一个「当前正在渲染的 fiber」变量,用来确定「这些 Hook 属于哪个组件」——这也是「普通函数里调用 Hook 会报 Invalid hook call」的原因。

  3. 「用 index 或 key 来标识 Hook 不行吗?」
    不行(或不划算):① 变量名——运行时拿不到(编译后就没了);② 代码位置——压缩后会变,且「循环里的 Hook」无法用位置唯一标识;③ 显式 key——技术上可行,但会让每个 Hook 都要「取一个稳定唯一的名字」(写法极其繁琐,且更容易写错)。所以 React 选择了「用调用顺序」——简洁、确定、天然覆盖循环场景,代价是「必须保证顺序稳定」。

  4. 「循环里调用 Hook 会怎样?」
    会「状态错乱」(不一定立刻崩)——因为「第二次渲染时循环次数可能变了」,那么「第 N 次循环调用」对应的链表节点就变了:① 循环次数变少 → 报 Rendered fewer hooks than expected;② 循环次数变多 → 报 Rendered more hooks than during the previous render;③ 次数相同但语义变了(如数组顺序变了)→ 静默错乱(每个 Hook 拿到的是「另一个元素」的状态)。正确做法是「用一个 Hook 承载动态集合」(如 useRef(new Map()))。

  5. 「有 if (cond) return null 在 Hook 之前会怎样?」
    会让「Hook 的数量随 cond 变化」:cond 为真时提前 return → 后面的 Hook 不被调用;cond 为假时才调用。于是两次渲染的 Hook 数量不同 → 报错或状态错乱。正确写法是「把所有 Hook 写在组件函数最前面,条件 return 放在所有 Hook 之后」——这样「Hook 的调用顺序与数量」在所有分支下都一致。

  6. 「Invalid hook call 报错有哪些原因?」
    React 的错误提示列了三种:① 违反 Hook 规则(在条件/循环/普通函数里调用);② React 与 ReactDOM 版本不匹配(升级时只升了一个);③ 应用里存在多份 React 副本(典型原因是某个库把 react 写进了 dependencies 而不是 peerDependencies,导致装了两份)。排查方式是 npm ls react 看是否有重复版本;前两种则用 eslint-plugin-react-hooks 提前拦住。

  7. 「自定义 Hook 的调用也有这个限制吗?」
    有。自定义 Hook 只是「把一组 Hook 调用封装起来」——它对宿主组件来说,就是「按顺序插入的一段 Hook」。所以:① 自定义 Hook 内部同样不能有条件/循环;② 调用多个自定义 Hook 的顺序也必须稳定(不能有条件地调用某个自定义 Hook)。抽 Hook 并不能解决「条件调用」的问题——只是把「违规点」搬进了 Hook 内部。

  8. 「为什么说『这个设计是权衡的结果』?」
    因为「用顺序标识」是「简洁性」与「约束」之间的权衡:① 好处——写法极简(不需要给 Hook 取唯一标识)、天然支持「同组件多次调用同一个 Hook」、实现高效(链表 + 游标,O(1) 定位);② 代价——开发者必须遵守「顺序稳定」的规则,一旦违反就会「状态错乱」且难以察觉(有时只是「值不对」而不是「报错」)。所以 React 提供了 eslint-plugin-react-hooks 来把这个「隐式约束」变成「显式报错」——这是规则与工具配套的典型例子。

一句话速记

Hooks 的状态存在「fiber 的 Hook 链表」上,React 用「调用顺序 + 游标」定位每个 Hook 的状态——Hook 的身份是「第几次调用」,不是变量名也不是位置;因此必须在每次渲染中以相同顺序、相同次数调用所有 Hook:不能写在条件/循环/嵌套函数/try-catch 里,不能在所有 Hook 之前提前 return;违反的后果是「状态错乱」或报错「Rendered fewer/more hooks than expected」;正确写法是「条件逻辑放到 Hook 之后或 Hook 内部、动态数量用一个 Hook 管 Map/数组、提前 return 移到所有 Hook 之后」;必须开 eslint-plugin-react-hooks(rules-of-hooks 设为 error)把这个隐式约束变成显式报错;Invalid hook call 还要排查「多份 React 副本」(npm ls react)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 12.1 为什么 Hooks 不能写在条件里

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫