React 17 发布已经半年多,这个版本本身没什么新 API,官方的说法是为后续升级做铺垫。铺垫的结果今天落地:React 团队发布 The Plan for React 18,正式公布 React 18 Alpha,npm install react@alpha react-dom@alpha 就能装到,同时组建 React 18 Working Group 让社区提前试用和反馈。公告回答了「18 要交付什么」:以 createRoot 为入口的并发渲染、自动批处理、新的 SSR,但怎么实现的公告不会讲,得去 master 分支上看。最近几个月 master 上 concurrent 相关的提交明显多了起来:新的 feature flag 不断出现,OffscreenComponent、CacheComponent 这些没在任何文档里出现过的组件类型进了 reconciler,Suspense 相关的代码改动也很频繁。这个系列就是我的阅读笔记,追踪方式是 alpha 迭代期间每隔一段时间 pin 一个 master 的 commit,从 reconciler 的数据结构开始,逐步读到优先级模型、调度决策和工作循环。
本篇的代码快照是 master 分支的 e0d9b28999,6 月 3 日的提交,也就是 alpha 公布前几天的状态。所有引用都给仓库相对路径和函数名,读者可以自己 checkout 出来对照。
系列目录
| 日期 | 标题 |
|---|---|
| 06-08 | 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构(本篇) |
递归为什么撑不起可中断渲染
React 15 及之前的 reconciler 是递归实现的,社区后来把它叫 Stack Reconciler。diff 一棵组件树就是一次深度优先的递归调用:处理一个组件时,它产出的子元素作为参数继续递归,整棵树的遍历进度保存在 JS 引擎的调用栈里。这个实现直接,但有一个绕不开的限制:递归一旦开始,只能跑到结束。遍历到一半发现浏览器这一帧的时间用完了,想停下来把主线程还给用户输入,进度记在调用栈上,栈一退就全没了,下一帧只能从头再来。
这个递归在代码里是实打实的函数调用。v15.6.2 的 src/renderers/shared/stack/reconciler/ReactCompositeComponent.js 里,mountComponent 先调 render 拿到子元素,就地 _instantiateReactComponent 实例化,紧接着 ReactReconciler.mountComponent 挂载这个孩子,孩子的挂载函数里再重复同样的三步。组件树有多深,调用栈就有多深,中间不存在任何可以交出去的进度句柄。
Fiber 架构随 React 16 在 2017 年落地,做的事情就是把调用栈显式化。每个组件对应一个堆上的对象,叫 Fiber,遍历进度由一个普通指针持有。这样处理到任何一个节点都可以暂停,把指针留在那里,下一帧从同一个节点继续;来了更高优先级的更新,也可以把整棵渲染中的树丢掉,从根重新来。这些能力的前提是「栈」不再依赖 JS 引擎,而是 reconciler 自己管理的数据结构。16 发布时这套能力没有对外开放,工作循环仍然同步跑完整棵树,可中断的基础设施先铺在那里,等后续的并发模式来用。
所以读 React 18 时代的源码,第一站不是调度器,是这个数据结构长什么样。
Fiber 的字段分组
Fiber 的类型定义在 packages/react-reconciler/src/ReactInternalTypes.js 的 Fiber 类型里,对应的对象由 packages/react-reconciler/src/ReactFiber.new.js 的 FiberNode 构造函数创建。构造函数里一共 34 个 this 赋值,按用途可以分成几组。
实例字段:tag、key、elementType、type、stateNode。tag 是这个 Fiber 的种类,取值定义在 packages/react-reconciler/src/ReactWorkTags.js,从 0 到 24 共 25 个编号(20 号空着,实际 24 个常量),FunctionComponent 是 0,ClassComponent 是 1,HostComponent(DOM 元素)是 5,还有一批没开放过的:OffscreenComponent 是 22,LegacyHiddenComponent 是 23,CacheComponent 是 24。elementType 和 type 容易混,类型注释把分工写得很清楚:elementType 是 element.type 的原始值,reconcile 时用它判断两个元素是不是同一种东西;type 是解析之后的值,lazy、memo 这类包装拆掉之后真正要调用的函数或类。stateNode 指向这个 Fiber 对应的真实对象,DOM 元素就是 DOM 节点,class 组件就是组件实例。
树结构字段:return、child、sibling、index。下一节单独说。
状态字段:pendingProps、memoizedProps、memoizedState、updateQueue、dependencies。pendingProps 是这次渲染传入的 props,memoizedProps 是上次用来产出的 props,两者相等是 bailout 的依据之一。updateQueue 的结构随 tag 变化,class 组件上挂的是 setState 产生的更新链表,函数组件上挂的是 effect 列表。dependencies 的注释写着「Dependencies (contexts, events) for this fiber」,记录这个 Fiber 订阅的 context,bailout 时要核对这些依赖有没有变。
副作用字段:flags、subtreeFlags、deletions。flags 是位掩码,常量定义在 packages/react-reconciler/src/ReactFiberFlags.js,一共用了 23 个二进制位,Placement(新插入)、Update、ChildDeletion、Passive(有 useEffect 之类的被动副作用)各占一位。
优先级字段:lanes、childLanes。这是 React 16 时代 expirationTime、childExpirationTime 两个字段的替代者,具体变化后面交代。
另外还有 mode 和 alternate。mode 也是位掩码,定义在 packages/react-reconciler/src/ReactTypeOfMode.js,标记这棵子树运行在哪种模式下。除了 ConcurrentMode、StrictLegacyMode、ProfileMode 三个熟的,文件里还有几个新位:StrictEffectsMode 配合 enableStrictEffects,在开发环境让 effect 的创建和清理多跑一轮,提前暴露清理函数没写对的组件;ConcurrentUpdatesByDefaultMode 由 createFiber 里一段条件控制,条件中用到了 feature flag allowConcurrentByDefault,旁边的注释写明只供内部实验。alternate 是双缓冲的另一半,也放在后面单独说。
类型文件里除 Fiber 外还有一个 FiberRoot,它不代表组件,代表一次挂载的根。上面挂着 containerInfo 和指向 HostRoot Fiber 的 current 指针,整棵树待处理的优先级也记在这里,后面讲字段变迁时会回到它。
三个指针替代调用栈
树用链表表达:child 指向第一个孩子,其余孩子串在 sibling 单向链表上,每个孩子用 return 指回父节点。ReactInternalTypes.js 里 return 字段的注释写得很直白:
It is conceptually the same as the return address of a stack frame.
概念上等同于栈帧的返回地址。这句话把 Fiber 的设计意图说完了:递归调用里由引擎维护的东西,返回地址、局部状态、遍历进度,全部搬进了显式对象。深度优先遍历用三个指针就能走完,进入孩子走 child,孩子处理完去兄弟走 sibling,没有兄弟了就沿 return 回到父节点,全程不需要函数递归。
第四个字段 index 不参与遍历,它记的是节点在兄弟中的位置。packages/react-reconciler/src/ReactChildFiber.new.js diff 孩子列表时,拿复用节点的 oldFiber.index 和新位置比较,位置没有前移的节点可以原地复用,不用打 Placement 标记,只有移动过的才需要。兄弟链表的顺序加上这个序号,就是列表 diff 判断「谁动了」的依据。
这个结构的代价也明显:兄弟节点查找是 O(n) 的,没有 childNodes 数组那种随机访问。React 接受这个代价,换来的是遍历过程可以随时停在任何一个节点上,状态都在对象里,中断和恢复就是保存和恢复一个指针。
工作循环具体怎么用这套结构,可以先留个印象。packages/react-reconciler/src/ReactFiberWorkLoop.new.js 里有一个模块级变量 workInProgress,充当遍历的当前位置,相当于栈顶。performUnitOfWork 对它调 beginWork 向下处理,走到底了就在 completeUnitOfWork 里向上完成,沿 sibling 和 return 找下一个未处理的节点。时间片用完时循环退出,这个指针留在原地,下一轮从同一个节点接着走;更高优先级更新进来时这次渲染整体丢弃,从根重新来。循环本身的细节是这个系列后面一篇的内容。
双缓冲:alternate
每个 Fiber 最多有一个 alternate,两棵树的同名节点通过它互相引用。一棵是 current,对应当前屏幕上显示的内容;另一棵是 workInProgress,渲染进行中的树。更新来了以后,React 在 workInProgress 树上做全部计算,不碰 current 树;commit 阶段把变更一次性应用上去,然后翻转,workInProgress 变成新的 current,旧的 current 降格为下一轮的 workInProgress。
ReactFiber.new.js 的 createWorkInProgress 是这个机制的核心,开头的注释说明了动机:
We use a double buffering pooling technique because we know that we’ll only ever need at most two versions of a tree.
一棵树的版本最多两个,所以 workInProgress 不新建对象,直接复用 alternate 指向的那个旧对象,重置上面的字段接着用。只有第一次渲染、alternate 还不存在时,才走另一条分支调 createFiber 新建一个:
} else {
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
// We already have an alternate.
// Reset the effect tag.
workInProgress.flags = NoFlags;
// The effects are no longer valid.
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
}注意这个重置只动和渲染结果相关的字段。return、child、sibling 这些树结构指针原样保留,孩子节点在下一轮 beginWork 里按需逐个克隆。开着 profiler 构建时,这里还会顺手把 actualDuration、actualStartTime 归零,源码注释解释了原因:防止耗时统计跨渲染无限累积。
这样做有两个直接收益。一是少分配对象,一棵大树的每次更新不用重新创建几千个节点,GC 压力小很多。二是可中断渲染有了落点:workInProgress 树上的半成品状态随时可以整体丢弃,屏幕上的 current 树从头到尾没被碰过,丢弃的成本就是下一轮渲染从头克隆。
.new / .old 双 fork
看目录时最先注意到的事:packages/react-reconciler/src/ 下 96 个源码文件,绝大多数是成对出现的,ReactFiber.new.js 和 ReactFiber.old.js、ReactFiberBeginWork.new.js 和 ReactFiberBeginWork.old.js,一路排下去。少数文件没有 fork,ReactWorkTags.js、ReactTypeOfMode.js、ReactFiberFlags.js、ReactInternalTypes.js 都是单文件,两边共用。
选择哪份代码发生在构建期。scripts/rollup/forks.js 里有一组替换规则,按 bundle 的 enableNewReconciler 标记和 bundle 类型决定 react-reconciler/src/ReactFiberReconciler 解析到 .new 还是 .old。也就是说两份 reconciler 不会同时进一个包,构建产物里只有一份。
这个 fork 结构从 React 17 时代就存在了。谁是 .new 的用户,scripts/rollup/bundles.js 里写得很清楚:全部 bundle 配置里只有一条带 enableNewReconciler: true,注释是「React DOM - www - Uses forked reconciler」,全局名 ReactDOMForked,bundle 类型只有 FB_WWW 三种。也就是说眼下 .new fork 的实际用户只有 Facebook 内部的 www 构建,npm 上发布的稳定版和实验版跑的是同一份 .old。实验版和稳定版的差别由 feature flag 区分,比如 enableCache 在共享的 flags 文件里是 __EXPERIMENTAL__,只在实验构建打开,allowConcurrentByDefault 目前还是 false。
把同名的 .new 和 .old 文件 diff 一遍,能印证这个分流还没真正开始:两边内容基本一样,差别只剩 import 路径的后缀,团队在机械地保持同步。fork 的价值在将来,等并发特性的行为分歧大到 flag 盖不住的时候,差异就会落进 .new 文件里。读并发相关的代码时,文件基本都要带 .new 后缀找。
字段变迁:lanes 与 subtreeFlags / deletions
Fiber 的字段这两年动过两轮,写文章时容易被当成新东西,实际都不是。把 git log 翻出来核对,时间线是这样的。
第一轮是优先级模型。React 16 时代 Fiber 上是 expirationTime 和 childExpirationTime 两个数字,表示这次更新的过期时间,时间越近优先级越高,v16.13.1 的 ReactFiber.js 里还能看到。2020 年 5 月,commit 93e078ddf2(Initial Lanes implementation)把它们换成了 lanes 和 childLanes,类型是一个 31 位的位掩码(ReactFiberLane.new.js 里写作 TotalLanes = 31)。这次改动已经随 React 17 进了稳定版源码,v17.0.2 的 Fiber 上就是 lanes 字段。只是 17 没有开放并发入口,lanes 在里面基本以同步方式被使用,多数人没有感知。到我现在看的 master,FiberRoot 上挂着 pendingLanes、suspendedLanes、pingedLanes、expiredLanes、entangledLanes 一组字段,整套优先级机制已经围绕位运算展开,这是下一篇的主题。
第二轮是副作用的记录方式。React 16 的做法是 render 阶段把有副作用的 Fiber 串成一条 firstEffect 到 lastEffect 的链表,commit 阶段顺着链表处理,不用重新遍历整棵树。2020 年 7 月到 9 月,几个 commit 把这条链表拆掉了:a226b9b445 开始引入 deletions 数组,8 月的 d2e914ab4e 移除了 effect 链表的剩余引用,9 月的 1665443603 把字段重命名成现在的样子。现在的方案是:每个 Fiber 自己带 flags,subtreeFlags 记录整棵子树的副作用聚合,被删除的孩子记在父节点的 deletions 数组里。commit 阶段重新遍历树,靠 subtreeFlags 跳过没有变化的子树。
聚合发生在 packages/react-reconciler/src/ReactFiberCompleteWork.new.js 的 bubbleProperties,一个节点完成时把孩子的副作用位合并到自己身上。合并到根以后,commit 阶段遇上一个 subtreeFlags 为零的节点,整棵子树直接跳过,不必再维护一条贯穿全树的链表:
let child = completedWork.child;
while (child !== null) {
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
// ...
child = child.sibling;
}有个考古细节能印证这次重构的时间点。ReactInternalTypes.js 的 Fiber 类型里至今还声明着 nextEffect、firstEffect、lastEffect 三个字段,注释还写着「Singly linked list fast path to the next fiber with side-effects」,但 FiberNode 构造函数早就不初始化它们了,工作循环里也不再使用。对照 v16.13.1 的 ReactFiber.js 能看得更清楚:那个版本的构造函数里,这三个字段还老老实实初始化成 null,是真实干活的字段,现在的构造函数里这三行已经删掉了。源码里残留的几个同名引用都是别的东西:commit 阶段有个局部变量叫 nextEffect,updateQueue 上还挂着 hook effect 的环形链表。类型声明比实现慢了将近一年,也算 master 分支的常态。
下一篇读 packages/react-reconciler/src/ReactFiberLane.new.js,看那 31 个二进制位具体怎么分组,以及 getHighestPriorityLane 这类位运算为什么比过期时间的比较更适合多优先级并存。

