React 18二:Fiber 架构

Fiber 节点就是一个普通对象,字段名能直接去 ReactInternalTypes.js 里对上。这一篇拆节点上的三个指针和双缓冲、beginWork 下行 completeWork 上行的那趟循环,以及为什么首次挂载其实停不下来。

位置
第 02 篇 / 共 6 篇
预计
9 分钟
系列
React 18

Fiber 不是抽象概念,是页面上真实存在的一堆对象,而且你现在就能把它捞出来看。React 把每个宿主 DOM 节点对应的 Fiber 挂在这个 DOM 节点自己身上,属性名是 __reactFiber$ 加一段随机后缀:

在控制台里捞一个 Fiber 出来
const el = document.querySelector('#root').firstElementChild;
const key = Object.keys(el).find((k) => k.startsWith('__reactFiber$'));
const fiber = el[key];
console.log(fiber.type, fiber.child, fiber.sibling, fiber.return);

打出来的是一个再普通不过的对象。type'div' 这样的字符串,或者你自己写的那个组件函数;childsiblingreturn 指向另外三个同样的对象。整棵组件树在内存里就是这些对象互相指着。

上一篇说 Fiber 把调用栈搬到了堆上,这一篇就是把那个「堆上的栈帧」逐个字段打开。

一个 Fiber 节点上有哪些字段

类型定义在 packages/react-reconciler/src/ReactInternalTypes.js,不带 .new / .old 后缀 —— 它是共享的类型文件。

ReactInternalTypes.js · Fiber(节选,类型有简化,字段顺序照原文)
export type Fiber = {|
tag: WorkTag, // 函数组件、类组件还是宿主组件
key: null | string, // 列表里写的那个 key
elementType: any, // JSX 里写的 type,被 memo / forwardRef 包过就是外层那个
type: any, // 解包之后的真身:组件函数本身,或者 'div'
stateNode: any, // 对应的实体:宿主组件是 DOM 节点,类组件是实例
return: Fiber | null, // 父节点
child: Fiber | null, // 第一个子节点
sibling: Fiber | null, // 下一个兄弟节点
index: number,
ref: any,
pendingProps: any, // 这次渲染要用的 props
memoizedProps: any, // 上次渲染用过的 props
updateQueue: mixed, // 待处理的更新,链表
memoizedState: any, // 上次渲染留下的 state;函数组件里这里是 hook 链表的头
dependencies: Dependencies | null,
mode: TypeOfMode,
flags: Flags, // 这个节点自己要干的活:新增 / 更新 / 删除
subtreeFlags: Flags, // 子树里还有没有活 —— 没有就整棵跳过
deletions: Array<Fiber> | null,
lanes: Lanes, // 这个节点上待处理更新的优先级
childLanes: Lanes, // 子树里待处理更新的优先级
alternate: Fiber | null, // 上一次渲染时的那个自己
|};

高亮的六行是这一篇要讲的全部内容:三个指针决定怎么走,两个 flags 决定走的时候能不能跳过,alternate 决定中途反悔了怎么办。

其余的字段大致分三拨:描述组件本身的(tagtypekey),存渲染结果的(memoizedPropsmemoizedStateupdateQueue),以及调度用的(laneschildLanesmode)。类型里还留着 nextEffectfirstEffectlastEffect 三个旧字段,上面没有列。开发构建下还会多出 _debugSource_debugOwner 这些,profiler 打开时多出 actualDuration 一组。

memoizedState 值得单独记一句:类组件里它是 state 对象,函数组件里它是 hook 链表的表头。 「hook 不能写在条件语句里」这条规矩的根就在这儿 —— 链表按调用顺序串起来,靠顺序对位,不靠名字。

三个指针把树压成了一条可以中途停下的路径

return / child / sibling 是普通的树结构,但组合起来的效果是:任何时刻只要记住一个节点,就能接着往下走完全程。

一段这样的 JSX:

三层结构
<App>
<Nav />
<Main>
<Article />
</Main>
</App>

在 Fiber 里连成这样:

指针的实际指向
App.child = Nav
Nav.sibling = Main
Nav.return = App
Main.return = App
Main.child = Article
Article.return = Main

