React Hook 原理

聊聊 Hook 如何在函数组件的多次渲染之间保存状态、处理副作用

先记住一句话:Hook 依赖稳定的调用顺序,把状态和副作用信息挂载到组件对应的 Fiber 上;函数组件每次重新执行时,React 按相同顺序取回这些信息。

本文介绍 Hook 的整体模型,具体 API 可以继续阅读:

1. 为什么 Hook 不能在条件中调用?

React 要求 Hook 只能在函数组件或自定义 Hook 的顶层调用,不能放在条件、循环、嵌套函数或事件处理函数中:

function Component({ show }) {
  const [a, setA] = useState("A");

  if (show) {
    // ❌ 不要在条件中调用 Hook
    useEffect(() => {}, []);
  }

  const [b, setB] = useState("B");
  return <div>{a}{b}</div>;
}

React 不依赖 Hook 的变量名来识别它,而是依赖调用顺序。可以把一个组件简化理解成这样的 Hook 链表:

第 1 次调用 -> 第 2 次调用 -> 第 3 次调用

首次渲染时,React 按顺序创建 Hook 信息;后续渲染时,React 仍按这个顺序读取。上面的例子在 showtrue 变为 false 后,后续 Hook 的位置就会发生偏移,React 无法再把每次调用准确对应到原来的状态。

因此,Hook 规则不是语法限制,而是 React 能够在没有组件实例的情况下管理状态的前提。

2. Hook 和 Fiber 的关系

函数组件本身只是一次函数调用,不负责持有跨渲染状态。React 会为组件维护 Fiber 节点,Fiber 上保存了与该组件有关的渲染信息。

在简化模型中:

Fiber
└── memoizedState
    └── Hook -> Hook -> Hook

memoizedState(注意拼写,不是 memorizedState)指向 Hook 链表。不同 Hook 节点会保存不同信息,例如:

  • useState / useReducer:当前状态和更新队列
  • useRef:稳定的 ref 对象
  • useMemo / useCallback:缓存值和依赖
  • useEffect:effect、依赖和清理信息

这是帮助理解的简化模型。真实 React 还会通过 Fiber 的 flags、提交阶段等机制处理副作用,并使用 current 与 workInProgress Fiber 进行更新。

3.useState 如何保存状态?

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((value) => value + 1)}>{count}</button>;
}

大致流程如下:

  1. 首次渲染时,React 创建一个 Hook 节点并保存初始状态。
  2. setCount 被调用时,React 创建一个更新对象并放入更新队列。
  3. React 调度组件重新渲染。
  4. 再次执行组件时,React 按相同位置找到这个 Hook,处理队列并得到最新状态。

更新不会直接修改当前渲染中的 count。因此多次更新时,推荐使用函数式写法:

setCount((value) => value + 1);
setCount((value) => value + 1); // 最终增加 2

直接使用闭包中的旧值可能得到不同结果:

setCount(count + 1);
setCount(count + 1); // 两次都可能基于同一个旧 count

React 会使用 Object.is 判断状态是否发生变化。更新对象或数组时,应返回新的引用,而不是原地修改旧对象。

4.useEffect 如何处理副作用?

渲染阶段应该保持纯粹:根据 props 和 state 计算 UI。订阅事件、请求数据、操作 DOM、启动定时器等副作用,应放在 effect 中。

useEffect(() => {
  const timer = setInterval(() => {
    console.log("tick");
  }, 1000);

  return () => clearInterval(timer);
}, []);

useEffect 的回调在组件提交更新后由 React 调度执行,通常会让浏览器先完成绘制;它不应被理解为永远严格发生在绘制之后。需要在绘制前读取或调整布局时,才考虑 useLayoutEffect

依赖数组

React 会逐项使用 Object.is 比较新旧依赖值:

useEffect(() => {
  document.title = String(count);
}, [count]);
  • useEffect(fn, []):不因后续重新渲染重新执行;组件卸载时执行清理。开发环境 Strict Mode 可能额外执行一次 setup/cleanup 检查。
  • useEffect(fn, [count]):首次提交后执行,之后在 count 发生变化时重新执行。
  • useEffect(fn):每次提交后都可能执行。

清理函数会在 effect 重新执行前运行,也会在组件卸载时运行:

useEffect(() => {
  subscribe(userId);
  return () => unsubscribe(userId);
}, [userId]);

闭包与依赖

每次渲染都是一次独立的函数调用,effect 捕获的是创建它的那次渲染中的变量:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);

  return () => clearInterval(timer);
}, []); // 这里捕获的是首次渲染的 count

解决方式通常是:

  • 把 effect 中使用的响应式值加入依赖数组;
  • 如果只是基于旧状态更新状态,使用函数式更新;
  • 如果确实需要读取最新但不触发 effect 重建的值,考虑 useRef,并明确管理同步逻辑。

依赖数组不是“控制 effect 执行次数”的开关,而是 effect 对外部响应式值的声明。应优先让依赖保持诚实,并通过重构逻辑解决不必要的重复执行。

5. 其他常用 Hook 的定位

Hook主要用途
useState保存组件局部状态
useReducer用 reducer 管理更复杂的状态更新
useRef保存跨渲染稳定的可变值,修改它不会触发渲染
useMemo缓存计算结果,不是通用的状态存储方案
useCallback缓存函数引用,常用于配合 memo 或稳定依赖
useContext读取上下文值
自定义 Hook复用带状态的逻辑,每次调用仍拥有独立状态

这些 Hook 仍然遵守同一条规则:调用顺序必须稳定。

6. React 18 和开发环境注意事项

自动批处理

React 18 会在更多场景下批量处理状态更新,减少不必要的重新渲染。批处理不改变 Hook 的调用规则,也不意味着 setState 会立即修改当前渲染中的变量。

Strict Mode

开发环境 Strict Mode 可能主动执行额外的渲染和 effect setup/cleanup 检查,用来发现不纯的渲染逻辑和缺少清理的副作用。因此 effect 必须能够正确清理并安全地重复执行。

并发渲染

现代 React 的渲染阶段可能被中断、重做或放弃。不要在渲染阶段执行有副作用的操作;副作用应放在 effect 或事件处理函数中。

面试怎么回答?

可以这样概括:

Hook 不是依靠函数变量保存状态,而是依靠稳定的调用顺序,让 React 在组件对应的 Fiber 上找到相应的 Hook 信息。useState 通过更新队列计算新状态,useEffect 在提交阶段处理副作用。调用顺序一旦改变,Hook 与状态的对应关系就会错位。

继续展开时,可以补充:

  1. Hook 链表是理解规则的简化模型,真实实现还涉及 Fiber、flags 和提交阶段。
  2. 依赖数组逐项使用 Object.is 比较,不是比较数组本身的引用。
  3. 每次渲染都有自己的闭包,函数式更新可以避免读取旧 state。
  4. Strict Mode 的额外执行是开发环境检查,不应依赖 effect 只运行一次。
  5. useMemouseCallback 是性能优化工具,不是保证永久缓存的语义工具。