08|Fiber 上的 `useState`:状态终于属于具体组件

对应代码:lessons/08-fiber-hooks

mini-react 仓库地址

第 04 篇用 root 上的一维数组解释了 Hook 的顺序寻址,但整棵组件树共享全局下标:在前面插入一个组件,后面组件的状态位置就可能全部变化。

Fiber 已经为每个函数组件实例提供了独立工作节点。本篇把 Hooks 挂到函数组件 Fiber 上,再通过 alternate + hookIndex 找回旧状态。setState 也不再立即改状态,而是先保存 action,下一轮 Render 再按顺序归并。

同一个组件函数可以有多个状态实例

课程页面渲染了两个 Counter

js 复制代码
h(Counter, { label: "Counter A" })
h(Counter, { label: "Counter B" })

它们的 type 都指向同一个 Counter 函数,但在元素树中占据两个位置,也就会产生两个不同的函数组件 Fiber:

text 复制代码
App Fiber
  ├─ Counter A Fiber
  │    └─ hooks[0] → A 的 count
  └─ Counter B Fiber
       └─ hooks[0] → B 的 count

状态属于"树中这个组件实例对应的 Fiber",不是挂在 Counter 函数对象上。否则所有 Counter 都会共享同一个 count。

在列表中,组件 Fiber 的跨渲染身份同样受 key + type 影响;没有稳定 key 的重排也会让组件状态跟错对象。

执行组件前设置当前 Fiber

useState() 没有接收 Fiber 参数,所以运行时要在调用组件期间暴露当前工作节点:

js 复制代码
let workInProgressFiber = null;
let hookIndex = 0;

function updateFunctionComponent(fiber) {
  workInProgressFiber = fiber;
  hookIndex = 0;
  fiber.hooks = [];

  const output = fiber.type(fiber.props);
  workInProgressFiber = null;
  reconcileChildren(fiber, normalizeChildren([output]));
}

每个函数组件开始时:

  1. workInProgressFiber 指向当前组件 Fiber;
  2. 组件内 Hook 下标重置为 0;
  3. 新 Fiber 创建自己的空 hooks 数组;
  4. 执行组件,期间每次 Hook 调用都向该数组追加一项;
  5. 组件返回后清空当前 Fiber 指针,再协调其输出。

宿主 Fiber 不执行 Hooks,也不需要 hooks 数组。

新 Hook 怎样找到旧 Hook

同一个组件的新旧 Fiber 已由协调算法通过 alternate 连接:

text 复制代码
新 Counter Fiber
  ├─ alternate ─────────→ 旧 Counter Fiber
  │                         └─ hooks[0]
  └─ 新 hooks[0]  ←──────────────┘

useState 读取:

js 复制代码
const oldHook = workInProgressFiber.alternate?.hooks?.[hookIndex];

这里的两级地址分别解决不同问题:

  • alternate:找到同一个组件实例的旧 Fiber;
  • hookIndex:找到该组件内部同一次 Hook 调用。

首次挂载没有旧 Hook,就使用 initialValue;更新时则从旧 Hook 的 state 开始:

js 复制代码
const hook = {
  state: oldHook
    ? oldHook.state
    : (typeof initialValue === "function"
        ? initialValue()
        : initialValue),
  queue: [],
};

新 Hook 是一个新对象,而不是直接修改旧 Hook。这样旧 Fiber 在本轮提交前仍然完整地描述当前页面。

setState 为什么先把 action 入队

本章的 setter 很短:

js 复制代码
const setState = (action) => {
  hook.queue.push(action);
  enqueueRootUpdate();
};

它闭包捕获的是本轮 Hook 对象。事件触发时,action 被放入当前已提交 Hook 的 queue,然后以 currentRootalternate 创建新的 WIP root。

action 可以是:

  • 普通值:下一状态直接替换为这个值;
  • 函数:接收前一个归并结果并返回下一状态。

setter 不执行组件,不读取或修改 DOM,也不在事件处理器中直接决定最终 UI。它只保存"状态应该怎样变化",真正计算发生在下一轮 Render。

与第 04 篇相比,区别是:

text 复制代码
第 04 篇:setState 立即写 root.hooks,微任务稍后 render
第 08 篇:setState 把 action 写入 Fiber Hook queue,Render 时计算新 state

下一轮 Render 怎样归并队列

新 Fiber 执行组件并再次调用 useState 时,会从旧 state 开始依次处理旧 queue:

js 复制代码
for (const action of oldHook?.queue ?? []) {
  hook.state = typeof action === "function"
    ? action(hook.state)
    : action;
}

假设旧 state 是 0,事件中连续入队:

js 复制代码
setCount((value) => value + 1);
setCount((value) => value + 1);

Render 阶段的归并过程是:

text 复制代码
旧 state:0
action 1:0 → 1
action 2:1 → 2
新 state:2

新 Hook 的 queue 初始为空。提交以后,新 Fiber 成为 current Fiber,它的 Hook 也成为下一轮的旧 Hook;刚才处理过的 action 不会再次执行。