注意 ArticleMain 都没有 sibling。遍历的规则因此只有三条:child 就往下,没有就看 sibling,都没有就顺着 return 往上退一层再看兄弟。 走到根节点还退不动,整棵树就走完了。

这三条规则不需要调用栈 —— 当前在哪个节点,是一个模块级变量就能存住的东西。它的名字叫 workInProgress

同一个组件同时有两个 Fiber

alternate 指向「上一次渲染时的那个自己」。屏幕上正显示的那棵树叫 current,正在构建的那棵叫 workInProgress,两棵树的节点通过 alternate 两两互指。

这就是双缓冲。React 不在 current 树上直接改,而是复用 alternate 指向的那个对象、填上新的 props 和 state,建出另一棵树;等整棵建完了,把根节点的指针一翻,workInProgress 就成了 current。

好处是中断变得没有代价:一次渲染被打断、被丢弃、或者被更高优先级的更新顶掉,屏幕上那棵 current 树一个字段都没动过。 用户不会看到半棵树。代价是每个组件常驻两个对象,内存翻倍 —— 这是可中断渲染的定价的第二笔(第一笔是上一篇说的那个:把栈帧变成对象)。

建 workInProgress 节点的函数叫 createWorkInProgress,在 packages/react-reconciler/src/ReactFiber.new.js。名字里的 .new 不是笔误:18.2.0 的 reconciler 目录下每个文件都有 .new.js.old.js 两份,靠 feature flag 二选一编译。文中提到的 reconciler 源码路径都按 v18.2.0 的树写。

渲染这一趟:下行 beginWork,上行 completeWork

遍历跑在 packages/react-reconciler/src/ReactFiberWorkLoop.new.js 里。整篇文章的重点就是下面这十几行:

ReactFiberWorkLoop.new.js · 两个工作循环(performUnitOfWork 有简化)
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate;
const next = beginWork(current, unitOfWork, subtreeRenderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
completeUnitOfWork(unitOfWork); // 没有子节点了,改成上行
} else {
workInProgress = next; // 有子节点,继续往下
}
// 省略:开发构建下的计时和 currentDebugFiber 维护
}

两个循环的差别只有 && !shouldYield() 这一个条件。 整个「可中断」就是这几个字符,其余部分一模一样。

一趟渲染的形状是这样的:

  • 下行beginWork 处理一个节点,跑组件函数或者 render 方法,把返回的 JSX 和上一次的子节点比一遍,产出子 Fiber,然后返回第一个子节点。返回什么,workInProgress 就变成什么。
  • 触底转向beginWork 返回 null 表示这个节点没有子节点了,转入 completeUnitOfWork
  • 上行completeWork 给宿主组件创建真实 DOM 实例、把子节点挂上去、把自己的 flags 往父节点的 subtreeFlags 上冒泡。然后看有没有 sibling,有就把 workInProgress 换成兄弟节点重新下行,没有就顺着 return 继续往上。

subtreeFlags 的冒泡是这趟上行真正的产物之一。有了它,下一次渲染走到某个节点时,一看子树的 flags 是空的就能整棵跳过,不必进去逐个确认。

顺带纠正一个容易记反的地方:shouldYield 不是 reconciler 自己的函数。ReactFiberWorkLoop.new.js 是从 ./Scheduler 导入它的,而那个文件里写的是 export const shouldYield = Scheduler.unstable_shouldYield; —— 一路转到 scheduler 包里。判断该不该让出主线程的逻辑,一行都不在 React 这边。

首次挂载其实是停不下来的

上面两个循环里,首次挂载走的是 workLoopSync,不是 workLoopConcurrent

决定权在 performConcurrentWorkOnRoot 的这一行:

ReactFiberWorkLoop.new.js · 走不走时间切片
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
(disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);

includesBlockingLane 在 lanes 里含有 InputContinuousLane 或者 DefaultLane 时返回真(还有对应的两条 hydration lane)。而 createRoot(...).render(<App />) 这次更新拿到的正是 DefaultLane。于是 shouldTimeSlice 为假,走 renderRootSyncworkLoopSync —— 那个循环里根本没有 shouldYield()

这个判断里有个逃生口:allowConcurrentByDefault。它打开时 includesBlockingLane 可以提前返回假,让默认更新也走时间切片。但 packages/shared/ReactFeatureFlags.js 里写死了 export const allowConcurrentByDefault = false;,稳定版用不上。

