
一句话:因为 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 链表」。)
八、面试延伸
- 「为什么 Hooks 不能写在条件里?」
因为 React 用「调用顺序」来匹配 Hook 与其状态——每个组件的 Hook 状态是一条链表(挂在fiber.memoizedState上),渲染时用一个游标从链表头开始,第几个调用就对应链表的第几个节点。如果某个 Hook「有时调用、有时不调用」,那么「顺序」就变了 → 后面的 Hook 会读到别人的状态(状态错乱),或者数量不一致导致报错(Rendered fewer/more hooks than expected)。 -
「React 怎么知道当前是哪个 Hook 的?」
靠「游标 + 顺序」:每次渲染开始时,游标指向该组件 Hook 链表的头部;每调用一个 Hook,就读取当前节点并把游标后移一位;渲染结束后把更新后的链表保存回去(首次渲染时则「建立链表」)。此外 React 内部维护一个「当前正在渲染的 fiber」变量,用来确定「这些 Hook 属于哪个组件」——这也是「普通函数里调用 Hook 会报Invalid hook call」的原因。 -
「用 index 或 key 来标识 Hook 不行吗?」
不行(或不划算):① 变量名——运行时拿不到(编译后就没了);② 代码位置——压缩后会变,且「循环里的 Hook」无法用位置唯一标识;③ 显式 key——技术上可行,但会让每个 Hook 都要「取一个稳定唯一的名字」(写法极其繁琐,且更容易写错)。所以 React 选择了「用调用顺序」——简洁、确定、天然覆盖循环场景,代价是「必须保证顺序稳定」。 -
「循环里调用 Hook 会怎样?」
会「状态错乱」(不一定立刻崩)——因为「第二次渲染时循环次数可能变了」,那么「第 N 次循环调用」对应的链表节点就变了:① 循环次数变少 → 报Rendered fewer hooks than expected;② 循环次数变多 → 报Rendered more hooks than during the previous render;③ 次数相同但语义变了(如数组顺序变了)→ 静默错乱(每个 Hook 拿到的是「另一个元素」的状态)。正确做法是「用一个 Hook 承载动态集合」(如useRef(new Map()))。 -
「有
if (cond) return null在 Hook 之前会怎样?」
会让「Hook 的数量随cond变化」:cond为真时提前return→ 后面的 Hook 不被调用;cond为假时才调用。于是两次渲染的 Hook 数量不同 → 报错或状态错乱。正确写法是「把所有 Hook 写在组件函数最前面,条件return放在所有 Hook 之后」——这样「Hook 的调用顺序与数量」在所有分支下都一致。 -
「
Invalid hook call报错有哪些原因?」
React 的错误提示列了三种:① 违反 Hook 规则(在条件/循环/普通函数里调用);② React 与 ReactDOM 版本不匹配(升级时只升了一个);③ 应用里存在多份 React 副本(典型原因是某个库把react写进了dependencies而不是peerDependencies,导致装了两份)。排查方式是npm ls react看是否有重复版本;前两种则用eslint-plugin-react-hooks提前拦住。 -
「自定义 Hook 的调用也有这个限制吗?」
有。自定义 Hook 只是「把一组 Hook 调用封装起来」——它对宿主组件来说,就是「按顺序插入的一段 Hook」。所以:① 自定义 Hook 内部同样不能有条件/循环;② 调用多个自定义 Hook 的顺序也必须稳定(不能有条件地调用某个自定义 Hook)。抽 Hook 并不能解决「条件调用」的问题——只是把「违规点」搬进了 Hook 内部。 -
「为什么说『这个设计是权衡的结果』?」
因为「用顺序标识」是「简洁性」与「约束」之间的权衡:① 好处——写法极简(不需要给 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)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!