人人都会AI编程

7.4 同步更新与异步更新的场景辨析

更新时间:2026-07-10

React 要求 Hooks 必须遵守两条“铁律”:

  1. 只在最顶层调用,不在条件、循环或嵌套函数中调用。
  2. 只在 React 函数组件或自定义 Hook 中调用

这两条规则并非 React 团队随意设定的编码风格,而是由 Hooks 的底层实现机制决定的。理解这一机制后,你不仅会记住规则,还能真正理解为什么不遵守就会出问题。

Hooks 的存储结构:链表节点与顺序索引

React 在每个函数组件对应的 Fiber 节点上维护了一条Hooks 链表。每次调用 useStateuseEffect 等 Hook 时,React 都会创建一个 Hook 节点,并将其链接到链表的末尾。

一个 Fiber 节点上的 Hooks 链表结构示意:

Fiber 节点 (fiber)
  ├── 第一个 Hook 节点 (state: 'count')
  │     └── 下一个
  ├── 第二个 Hook 节点 (state: 'name')
  │     └── 下一个
  └── 第三个 Hook 节点 (effect, 清理函数...)

在组件首次渲染(mount)时,React 按调用顺序逐个创建这些 Hook 节点并构建链表。后续重渲染(update)时,React 会从头遍历这条链表,按相同的顺序取出之前的 Hook 节点来获取上一次的值,并执行逻辑。

这里的关键在于:React 依赖调用顺序来关联前后两次渲染的 Hook。链表上的节点是按调用顺序排列的,没有任何显式的“名称”或“标识”来匹配。因此,如果某次渲染时 Hooks 的调用顺序发生了变化,就会导致链表的“对位”错乱。

错误示例:条件调用导致顺序错乱

假设你在条件语句中使用了 Hook:

function Form({ isLogin }) {
  if (isLogin) {
    // ❌ 仅在 isLogin 为 true 时调用
    const [email, setEmail] = useState('');
  }

  const [password, setPassword] = useState('');

  // ...
}
  • 首次渲染(isLogin 为 true):链表顺序为 [email-Hook] → [password-Hook]
  • 重渲染(isLogin 变为 false):email-Hook 的调用被跳过,链表读取顺序变成 [password-Hook]。React 会用上一次 email-Hook 的状态去匹配这次第一个调用(password-Hook),造成状态错乱:password 的值变成了旧的 email 值。

这种错误会导致难以排查的 bug,比如状态错位、值在渲染间跳变、内存泄漏等。

为什么不能提取到普通函数调用

自定义 Hook 本质上是组合了原有 Hooks 的函数,但只要自定义 Hook 的调用发生在顶层,内部 Hooks 的顺序仍然是确定的。如果将 useState 等直接放在普通函数中,并在组件里条件调用这个普通函数,同样会破坏顺序。

// ❌ 普通函数内部用 Hook,然后在组件中条件调用
function useMyState() {
  return useState(0);
}
function App() {
  if (condition) {
    const [value] = useMyState();  // 第一次渲染有,第二次没有 → 顺序错乱
  }
}

这也是为什么 React 会通过 lint 规则(react-hooks/rules-of-hooks)强制 Hook 必须出现在函数组件或自定 Hook 的顶层,且其名称必须以 use 开头,方便静态检测。

底层源码层面的逻辑(简化版)

在 React 内部,每次调用 useState 大致会执行这样的逻辑(以伪代码呈现):

function useState(initialValue) {
  const hook = mountWorkInProgressHook();  // 创建或获取当前 Hook 节点
  if (isInitialRender) {
    hook.memoizedState = initialValue;
  }
  // 返回状态和更新函数...
}

mountWorkInProgressHook 会维护一个指针 currentlyRenderingFiber 和一个链表尾指针 workInProgressHook。每次调用时,它把当前 Hook 追加到链表末尾,然后将指针移到下一个位置。在更新阶段,updateWorkInProgressHook 会从头遍历链表,利用调用顺序一一对应。一旦调用顺序不同,就会取出错误的 Hook 节点。

为什么 Hooks 不能放在条件中,但可以用 if-return 提前退出?

React 允许组件早期返回 null,但不影响 Hooks 的顺序,因为条件返回是在 Hooks 调用之后执行的:

function User({ user }) {
  const [count, setCount] = useState(0);   // 始终先调用
  useEffect(() => { ... });                 // 始终先调用

  if (!user) return null;                   // 提前返回不影响 Hook 调用顺序

  return <div>{user.name}</div>;
}

只要 Hooks 的调用路径在每次渲染时完全相同(不管数据如何变化),顺序就是稳定的。这正是“不要在循环或条件中调用 Hook”的本意。

实际开发中的避坑方法

  • 开启 ESLint 插件 eslint-plugin-react-hooks,它会自动检测违反规则的代码。
  • 把条件逻辑写在 Hook 内部,比如将 if 判断放入 useEffect 的依赖变化内部或使用三元运算符决定 Hook 的初始值,而不是控制 Hook 是否被执行。
  • 需要条件性副作用时,使用 useEffect 的依赖数组或内部判断,而非条件渲染 Hook 本身。
// ✅ 正确:Hook 始终在顶层,条件逻辑内置于 effect 中
useEffect(() => {
  if (isLogin) {
    // 仅在 isLogin 为 true 时执行
  }
}, [isLogin]);

理解 Hooks 的链表存储和顺序依赖后,你就会明白上述铁律实际上是为了保证状态在重渲染间的正确对应。它们不是主观的约束,而是 React 内部机制的必然要求。