陌上人如玉
公子世无双

2.1 Hooks 规则与实现原理

本文于 2026-10-12 10:37 更新,部分内容具有时效性,如有失效,请留言

2.1 Hooks 规则与实现原理

一句话:React 用链表按「调用顺序」存储每个组件的 Hooks 状态,因此 Hooks 必须在每次渲染中以完全相同的顺序、相同次数被调用——这就是「不能在条件/循环里写 Hook」的根本原因。

一、两条规则

① 只能在【顶层】调用
   ❌ 不能在条件、循环、嵌套函数、try/catch 里调用

② 只能在【函数组件】或【自定义 Hook】中调用
   ❌ 不能在普通 JS 函数、类组件、事件处理函数里调用
// ❌ 违反规则一:写在条件里
function Comp({ isLoggedIn }) {
  if (isLoggedIn) {
    const [user, setUser] = useState(null);      // ⚠️ 顺序会变
  }
  const [theme, setTheme] = useState('light');
  // ...
}

// ❌ 违反规则一:写在循环里
function Comp2({ list }) {
  const refs = list.map(() => useRef(null));     // ⚠️ 数量会变
}

// ❌ 违反规则一:写在 try/catch 里
function Comp3() {
  try {
    const [x, setX] = useState(0);               // ⚠️
  } catch {}
}

// ❌ 违反规则一:写在嵌套函数里
function Comp4() {
  const handler = () => {
    const [x, setX] = useState(0);               // ⚠️
  };
}

// ✅ 正确:全部写在组件函数体的顶层
function Good({ isLoggedIn }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  // 条件逻辑放在 Hook 调用【之后】
  const displayName = isLoggedIn ? user?.name : '访客';

  return <div>{displayName} / {theme}</div>;
}
// ❌ 违反规则二:在普通函数里调用
function formatData(data) {
  const [x, setX] = useState(0);        // ⚠️ 报错:Invalid hook call
}

// ✅ 如果是「复用逻辑」,写成自定义 Hook(use 开头)
function useFormattedData(data) {
  const [x, setX] = useState(0);
  return x;
}

二、为什么不能写在条件里:链表结构

React 为每个组件维护一个 fiber 节点,节点上有 memoizedState 字段:

fiber.memoizedState → hook1 → hook2 → hook3 → null
                       ↓        ↓        ↓
                     {state}  {state}  {state}   (单向链表,按调用顺序相连)

每次渲染时,React 用一个「游标」从链表头部开始,
每调用一个 Hook 就把游标往后移一位,读取/写入对应的 hook 对象。
// 用伪代码模拟 React 内部的处理
let currentHookNode = null;     // 当前正在处理的 hook 节点
let isFirstRender = true;

function renderWithHooks(Component, props) {
  currentHookNode = ComponentFiber.memoizedState;    // ⭐ 从头开始
  const result = Component(props);                   // 执行组件函数(Hooks 被依次调用)
  ComponentFiber.memoizedState = currentHookNode;    // 保存(首次渲染时即建立链表)
  return result;
}

function useState(initial) {
  let hook;

  if (isFirstRender) {
    // 首次渲染:创建节点并接到链表尾部
    hook = { memoizedState: typeof initial === 'function' ? initial() : initial, queue: [] };
    if (currentHookNode === null) {
      CurrentFiber.memoizedState = hook;             // 链表头
    } else {
      currentHookNode.next = hook;                   // 接到尾部
    }
    currentHookNode = hook;
  } else {
    // 更新渲染:按【顺序】读取已有节点
    hook = currentHookNode;
    currentHookNode = hook.next;                     // ⚠️ 游标后移
    // 消费 update 队列
    if (hook.queue.length) {
      hook.memoizedState = hook.queue[hook.queue.length - 1].newValue;
    }
  }

  const setState = (action) => {
    hook.queue.push({ newValue: typeof action === 'function' ? action(hook.memoizedState) : action });
    scheduleRerender();                              // 触发重渲染
  };

  return [hook.memoizedState, setState];
}
如果 Hook 的调用顺序变了会怎样?

首次渲染(isLoggedIn = false):      更新渲染(isLoggedIn = true):
  useState(user) → 节点 0              if (isLoggedIn) useState(user) → 节点 0   ❌
  useState(theme) → 节点 1             useState(theme)               → 节点 1   ❌
                                       ↑ 顺序错位:theme 读到了 user 的节点

后果:
① 状态错乱(theme 拿到了 user 的值)
② 报错:Rendered more hooks than during the previous render
③ 未知副作用(可能崩溃)

结论:Hook 的身份由「调用顺序」决定,而不是由变量名或位置决定。所以顺序必须稳定。

三、Hook 对象的内部结构

