Appearance
Hooks 源码:useState、useEffect 与 Hook 链表
很多人平时会用 Hooks,但一到源码层就容易卡住:
- Hook 的状态到底放在哪里
- 为什么 Hook 调用顺序必须稳定
useState的更新队列是什么useEffect为什么要等 commit
这篇文章重点讲:
- Hook 在源码层和 Fiber 的关系
- Hook 为什么会以链表形式挂在 Fiber 上
useState的挂载和更新主线useEffect的收集与执行主线
1. Hooks 为什么一定要建立在 Fiber 上
Hook 不是脱离组件独立存在的。
它必须回答两个问题:
- 当前这个 Hook 属于哪个组件
- 当前这一轮渲染里,它应该对应到哪个状态槽位
React 的做法是把 Hook 状态挂到当前正在渲染的 Fiber 上。
也就是说:
- Fiber 代表当前组件这次更新的工作节点
- Hook 代表这个组件内部一段状态或副作用信息
所以 Hook 源码如果脱离 Fiber 去看,会非常碎。
1.1 Hook 的完整释义和能力边界
很多人把 Hook 只理解成“函数组件里的 API”。
从源码视角看,这个定义还不够完整。
把源码语境放进来:
Hook 是 React 挂在当前 Fiber 上的一段运行期状态单元。它用来记录这个组件在当前渲染链路里某一段状态、队列、依赖或副作用信息,并且依赖稳定调用顺序来完成挂载和更新匹配。`
它主要负责:
- 保存某个 Hook 槽位上的状态或副作用信息
- 连接更新队列、依赖、effect 描述等运行期数据
- 让函数组件也能拥有持续的状态和副作用模型
它不负责:
- 不独立存在于组件之外
- 不自己调度整棵树更新
- 不脱离 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 槽位”。
它负责:
- 保存调用顺序
- 让挂载和更新阶段都能按顺序读取
- 让一个组件里多个 Hook 可以稳定串起来
它不负责:
- 不按变量名查找 Hook
- 不解决跨组件共享状态
- 不直接决定执行优先级
所以 Hook 链表的本质不是“更省内存”,而是 更适合和函数组件的固定调用顺序配合。
3. Hook 链表挂在哪里
通常会挂在当前 Fiber 的 memoizedState 上。
看最直接的一层:
js
currentlyRenderingFiber.memoizedState = firstHook;然后:
js
firstHook.next = secondHook;
secondHook.next = thirdHook;所以如果一个组件里写了:
jsx
useState(...)
useEffect(...)
useRef(...)源码视角里更接近:
- 第一个 Hook 节点对应
useState - 第二个 Hook 节点对应
useEffect - 第三个 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;
}这里最关键的动作是:
- 创建当前 Hook 节点
- 如果这是第一个 Hook,就挂到 Fiber 上
- 如果不是第一个,就接到前一个 Hook 后面
5. 更新阶段怎么找到对应 Hook
当组件再次渲染时,React 不会重新随便创建一批全新的 Hook。
它更重要的动作是 按同样顺序取出上一轮对应位置的 Hook,再生成当前 work-in-progress 版本。
所以更新阶段的关键不再只是“建节点”,而是:
- 读取 current Fiber 上已有 Hook 链
- 按顺序拿到当前应对应的 Hook
- 基于旧 Hook 生成这轮新 Hook
这也是为什么:
- 不能把 Hook 放进条件分支里
- 不能有时调一个,有时少调一个
否则 React 下一轮按顺序读取时,就会把状态错配到别的 Hook 上。
6. useState 的源码主线怎么理解
6.1 挂载阶段
第一次执行 useState(initialState) 时,可以把主线理解成:
- 创建一个 Hook 节点
- 把初始值放到
memoizedState - 创建更新队列
queue - 返回
[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 更新阶段
更新时更核心的事情是:
- 取出当前 Hook
- 处理它的更新队列
- 把一串 update 计算成最新 state
- 返回新的
[state, dispatch]
也就是说,setState 并不是立刻改值,而是先往队列里塞 update。
6.3 useState 的能力边界
useState 很常用,但源码上它解决的问题非常聚焦。
它负责的是:
- 保存单个 Hook 槽位上的状态值
- 管理该状态对应的更新队列
- 在下一轮 render 时把 update 计算成最新 state
它不负责的是:
- 不直接执行副作用
- 不自己决定整棵树何时 commit
- 不天然适合复杂多动作状态流
所以源码上 useState 更像:本地状态 + 更新入队 + 下轮重算。
如果你看到复杂动作分发、不同类型更新行为越来越多,那条线通常更接近 useReducer,而不是 useState 本身的能力边界。
7. 为什么 setState 更像入队
源码视角里,dispatchSetState 更像是在做这些事:
- 创建一个 update 对象
- 把 update 挂到当前 Hook 的更新队列里
- 标记这次更新需要调度
精简示意:
js
function dispatchSetState(fiber, queue, action) {
const update = {
action,
next: null
};
enqueueUpdate(queue, update);
scheduleUpdateOnFiber(fiber);
}🌟 所以 setState 的源码主线更接近 入队 + 调度,不是“直接改变量”。
8. useEffect 的源码主线怎么理解
useEffect 比 useState 更容易让人误会,因为它不只是存一个值。
它更像在做两件事:
- 在 render 阶段收集 effect 信息
- 在 commit 阶段真正执行副作用和清理
8.1 render 阶段
React 会把当前 effect 的信息挂到当前 Fiber 的 effect 链路上,并标记它后续需要处理。
把 render 阶段摊开看:
- 依赖有没有变
- 如果要执行,就打上对应 flags
- 把 effect 记录下来,等 commit 再处理
8.2 commit 阶段
到了 commit,React 才会真正做:
- 执行上一次 effect 的清理
- 执行这一次 effect 的创建函数
这也是为什么:
useEffect不是 render 时立刻执行- 它天然和 commit 时机绑定
8.3 useEffect 的能力边界
useEffect 的边界也很值得单独讲清楚。
它负责的是:
- 在 render 阶段登记副作用信息
- 在 commit 后执行副作用创建逻辑
- 在下一次更新或卸载前执行清理
它不负责的是:
- 不参与纯渲染计算
- 不应该承担所有业务逻辑
- 不在 render 阶段立即执行
所以把边界放回原位看,useEffect 不是“生命周期函数替代品”这么简单,它是 React 用来把副作用从 render 纯计算阶段隔离出去的一条机制。`
这也是为什么:
useEffect经常和 commit 一起讲- 它天然比
useState更容易涉及时机问题
9. 一段精简后的 useEffect 思路示意
js
function mountEffect(create, deps) {
const hook = mountWorkInProgressHook();
hook.memoizedState = pushEffect({
create,
deps,
destroy: undefined
});
currentlyRenderingFiber.flags |= PassiveEffect;
}这段示意代码最值得看的点是:
- effect 也会占一个 Hook 槽位
- 它记录的是副作用描述信息
- Fiber 会被打上
PassiveEffect这类标记 - 真正执行仍然要等 commit
10. useState 和 useEffect 在源码层最大的差别
可以这样分:
| Hook | 更核心的源码关注点 |
|---|---|
useState | 状态值、更新队列、dispatch、重算 state |
useEffect | 依赖比较、effect 记录、flags、commit 执行 |
也就是说:
useState更像状态更新模型useEffect更像副作用登记和延迟执行模型
10.1 Hook 源码里,哪些事情不是 Hook 自己做的
这一点很关键,不然很容易把 Hook 神化。
Hook 自己不单独做这些事:
- 不独立驱动整棵 Fiber 树渲染
- 不单独承担优先级调度
- 不直接操作宿主 DOM
这些事情分别会回到:
- Fiber work loop
- root 调度
- commit 和
react-dom
所以 Hook 更像“组件内部某个槽位上的运行期状态单元”,而不是“完整更新系统”。`
11. 常见相关源码文件
如果你继续往仓库里读,通常会重点看这些文件:
| 文件 | 主要职责 |
|---|---|
packages/react-reconciler/src/ReactFiberHooks.js | Hook 挂载、更新、队列处理 |
packages/react-reconciler/src/ReactFiberWorkLoop.js | 更新调度入口会回到 Fiber 主循环 |
packages/react-reconciler/src/ReactFiberCommitWork.js | effect 在 commit 阶段的处理 |
阅读时建议先盯住:
mountWorkInProgressHookupdateWorkInProgressHookmountState/updateState- 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 阶段执行。