所以第一篇那句「换到 createRoot 之后默认行为和以前基本一致」在源码层面是这么落实的。React 18 的发布博客把这件事说得很直白:在用上任何并发特性之前,更新的渲染方式和旧版本一样,是一次不可打断的同步事务。

真正走 workLoopConcurrent 的只有非阻塞的那几类 lane:transition、retry、idle。想让一次更新可中断,办法只有一个 —— 用 startTransition 把它标出去。第三篇会把这条阶梯完整排一遍。

commit 为什么必须一口气做完

渲染阶段结束时,得到的是一棵打好标记的 workInProgress 树,DOM 还一点没动。把标记落到 DOM 上是 commitRoot,它分成三段,函数都在 packages/react-reconciler/src/ReactFiberCommitWork.new.js

阶段 函数 干什么
变更前 commitBeforeMutationEffects DOM 还是旧的,getSnapshotBeforeUpdate 在这里跑
变更中 commitMutationEffects 真正的插入、更新、删除;这一步跑完 DOM 就是新的了
变更后 commitLayoutEffects componentDidMountcomponentDidUpdateuseLayoutEffect

current 指针的翻转发生在变更中和变更后之间 —— componentDidMount 里读到的必须已经是新树。

这三段全程不让出主线程,理由很直接:DOM 改到一半让出去,浏览器就会把那个中间状态画到屏幕上。 渲染阶段可以随便丢弃重来,是因为它只动内存里的对象;commit 一旦开始,副作用就已经发生了,没有「丢弃重来」这回事。

这条分界线也解释了并发渲染的适用范围:它能让长渲染不卡页面,但对「commit 本身很重」的场景无能为力。一次性往 DOM 里插两万个节点,React 18 和 React 17 一样卡 —— 该做的是别插两万个节点。

错误边界拦得住的和拦不住的

Fiber 的报错处理和 Suspense 共用一条通道:渲染中抛出的东西被 throwExceptionpackages/react-reconciler/src/ReactFiberThrow.new.js)接住,然后顺着 return 指针往上找,看有没有哪个祖先能处理。抛的是 Promise 就找 Suspense 边界,抛的是错误就找错误边界。

先澄清一件事:React 没有导出任何叫 ErrorBoundary 的组件。 错误边界不是一个 API,是一种约定 —— 一个类组件只要实现了下面两个方法之一,它就成了错误边界。

错误边界的最小实现
class ErrorBoundary extends React.Component {
state = { error: null };
// 渲染期调用,返回值合并进 state,用来切到备用 UI
static getDerivedStateFromError(error) {
return { error };
}
// commit 之后调用,用来上报;这里做副作用是安全的
componentDidCatch(error, info) {
report(error, info.componentStack);
}
render() {
if (this.state.error) return this.props.fallback;
return this.props.children;
}
}

两个方法的分工是时机:getDerivedStateFromError 在渲染期跑,所以不能有副作用;componentDidCatch 在 commit 之后跑,上报日志要写在这里。

函数组件写不了错误边界。 到 18 为止没有对应的 Hook,官方文档给的建议是直接用 react-error-boundary 这个社区库。

代价那一半:错误边界只管渲染期间、生命周期方法和构造函数里抛出的错误。下面这些它一概接不住 ——

  • 事件处理器里的错误。 点击回调里抛异常不经过渲染流程,该用 try / catch
  • 异步代码里的错误。 setTimeout.then 回调里抛的错误,抛出时那次渲染早结束了。
  • 服务端渲染时的错误。
  • 边界自己抛的错误。 它只捕子树,捕不了自己,所以备用 UI 要写得足够简单。

漏得最多的是前两条,而它们恰好是业务代码里最常出错的地方。「加了错误边界就不白屏了」这个预期,一半时候会落空。

节点的结构、遍历的形状、中断的位置都在这儿了。剩下的问题是:谁来决定一次更新走哪个循环、什么时候喊停 —— 那是 Scheduler 的活。

这篇是 React 18的第 2 篇。前一篇是 React 18 一:架构与核心概念,后一篇是 React 18三:Concurrent Mode