Skip to content

Hooks 源码:useState、useEffect 与 Hook 链表

很多人平时会用 Hooks,但一到源码层就容易卡住:

  1. Hook 的状态到底放在哪里
  2. 为什么 Hook 调用顺序必须稳定
  3. useState 的更新队列是什么
  4. useEffect 为什么要等 commit

这篇文章重点讲:

  1. Hook 在源码层和 Fiber 的关系
  2. Hook 为什么会以链表形式挂在 Fiber 上
  3. useState 的挂载和更新主线
  4. useEffect 的收集与执行主线

1. Hooks 为什么一定要建立在 Fiber 上

Hook 不是脱离组件独立存在的。

它必须回答两个问题:

  1. 当前这个 Hook 属于哪个组件
  2. 当前这一轮渲染里,它应该对应到哪个状态槽位

React 的做法是把 Hook 状态挂到当前正在渲染的 Fiber 上。

也就是说:

  1. Fiber 代表当前组件这次更新的工作节点
  2. Hook 代表这个组件内部一段状态或副作用信息

所以 Hook 源码如果脱离 Fiber 去看,会非常碎。


1.1 Hook 的完整释义和能力边界

很多人把 Hook 只理解成“函数组件里的 API”。

从源码视角看,这个定义还不够完整。

把源码语境放进来:

Hook 是 React 挂在当前 Fiber 上的一段运行期状态单元。它用来记录这个组件在当前渲染链路里某一段状态、队列、依赖或副作用信息,并且依赖稳定调用顺序来完成挂载和更新匹配。`

它主要负责:

  1. 保存某个 Hook 槽位上的状态或副作用信息
  2. 连接更新队列、依赖、effect 描述等运行期数据
  3. 让函数组件也能拥有持续的状态和副作用模型

它不负责:

  1. 不独立存在于组件之外
  2. 不自己调度整棵树更新
  3. 不脱离 Fiber 单独工作

所以把这两层分开看:

概念主要负责什么主要不负责什么
Hook 节点保存当前槽位运行期信息不单独表示组件
Fiber保存组件这轮工作的整体信息不细分每个 Hook 槽位逻辑
dispatch发起更新不自己完成整棵树计算

这也是为什么 Hook 源码一定要和 Fiber 一起看。


2. Hook 为什么是链表而不是对象 map

这是源码层最关键的理解点之一。

React 并没有用:

js
hooks["count"]
hooks["effect1"]

这种按名字查的结构。

它更接近:

js
fiber.memoizedState -> Hook1 -> Hook2 -> Hook3

可以看一份精简示意:

js
const hook = {
  memoizedState: null,
  baseState: null,
  baseQueue: null,
  queue: null,
  next: null
};

这里最关键的是 next,因为多个 Hook 会被串成链表。

🌟 这也是为什么 Hook 调用顺序必须稳定,React 不是按名字找 Hook,而是按调用顺序依次取链表节点。


2.1 Hook 链表的边界

Hook 链表主要负责的是“按顺序保存当前组件里每一个 Hook 槽位”。

它负责:

  1. 保存调用顺序
  2. 让挂载和更新阶段都能按顺序读取
  3. 让一个组件里多个 Hook 可以稳定串起来

它不负责:

  1. 不按变量名查找 Hook
  2. 不解决跨组件共享状态
  3. 不直接决定执行优先级

所以 Hook 链表的本质不是“更省内存”,而是 更适合和函数组件的固定调用顺序配合。


3. Hook 链表挂在哪里

通常会挂在当前 Fiber 的 memoizedState 上。

看最直接的一层:

js
currentlyRenderingFiber.memoizedState = firstHook;

然后:

js
firstHook.next = secondHook;
secondHook.next = thirdHook;

所以如果一个组件里写了:

jsx
useState(...)
useEffect(...)
useRef(...)

源码视角里更接近:

  1. 第一个 Hook 节点对应 useState
  2. 第二个 Hook 节点对应 useEffect
  3. 第三个 Hook 节点对应 useRef

顺序一旦变化,后面就全错位了。


4. 挂载阶段怎么处理 Hook

当组件第一次渲染时,React 会走“挂载 Hook”的逻辑。

可以看一份精简后的思路示意:

js
function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null
  };

  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = hook;
    workInProgressHook = hook;
  } else {
    workInProgressHook.next = hook;
    workInProgressHook = hook;
  }

  return hook;
}

这里最关键的动作是:

  1. 创建当前 Hook 节点
  2. 如果这是第一个 Hook,就挂到 Fiber 上
  3. 如果不是第一个,就接到前一个 Hook 后面

5. 更新阶段怎么找到对应 Hook

当组件再次渲染时,React 不会重新随便创建一批全新的 Hook。

它更重要的动作是 按同样顺序取出上一轮对应位置的 Hook,再生成当前 work-in-progress 版本。

所以更新阶段的关键不再只是“建节点”,而是:

  1. 读取 current Fiber 上已有 Hook 链
  2. 按顺序拿到当前应对应的 Hook
  3. 基于旧 Hook 生成这轮新 Hook

这也是为什么:

  1. 不能把 Hook 放进条件分支里
  2. 不能有时调一个,有时少调一个

否则 React 下一轮按顺序读取时,就会把状态错配到别的 Hook 上。


6. useState 的源码主线怎么理解

6.1 挂载阶段

第一次执行 useState(initialState) 时,可以把主线理解成:

  1. 创建一个 Hook 节点
  2. 把初始值放到 memoizedState
  3. 创建更新队列 queue
  4. 返回 [state, dispatch]

精简示意:

js
function mountState(initialState) {
  const hook = mountWorkInProgressHook();
  hook.memoizedState = initialState;
  hook.baseState = initialState;

  const queue = {
    pending: null,
    dispatch: null
  };

  hook.queue = queue;

  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;

  return [hook.memoizedState, dispatch];
}

6.2 更新阶段

更新时更核心的事情是:

  1. 取出当前 Hook
  2. 处理它的更新队列
  3. 把一串 update 计算成最新 state
  4. 返回新的 [state, dispatch]

也就是说,setState 并不是立刻改值,而是先往队列里塞 update。


6.3 useState 的能力边界

useState 很常用,但源码上它解决的问题非常聚焦。

它负责的是:

  1. 保存单个 Hook 槽位上的状态值
  2. 管理该状态对应的更新队列
  3. 在下一轮 render 时把 update 计算成最新 state

它不负责的是:

  1. 不直接执行副作用
  2. 不自己决定整棵树何时 commit
  3. 不天然适合复杂多动作状态流

所以源码上 useState 更像:本地状态 + 更新入队 + 下轮重算
如果你看到复杂动作分发、不同类型更新行为越来越多,那条线通常更接近 useReducer,而不是 useState 本身的能力边界。


7. 为什么 setState 更像入队

源码视角里,dispatchSetState 更像是在做这些事:

  1. 创建一个 update 对象
  2. 把 update 挂到当前 Hook 的更新队列里
  3. 标记这次更新需要调度

精简示意:

js
function dispatchSetState(fiber, queue, action) {
  const update = {
    action,
    next: null
  };

  enqueueUpdate(queue, update);
  scheduleUpdateOnFiber(fiber);
}

🌟 所以 setState 的源码主线更接近 入队 + 调度,不是“直接改变量”。


8. useEffect 的源码主线怎么理解

useEffectuseState 更容易让人误会,因为它不只是存一个值。

它更像在做两件事:

  1. 在 render 阶段收集 effect 信息
  2. 在 commit 阶段真正执行副作用和清理

8.1 render 阶段

React 会把当前 effect 的信息挂到当前 Fiber 的 effect 链路上,并标记它后续需要处理。

把 render 阶段摊开看:

  1. 依赖有没有变
  2. 如果要执行,就打上对应 flags
  3. 把 effect 记录下来,等 commit 再处理

8.2 commit 阶段

到了 commit,React 才会真正做:

  1. 执行上一次 effect 的清理
  2. 执行这一次 effect 的创建函数

这也是为什么:

  1. useEffect 不是 render 时立刻执行
  2. 它天然和 commit 时机绑定

8.3 useEffect 的能力边界

useEffect 的边界也很值得单独讲清楚。

它负责的是:

  1. 在 render 阶段登记副作用信息
  2. 在 commit 后执行副作用创建逻辑
  3. 在下一次更新或卸载前执行清理

它不负责的是:

  1. 不参与纯渲染计算
  2. 不应该承担所有业务逻辑
  3. 不在 render 阶段立即执行

所以把边界放回原位看,useEffect 不是“生命周期函数替代品”这么简单,它是 React 用来把副作用从 render 纯计算阶段隔离出去的一条机制。`

