React:FiberNode 与 React 组件之间的对应关系

拿一个七行的 Counter 对着 React 18.2 的源码数:它在 Fiber 树里是六个节点,state 挂在 Hook 链表上,点一次按钮被打标记的既不是组件也不是 <p>。

预计
6 分钟

Fiber 不用靠想象。在任何一个 React 页面的控制台里,选中一个 DOM 节点就能把它的 Fiber 掏出来:

控制台 · 从 DOM 节点拿到它的 Fiber
const el = document.querySelector('button');
const key = Object.keys(el).find((k) => k.startsWith('__reactFiber$'));
el[key];

__reactFiber$ 后面那串随机后缀每次加载页面都不一样,生成它的一行在 packages/react-dom/src/client/ReactDOMComponentTree.jsconst randomKey = Math.random().toString(36).slice(2)),为的是不和页面上别的脚本撞名字。展开拿到的对象,下面讲的每个字段都能对上。

这篇拿来数的组件是它:

Counter
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

Fiber 架构本身要解决什么问题、为什么要把树拍成链表,React 18二:Fiber 架构讲的是那一层;这篇只做一件事——逐个字段对到源码上。下面的路径和字段都按 v18.2.0 的树写。

这七行 JSX 是六个 Fiber

数节点最容易数错的地方有两处,而且方向相反。

第一处是漏掉 <div>。JSX 里它只是个包装,Fiber 树里它是实打实的一个节点,Counterchild 指的是它,不是 <p>

第二处是 <p> 底下比看上去多。<p>Count: {count}</p>children 是一个长度为 2 的数组——字符串 'Count: ' 和数字 count——两个都会各自生成一个 HostText Fiber。

<button>Increment</button> 底下反倒一个子节点都没有。区别在 shouldSetTextContentReactDOMHostConfig.js):children字符串或数字本身时它返回 truebeginWork 里的 updateHostComponent 就把 nextChildren 置成 null,文本直接由宿主环境写进 DOM,省掉一个 Fiber 和一趟遍历。<p> 拿到的是数组,走不到这条捷径。

Counter 首次渲染后的 Fiber 树
Counter tag=0 FunctionComponent type=Counter 函数
└─child→ div tag=5 HostComponent type='div'
└─child→ p tag=5 type='p'
│ └─child→ 'Count: ' tag=6 HostText
│ └─sibling→ 0 tag=6 HostText
└─sibling→ button tag=5 type='button'

每个节点还有一个 return 指回父节点,图里省了。child / sibling / return 三根指针把这棵树拍成了可以用循环走完的链表——这是 Fiber 能中途停下、之后接着走的前提,递归栈做不到这件事。

tag 的数值在 ReactWorkTags.jsFunctionComponent = 0HostRoot = 3HostComponent = 5HostText = 6。这张表里 20 是空的,FundamentalComponent 被删掉之后号没有补上。

type 存的是函数本身,分类归 tag 管

Counter 这个 Fiber 的 type 就是 Counter 函数的引用,el[key].type === Counter 在控制台里是 true。宿主节点的 type 则是字符串 'div''p'

旁边还有个 elementType。多数时候两者相等,分岔发生在 React.memoReact.lazy 这类包装上:elementType 存的是你写在 JSX 里的那个东西,type 存的是解析之后真正要渲染的那个。判断「这是个什么」不要看 type,看 tag

stateNode 挂的是这个 Fiber 对应的实例。宿主节点上是真的 DOM 元素——button 那个 Fiber 的 stateNode 就是你一开始 querySelector 到的那个节点,一个环。类组件上是组件实例。函数组件没有实例可挂,所以是 null,这也是为什么它的状态只能寄存在别的字段上。

pendingProps 是这一轮传进来的 props,memoizedProps 是上一轮用过的。构造函数里 memoizedProps 一律是 null,要等这个 Fiber 的 complete 阶段走完才被填上;<Counter /> 没传任何属性,填上的是元素身上那个空对象。两个字段并存不是冗余:bailoutHooks 之前那道 oldProps === newProps 的判断,比的就是它俩。

memoizedState 上挂的是 Hook 链表,不是 state

函数组件的 memoizedState 不存值,它指向第一个 Hook 对象,Hook 之间再用 next 串成单向链表。useState(0) 造出来的那个是这样的:

react-reconciler/src/ReactFiberHooks.new.js · mountState 造出来的 Hook
{
memoizedState: 0, // 这个 Hook 当前的值
baseState: 0, // 重放更新队列时的起点
baseQueue: null, // 上一轮没处理完、被跳过的 update
queue: {
pending: null, // 环形链表,存还没处理的 update
lanes: 0,
dispatch: setCount, // 就是解构出来的第二个返回值
lastRenderedReducer: basicStateReducer,
lastRenderedState: 0, // 用来做「值没变就别渲染」的提前判断
},
next: null, // 下一个 Hook;只有一个 Hook 所以是 null
}

字段顺序照抄 ReactFiberHooks.new.js 里的 Hook 类型声明——memoizedState, baseState, baseQueue, queue, nextqueuenext 前面。这五个字段没有一个是可选的,所以链表节点的形状永远一致,引擎不用为它反复改隐藏类。

有两个字段值得单挑出来说。baseQueuebaseState 是并发渲染留下的痕迹:一次渲染里优先级不够的 update 会被跳过而不是丢掉,跳过的那些存进 baseQueuebaseState 记下跳过之前的值,下一轮从这个起点重新算一遍——少了这两个字段,被跳过的更新就只能整个作废。lastRenderedState 则是 dispatchSetState 里那条捷径:新值和它用 Object.is 一比相等,这次 setCount 连调度都不发起。

链表这个结构直接解释了 Hook 为什么不能写进 if 里:React 不记 Hook 的名字,只按调用顺序沿 next 往下走。某一轮少调用一个,后面所有 Hook 都会认领到前一个的位置上。

effectTag 这个名字从 React 17 起就没有了

字段现在叫 flags。改名发生在 facebook/react#19755(2020-09-04 合入),同一个 PR 把 subtreeTag 一并改成了 subtreeFlags。16.x 的文章和 DevTools 截图里还是 effectTag,17.0.0 之后翻遍 Fiber 也找不到这个字段——对着旧文章在 18 的源码里搜,搜不到很正常。

它也不是字符串。ReactFiberFlags.js 里全是二进制常量,NoFlags = 0Placement = 0b10Update = 0b100,用按位或往上叠,workInProgress.flags |= Update。所以控制台里看到的是数字 4,不是 "Update"

现在回到点击。count 从 0 变成 1,被打上 Update 的是存着这个数字的那个 HostText Fiber,不是 <p>,更不是 Counter。判定写在 completeWorkcase HostText 里:拿 current.memoizedProps 当旧文本,和新文本一比,不同才 markUpdate<p> 自己的 props 一个都没变,updateHostComponent 算出来的 diff 是空的,它身上不会有任何标记。

这就是「粒度」在 Fiber 上的具体样子:改一个数字,整棵树里只有一个节点带标记,提交阶段只有它对应的那次 DOM 写入真的发生。subtreeFlags 是配套的另一半——父节点上按位或了整棵子树的标记,提交时看一眼就知道这个分支能不能整个跳过。

类型上 34 个字段,构造函数最多填 30 个

ReactInternalTypes.js 里的 Fiber 类型声明了 34 个字段:25 个常规字段,加 4 个只在 enableProfilerTimer 下有值的计时字段,再加 5 个 _debug 开头的。

ReactFiber.new.jsFiberNode 构造函数无条件赋值的是 22 个,profiler 分支里再加 4 个,DEV 分支里再加 4 个。对不上的是这四个:nextEffectfirstEffectlastEffect_debugIsCurrentlyTiming——构造函数从头到尾没碰过。

前三个是 18 之前那套 effect list 的遗物。老版本把有副作用的 Fiber 单独串成一条链表,提交时只走这条链;18 换成了 subtreeFlags,靠父节点上的位判断能不能跳过子树,链表就不需要了,但类型声明没跟着删。

它们死得比看上去彻底。DEV 分支的最后一行是 Object.preventExtensions(this),形状在构造那一刻就冻住了——既然 nextEffect 从来没被赋过值,它压根不是这个对象的属性,DEV 构建下想补一个都补不上。

顺带挡掉两个常见的误认。Fiber 上没有 context 字段ReactInternalTypes.js 里的 context 只出现在 BaseFiberRootProperties 上,那是 FiberRoot,整棵树只有一个,和每个组件一个的 Fiber 不是一回事;组件订阅的 context 挂在 fiber.dependencies 上。也没有 errorInfo 字段:那个名字只是 FiberRoot.onRecoverableError(error, errorInfo) 的参数名,错误边界捕获到的信息不存在 Fiber 上。

想确认这两条不用读源码,控制台里 'context' in el[key] 直接返回 false

这篇归在 React 与生态下。同一主题里最近的另外两篇是 Jotai v2:React 状态管理的新篇章React 18 完结:最佳实践与注意事项