如果两个 action 都是组件闭包中计算好的 count + 1,它们都是同一个值 1,归并仍会得到 1。函数式 action 才能显式串联前一个结果。

根更新如何重跑同一棵应用

enqueueRootUpdate() 复用当前根的容器和顶层 children:

js 复制代码
function enqueueRootUpdate() {
  if (!currentRoot) return;

  workInProgressRoot = {
    dom: currentRoot.dom,
    props: currentRoot.props,
    alternate: currentRoot,
  };

  deletions = [];
  nextUnitOfWork = workInProgressRoot;
  scheduleWork();
}

顶层元素对象可以保持同一引用,但工作循环仍会从根开始执行函数组件,创建新 Fiber,并归并 Hook queue。

scheduled 会避免同一时刻登记多个工作循环回调,所以课程按钮同步调用两次 setter 时,两条 action 会在下一轮一起被处理。不过该实现没有生产 React 的优先级、Lane、渲染中更新和并发冲突处理,不能把它推广为完整批处理语义。

跟踪 Counter A 的一次更新

以页面中的两个计数器为例:

text 复制代码
1. 点击 Counter A
2. 两个 action 进入 A 当前 Hook 的 queue
3. enqueueRootUpdate() 创建 WIP root
4. App 新 Fiber 执行,读取 App 自己的旧 hooks
5. Counter A 新 Fiber 执行
   alternate → Counter A 旧 Fiber
   hooks[0] 从 0 归并为 2
6. Counter B 新 Fiber 执行
   alternate → Counter B 旧 Fiber
   queue 为空,state 仍为 0
7. Render 完成,Commit 中只有 A 的可见文本发生变化;示例里的内联事件函数引用也会更新
8. 新树成为 currentRoot

组件函数相同不会混淆状态,因为两个 Counter 有不同的 Fiber 和 alternate 链。由于 onClickonInput 在组件执行时被重新创建,updateDom() 还会解绑旧监听函数并绑定新函数;"只有 A 的文本变化"不等于 Commit 只调用了一次 DOM API。

Hook 顺序规则为什么仍然存在

Fiber 解决了"Hook 属于哪个组件",但组件内部仍要靠 hookIndex 区分第几个 Hook:

text 复制代码
组件身份:alternate
组件内槽位:hookIndex

因此条件 Hook 仍然错误:

js 复制代码
const [first] = useState(0);
if (enabled) useState("extra");
const [last] = useState(1);

enabled 改变后,last 对应的下标发生变化。Fiber 只能把你带回正确组件,不能猜出某次条件调用对应哪个变量。

最终版引入 useEffect 后,两种 Hook 还会共享同一个下标序列,因此稳定顺序更加重要。

教学实现的一个实用边界

本章每次渲染都会创建新的 setter,它闭包捕获新的 Hook;DOM 属性 Diff 会把旧事件处理器替换成新处理器,所以页面上的按钮通常持有当前 setter。

如果业务代码把很早以前的 setter 长期保存到运行时之外,并在多次提交后调用,它可能把 action 放进已经过期的 Hook queue。真正 React 的更新队列和 dispatch 稳定性处理更复杂,本项目不模拟这个边界。

小结

  • 每个函数组件实例有自己的 Fiber 和 hooks 数组。
  • alternate 找旧组件,hookIndex 找组件内旧 Hook。
  • setter 先把 action 放入当前 Hook queue,再安排根更新。
  • 新 Hook 从旧 state 开始按顺序归并 action,提交后成为下一轮基线。
  • Fiber 隔离了组件实例,但组件内部仍必须保持 Hook 调用顺序。

到这里,状态变化已经能走完"入队 → Render → 协调 → Commit"。最后还缺一类不能在纯 UI 计算中完成的工作:订阅、定时器和外部系统同步。下一篇用 useEffect 补上闭环。

相关推荐
默_笙32 分钟前
😭 Vibe Coding 翻车实录:AI 编程为什么必须先写"剧本"
前端·javascript
LEE34 分钟前
原来 Claude Code 最厉害的工具,一行代码都不写
前端·后端
parade岁月37 分钟前
为了给表格加虚拟滚动而选了 vxe-table?也可以考虑下这个
前端·vue.js
打呵欠的猫40 分钟前
一个 Hook 让 AI 每次写文件前自动检查编码规范,违规代码再也提交不进来
前端·ai编程·代码规范
恋猫de小郭1 小时前
Android 17 + OkHttp 5.5.0 ,全新 ECH 下你的 HTTPS 域名可以请求时被安全隐藏
android·前端·flutter
后除1 小时前
从零到一: 创建一个 TypeScript 7 项目
前端·webpack·typescript
lerhxx1 小时前
我用 R3F 手搓了一个能走进去的 3D 迷宫简历(上):从选型架构到迷宫生成算法
前端·javascript·three.js
还有多久拿退休金1 小时前
不调多模态,纯文本大模型如何给系统操作配上截图
前端·llm·aigc
hunterandroid2 小时前
HarmonyOS WebSocket 实战:断线重连、心跳保活与连接状态机设计
前端