这也是为什么:

  1. useEffect 经常和 commit 一起讲
  2. 它天然比 useState 更容易涉及时机问题

9. 一段精简后的 useEffect 思路示意

js
function mountEffect(create, deps) {
  const hook = mountWorkInProgressHook();

  hook.memoizedState = pushEffect({
    create,
    deps,
    destroy: undefined
  });

  currentlyRenderingFiber.flags |= PassiveEffect;
}

这段示意代码最值得看的点是:

  1. effect 也会占一个 Hook 槽位
  2. 它记录的是副作用描述信息
  3. Fiber 会被打上 PassiveEffect 这类标记
  4. 真正执行仍然要等 commit

10. useStateuseEffect 在源码层最大的差别

可以这样分:

Hook更核心的源码关注点
useState状态值、更新队列、dispatch、重算 state
useEffect依赖比较、effect 记录、flags、commit 执行

也就是说:

  1. useState 更像状态更新模型
  2. useEffect 更像副作用登记和延迟执行模型

10.1 Hook 源码里,哪些事情不是 Hook 自己做的

这一点很关键,不然很容易把 Hook 神化。

Hook 自己不单独做这些事:

  1. 不独立驱动整棵 Fiber 树渲染
  2. 不单独承担优先级调度
  3. 不直接操作宿主 DOM

这些事情分别会回到:

  1. Fiber work loop
  2. root 调度
  3. commit 和 react-dom

所以 Hook 更像“组件内部某个槽位上的运行期状态单元”,而不是“完整更新系统”。`


11. 常见相关源码文件

如果你继续往仓库里读,通常会重点看这些文件:

文件主要职责
packages/react-reconciler/src/ReactFiberHooks.jsHook 挂载、更新、队列处理
packages/react-reconciler/src/ReactFiberWorkLoop.js更新调度入口会回到 Fiber 主循环
packages/react-reconciler/src/ReactFiberCommitWork.jseffect 在 commit 阶段的处理

阅读时建议先盯住:

  1. mountWorkInProgressHook
  2. updateWorkInProgressHook
  3. mountState / updateState
  4. effect 的 push 与 commit 处理

12. 这一层最常见的误区

12.1 以为 Hook 是按变量名保存的

不是。

它是按调用顺序挂到链表里的。

12.2 以为 setState 立刻改了 state

源码上更接近更新入队和调度。

12.3 以为 useEffect 在 render 时就执行

不是。

render 阶段主要是登记,真正执行更接近 commit 阶段。


13. 一句话总结

Hooks 这一层的源码主线,本质上是在做 把每个 Hook 节点按调用顺序挂到当前 Fiber 上,让 useState 通过更新队列管理状态,让 useEffect 在 render 阶段登记副作用并在 commit 阶段执行。

基于 VitePress 构建的个人技术笔记。