对应代码:
lessons/08-fiber-hooks
第 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]));
}
每个函数组件开始时:
workInProgressFiber指向当前组件 Fiber;- 组件内 Hook 下标重置为 0;
- 新 Fiber 创建自己的空
hooks数组; - 执行组件,期间每次 Hook 调用都向该数组追加一项;
- 组件返回后清空当前 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,然后以 currentRoot 为 alternate 创建新的 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 链。由于 onClick、onInput 在组件执行时被重新创建,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 补上闭环。