// 每个 Hook 节点大致长这样(不同类型字段略有差异)
{
  memoizedState: any,       // 当前的状态值(useEffect 里存的是 effect 对象)
  baseState: any,           // 基础状态(用于更新队列的基线)
  baseQueue: any,
  queue: UpdateQueue,       // 更新队列(setState 产生的更新会进这里)
  next: Hook | null,        // 指向下一个 Hook
}

// 不同 Hook 存的东西不同:
// useState     → memoizedState: 状态值;queue: 更新队列
// useRef       → memoizedState: { current: ... }  (注意:useRef 的节点只创建一次)
// useEffect    → memoizedState: effect 对象(含 create / destroy / deps)
// useMemo      → memoizedState: [value, deps]
// useCallback  → memoizedState: [callback, deps]
// useReducer   → memoizedState: 状态值;queue: 参数化的 dispatch 队列
// 观察 Hook 链表(在 React DevTools 里可以看到组件的 Hooks 列表)
function Demo() {
  const [a, setA] = useState(1);          // Hook 0
  const [b, setB] = useState('x');        // Hook 1
  const ref = useRef(null);               // Hook 2
  useEffect(() => {}, []);                // Hook 3
  const memo = useMemo(() => a + 1, [a]); // Hook 4
  // ...
}
// DevTools 中显示为:State(1) → State('x') → Ref → Effect → Memo → ...

四、为什么「Hook 只能在组件里调用」

React 需要知道「当前正在渲染哪个组件」,才能把 Hook 的状态挂到对应的 fiber 上。
这个信息保存在一个「当前正在渲染的 fiber」变量里(ReactCurrentDispatcher / currentFiber)。

这个变量只在 React 渲染组件的过程中被设置。
在普通函数里调用 Hook 时,这个变量是 undefined 或指向别的组件
→ React 抛错:"Invalid hook call. Hooks can only be called inside of the body of a function component."
// 报错信息的三种常见成因(React 会提示这三种可能)
// 1. React 与 ReactDOM 版本不匹配
// 2. 违反 Hook 规则(在非组件里调用)
// 3. 同一个应用里有【多份 React 副本】(如依赖重复安装)

// ③ 的排查:npm ls react 检查是否有多个版本

五、ESLint 规则

// .eslintrc / eslint.config.js
{
  "extends": ["plugin:react-hooks/recommended"]
  // 包含两条规则:
  // react-hooks/rules-of-hooks      → 检查 Hook 的调用位置(error)
  // react-hooks/exhaustive-deps     → 检查依赖数组是否完整(warn)
}
// rules-of-hooks 能捕获的问题
function Comp({ flag }) {
  if (flag) {
    useEffect(() => {});              // ✅ ESLint 报错:React Hook "useEffect" is called conditionally
  }
}

// exhaustive-deps 的提示
function Comp2({ id }) {
  useEffect(() => {
    fetchData(id);                    // ✅ ESLint 警告:React Hook useEffect has a missing dependency: 'id'
  }, []);                             // ⚠️ 应该写成 [id]
}

六、违反规则的常见场景与正确写法

// ① 条件 Hook → 提到顶层 + 在逻辑里判断
// ❌
if (isEnabled) {
  const [value, setValue] = useState(0);
}

// ✅
const [value, setValue] = useState(0);
if (!isEnabled) return null;          // 条件影响的是「渲染结果」,不是 Hook 调用

// ② 循环 Hook → 用「一个 Hook 管理数组」
// ❌
items.map(() => useRef(null));

// ✅
const refs = useRef<Map<string, HTMLElement>>(new Map());

// ③ 抽成自定义 Hook(把条件逻辑包进 Hook 内部)
// ✅
function useUser(isLoggedIn: boolean) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    if (!isLoggedIn) return;          // ✅ 条件写在 Effect 内部
    fetchCurrentUser().then(setUser);
  }, [isLoggedIn]);

  return user;
}

// ④ 需要「动态数量」的 Hook → 用一个 Hook 承载
// ❌
const a = useCounter(1);
const b = useCounter(2);   // 数量固定即可

// ✅ 如果数量是动态的,说明应该用「一个 Hook + 数组/Map」
const counters = useCounters([1, 2, 3]);

// ⑤ 早期 return 之前的 Hook 数量必须一致
function Comp3({ data }) {
  const [a] = useState(0);            // Hook 0
  if (!data) return null;             // ❌ 如果在 Hook 之前 return,会导致下方 Hook 数量变化

  const [b] = useState(1);            // ⚠️ 有时调用、有时不调用 → 违反规则
  return <div>{a}{b}</div>;
}
// ✅ 所有 Hook 都写在最前面,之后再写条件 return
function Good3({ data }) {
  const [a] = useState(0);
  const [b] = useState(1);
  if (!data) return null;
  return <div>{a}{b}</div>;
}

七、常见坑

// ① 在条件里调用 Hook
if (cond) useSomething();

