React:FiberNode 与 React 组件之间的对应关系
拿一个七行的 Counter 对着 React 18.2 的源码数:它在 Fiber 树里是六个节点,state 挂在 Hook 链表上,点一次按钮被打标记的既不是组件也不是 <p>。
Fiber 不用靠想象。在任何一个 React 页面的控制台里,选中一个 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.js(const randomKey = Math.random().toString(36).slice(2)),为的是不和页面上别的脚本撞名字。展开拿到的对象,下面讲的每个字段都能对上。
这篇拿来数的组件是它:
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 树里它是实打实的一个节点,Counter 的 child 指的是它,不是 <p>。
第二处是 <p> 底下比看上去多。<p>Count: {count}</p> 的 children 是一个长度为 2 的数组——字符串 'Count: ' 和数字 count——两个都会各自生成一个 HostText Fiber。
而 <button>Increment</button> 底下反倒一个子节点都没有。区别在 shouldSetTextContent(ReactDOMHostConfig.js):children 是字符串或数字本身时它返回 true,beginWork 里的 updateHostComponent 就把 nextChildren 置成 null,文本直接由宿主环境写进 DOM,省掉一个 Fiber 和一趟遍历。<p> 拿到的是数组,走不到这条捷径。
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.js:FunctionComponent = 0、HostRoot = 3、HostComponent = 5、HostText = 6。这张表里 20 是空的,FundamentalComponent 被删掉之后号没有补上。
type 存的是函数本身,分类归 tag 管
Counter 这个 Fiber 的 type 就是 Counter 函数的引用,el[key].type === Counter 在控制台里是 true。宿主节点的 type 则是字符串 'div'、'p'。
旁边还有个 elementType。多数时候两者相等,分岔发生在 React.memo、React.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) 造出来的那个是这样的:
{ 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, next,queue 在 next 前面。这五个字段没有一个是可选的,所以链表节点的形状永远一致,引擎不用为它反复改隐藏类。
有两个字段值得单挑出来说。baseQueue 和 baseState 是并发渲染留下的痕迹:一次渲染里优先级不够的 update 会被跳过而不是丢掉,跳过的那些存进 baseQueue,baseState 记下跳过之前的值,下一轮从这个起点重新算一遍——少了这两个字段,被跳过的更新就只能整个作废。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 = 0、Placement = 0b10、Update = 0b100,用按位或往上叠,workInProgress.flags |= Update。所以控制台里看到的是数字 4,不是 "Update"。
现在回到点击。count 从 0 变成 1,被打上 Update 的是存着这个数字的那个 HostText Fiber,不是 <p>,更不是 Counter。判定写在 completeWork 的 case HostText 里:拿 current.memoizedProps 当旧文本,和新文本一比,不同才 markUpdate。<p> 自己的 props 一个都没变,updateHostComponent 算出来的 diff 是空的,它身上不会有任何标记。
这就是「粒度」在 Fiber 上的具体样子:改一个数字,整棵树里只有一个节点带标记,提交阶段只有它对应的那次 DOM 写入真的发生。subtreeFlags 是配套的另一半——父节点上按位或了整棵子树的标记,提交时看一眼就知道这个分支能不能整个跳过。
类型上 34 个字段,构造函数最多填 30 个
ReactInternalTypes.js 里的 Fiber 类型声明了 34 个字段:25 个常规字段,加 4 个只在 enableProfilerTimer 下有值的计时字段,再加 5 个 _debug 开头的。
ReactFiber.new.js 的 FiberNode 构造函数无条件赋值的是 22 个,profiler 分支里再加 4 个,DEV 分支里再加 4 个。对不上的是这四个:nextEffect、firstEffect、lastEffect、_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 完结:最佳实践与注意事项。