React 18二:Fiber 架构
Fiber 节点就是一个普通对象,字段名能直接去 ReactInternalTypes.js 里对上。这一篇拆节点上的三个指针和双缓冲、beginWork 下行 completeWork 上行的那趟循环,以及为什么首次挂载其实停不下来。
Fiber 不是抽象概念,是页面上真实存在的一堆对象,而且你现在就能把它捞出来看。React 把每个宿主 DOM 节点对应的 Fiber 挂在这个 DOM 节点自己身上,属性名是 __reactFiber$ 加一段随机后缀:
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' 这样的字符串,或者你自己写的那个组件函数;child、sibling、return 指向另外三个同样的对象。整棵组件树在内存里就是这些对象互相指着。
上一篇说 Fiber 把调用栈搬到了堆上,这一篇就是把那个「堆上的栈帧」逐个字段打开。
一个 Fiber 节点上有哪些字段
类型定义在 packages/react-reconciler/src/ReactInternalTypes.js,不带 .new / .old 后缀 —— 它是共享的类型文件。
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 决定中途反悔了怎么办。
其余的字段大致分三拨:描述组件本身的(tag、type、key),存渲染结果的(memoizedProps、memoizedState、updateQueue),以及调度用的(lanes、childLanes、mode)。类型里还留着 nextEffect、firstEffect、lastEffect 三个旧字段,上面没有列。开发构建下还会多出 _debugSource、_debugOwner 这些,profiler 打开时多出 actualDuration 一组。
memoizedState 值得单独记一句:类组件里它是 state 对象,函数组件里它是 hook 链表的表头。 「hook 不能写在条件语句里」这条规矩的根就在这儿 —— 链表按调用顺序串起来,靠顺序对位,不靠名字。
三个指针把树压成了一条可以中途停下的路径
return / child / sibling 是普通的树结构,但组合起来的效果是:任何时刻只要记住一个节点,就能接着往下走完全程。
一段这样的 JSX:
<App> <Nav /> <Main> <Article /> </Main></App>在 Fiber 里连成这样:
App.child = NavNav.sibling = MainNav.return = AppMain.return = AppMain.child = ArticleArticle.return = Main注意 Article 和 Main 都没有 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 里。整篇文章的重点就是下面这十几行:
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 的这一行:
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 为假,走 renderRootSync → workLoopSync —— 那个循环里根本没有 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 |
componentDidMount、componentDidUpdate、useLayoutEffect |
current 指针的翻转发生在变更中和变更后之间 —— componentDidMount 里读到的必须已经是新树。
这三段全程不让出主线程,理由很直接:DOM 改到一半让出去,浏览器就会把那个中间状态画到屏幕上。 渲染阶段可以随便丢弃重来,是因为它只动内存里的对象;commit 一旦开始,副作用就已经发生了,没有「丢弃重来」这回事。
这条分界线也解释了并发渲染的适用范围:它能让长渲染不卡页面,但对「commit 本身很重」的场景无能为力。一次性往 DOM 里插两万个节点,React 18 和 React 17 一样卡 —— 该做的是别插两万个节点。
错误边界拦得住的和拦不住的
Fiber 的报错处理和 Suspense 共用一条通道:渲染中抛出的东西被 throwException(packages/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。