// ② 在循环里调用 Hook
items.forEach(() => useSomething());

// ③ 在 Hook 之前提前 return
if (!data) return null;
const [x] = useState(0);        // ❌

// ④ 在事件处理函数/普通函数里调用
const handleClick = () => { const [x] = useState(0); };   // ❌

// ⑤ 在类组件里用 Hook
class A extends React.Component {
  render() { const [x] = useState(0); }                    // ❌
}

// ⑥ 依赖重复安装的 React
// 报 "Invalid hook call" 但代码没问题 → 检查 npm ls react
// 常见于:组件库把 react 放进了 dependencies(应该放 peerDependencies)

// ⑦ 用条件表达式「看起来」没变但顺序变了
{cond ? useA() : useB()}         // ❌ 两者都是 Hook,但只调一个

// ⑧ 在 useState 的初始化函数里调用别的 Hook
useState(() => { useSomething(); return 0; });   // ❌ 回调里不能调 Hook

// ⑨ 忘记 ESLint 规则(项目里没配 react-hooks)
// → 这类 bug 很隐蔽,必须开 lint

// ⑩ 自定义 Hook 命名不以 use 开头
function fetchUser() { const [x] = useState(0); }  // ⚠️ lint 检查不到,且语义不清
function useFetchUser() { const [x] = useState(0); } // ✅

面试延伸

  1. 「为什么 Hooks 不能写在条件或循环里?」
    因为 React 用链表存储每个组件的 Hooks 状态,并且靠「调用顺序」来匹配(第 1 个 Hook 对应链表的第 1 个节点)。如果某次渲染中顺序或数量变了(某个 Hook 被跳过或多调用),React 就会把状态写到错误的节点上——表现为状态错乱、Rendered more hooks than during the previous render 报错等。所以规则是「每次渲染必须以完全相同的顺序、相同的次数调用所有 Hook」。

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

  3. 「Hook 对象内部存了什么?」
    每个 Hook 是链表上的一个节点,包含 memoizedState(当前状态)、queue(更新队列)、next(指向下一个 Hook)。不同 Hook 存的 memoizedState 不同:useState/useReducer 存状态值;useRef 存 { current }(且只创建一次);useEffect 存 effect 对象(含 create / destroy / deps);useMemo / useCallback 存 [值, 依赖]。

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

  5. 「为什么 Hook 必须写在组件函数体的顶层?」
    因为 Hook 的执行需要「当前正在渲染的组件上下文」——React 需要知道把状态挂到哪个 fiber 上,以及当前链表游标在哪。写在嵌套函数或回调里时,调用时机不确定(可能不在渲染过程中),React 无法确定上下文,于是抛错。写在顶层能保证「每次渲染都按固定顺序执行」。

  6. 「if (!data) return null 写在 Hook 之前有什么问题?」
    会导致「Hook 的调用数量不稳定」:当 data 存在时,后续的 Hook 会被调用;当 data 为空时提前 return,后续 Hook 被跳过 → 两次渲染的 Hook 数量不同 → React 报错(Rendered fewer hooks than expected)或状态错乱。正确写法是把所有 Hook 都写在最前面,条件 return 放在所有 Hook 之后。

  7. 「自定义 Hook 必须用 use 开头吗?」
    必须。原因有两个:① ESLint 的 rules-of-hooks 规则靠命名来识别——只有 use 开头的函数才会被当作「可能包含 Hook 的地方」来检查,不这么命名会让 lint 失效,隐藏 bug;② 语义约定——use 开头明确告诉调用者「这是一个包含状态/副作用的 Hook,必须遵守 Hook 规则」。所以即使只是复用逻辑,只要用到 Hook 就必须 use 开头。

  8. 「Hook 的调用顺序怎么保证在组件复用时也不错乱?」
    关键在于「自定义 Hook 内部的 Hook 调用顺序也必须是稳定的」。自定义 Hook 只是把一组 Hook 调用封装起来,它对宿主组件来说就是「按顺序插入的一段 Hook」。所以:① 自定义 Hook 内部同样不能有条件/循环;② 调用多个自定义 Hook 的顺序也必须稳定(不能有条件地调用某个自定义 Hook)。这也是为什么「抽 Hook」不能解决「条件调用」的问题。

一句话速记

React 用链表 + 调用顺序来匹配 Hook 的状态(fiber.memoizedState → hook1 → hook2 → …),所以 Hooks 必须在每次渲染中以相同顺序、相同次数出现在组件函数体的顶层;不能写在条件、循环、嵌套函数、try/catch 里,也不能在所有 Hook 之前提前 return;「动态数量」的场景用「一个 Hook 管数组/Map」而不是循环调用;必须开 eslint-plugin-react-hooks;报 Invalid hook call 还要检查「是否装了多份 React」(npm ls react)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.1 Hooks 规则与实现原理

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