React 18 追踪:Offscreen 从 Suspense 拆出,兼系列四个月回顾

3 分钟阅读
·

这是系列第 16 篇,也是最后一篇。6 月 8 日 Alpha 公布当天发第一篇,从 Fiber 数据结构开始追 master 分支,到现在快四个月,该收个尾了。收尾的对象选 Offscreen。第 2 篇讲 Lane 位图时提过最高位的 OffscreenLane,当时的注释只写了「Offscreen 组件后台预渲染用」,组件本体还没有影子。三个多月过去,unstable_Offscreen 已经能从 react 包里导出,可见性切换的 effect 语义补了三轮,已经能看清完整形态。参考代码是 master 分支 2825a08dc0,9 月 30 日傍晚的提交。

系列目录

日期 标题
06-08 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构
06-10 React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime
06-17 React 18 追踪:Lane 模型(下):调度决策与饥饿保护
06-24 React 18 追踪:Concurrent 工作循环与时间切片
07-01 React 18 追踪:createRoot 转正,ReactDOM.render 进入废弃警告
07-08 React 18 追踪:flushSync 统一同步刷新入口
07-15 React 18 追踪:自动批处理与交错更新队列
07-22 React 18 追踪:Suspense 的挂起与恢复
08-05 React 18 追踪:useTransition 与 useDeferredValue
08-12 React 18 追踪:useOpaqueIdentifier,SSR 一致的 id 怎么生成
08-19 React 18 追踪:事件系统与 Context 传播
09-02 React 18 追踪:Fizz,流式 SSR 引擎
09-09 React 18 追踪:选择性注水,用户交互如何插队 hydration
09-16 React 18 追踪:useInsertionEffect 与 Server Components 雏形
09-23 React 18 追踪:useSyncExternalStore 替换 useMutableSource 的三周
09-30 React 18 追踪:Offscreen 从 Suspense 拆出,兼系列四个月回顾(本篇)

两个 WorkTag,一份实现

packages/react/src/React.js 现在导出两个相关类型:REACT_OFFSCREEN_TYPE as unstable_OffscreenREACT_LEGACY_HIDDEN_TYPE as unstable_LegacyHidden。对应的 symbol 是 react.offscreen(0xeae2)和 react.legacy_hidden(0xeae3),ReactWorkTags.jsOffscreenComponent = 22LegacyHiddenComponent = 23ReactFiber.new.jscreateFiberFromTypeAndProps 按 symbol 分别走 createFiberFromOffscreencreateFiberFromLegacyHidden

到了 beginWork,两个 tag 又合回去了。ReactFiberBeginWork.new.js 里有一行:

// Note: These happen to have identical begin phases, for now. We shouldn't hold
// ourselves to this constraint, though. If the behavior diverges, we should
// fork the function.
const updateLegacyHiddenComponent = updateOffscreenComponent;

注释说得很直白:目前 begin 阶段恰好一样,行为哪天分叉了就拆开。两个 tag 的分工是来源上的。unstable_LegacyHidden 是更早的实验 API,从 JSX 直接创建 fiber。OffscreenComponent 除了从 JSX 创建,还承担一个内部职责:第 8 篇讲 Suspense 时见过,Suspense 的 primary children 永远包在一个 Offscreen fiber 里(mountWorkInProgressOffscreenFiber),挂起时靠它隐藏主内容。7 月 20 日的 9ab90de602 清理之后,这套可见性逻辑完全归 Offscreen fiber 管,后面讲 commit 时再展开。

props 类型定义在 packages/react-reconciler/src/ReactFiberOffscreenComponent.js,这个文件没有 .new/.old fork,两个分支共用。内容很短,只有 OffscreenPropsOffscreenState 两个类型。props 就一个 modechildren,mode 的取值里有 hidden,还有一个 unstable-defer-without-hiding,延后渲染但不隐藏内容。类型上方挂着一条 TODO,说 API 还没定,连组件名都在候选之列,注释里直接写了一句「Possible alt: <Visibility />?」。这个状态要如实说:Offscreen 现在能用,但接口形状随时会变。

OffscreenState 有两个字段,baseLanescachePool。字段上方那行注释是理解整个机制的关键:「We use the existence of the state object as an indicator that the component is hidden」。memoizedState 为 null 就是可见,非 null 就是隐藏,没有单独的布尔位。

两个 tag 真正分叉的地方不在 begin,而在 complete。ReactFiberCompleteWork.new.js 给边界补 Visibility flag 的两处判断里带着同一个条件:workInProgress.tag !== LegacyHiddenComponent,旁边的注释写得很直接:「LegacyHidden doesn’t do any hiding — it only pre-renders」。LegacyHidden 只参与「延后渲染」这一半语义,display 切换与它无关。unstable-defer-without-hiding 模式同样被这两处判断排除,名字里的 without-hiding 在代码里就是这么落实的。begin 阶段那句「行为哪天分叉了就拆开」的预言,在 complete 阶段其实已经兑现了一半。

updateOffscreenComponent 的四个分支

begin 阶段的 updateOffscreenComponentReactFiberBeginWork.new.js)按「下一帧要隐藏还是显示」和「这次渲染是什么优先级」分成四个分支。

mode 是 hidden(或 defer-without-hiding)时,先看 fiber 的 mode。legacy 同步模式下不做任何延后,直接正常渲染,记下 baseLanes: NoLanes 就往下走。并发模式下再看 renderLanes 里有没有 OffscreenLane:

  • 当前渲染不含 OffscreenLane。说明这是一次普通优先级的渲染撞上了隐藏子树。函数把这次的 renderLanes 合并进 baseLanes(首次隐藏就直接记 renderLanes),然后把 fiber 自己的 lanes 和 childLanes 都置成 OffscreenLane,return null bailout。代码里还有一条 enableLazyContextPropagation 控制的分支:开着且不是首次渲染时,调 propagateParentContextChangesToDeferredTree 把父链上变过的 context 传播到这棵延后树里,因为补渲染是一次独立渲染,不传播就会丢 context 变更。这个 flag 当前默认值是 false,分支还不生效,如实记录。这个分支的效果是:本次提交先处理周围可见的内容,隐藏子树欠下的渲染被记成一笔账,同时挂一个 OffscreenLane 的更新,等主线程空闲下来用最低优先级再渲染一次。
  • 当前渲染就是 OffscreenLane。这是上面挂的那笔账到期了,第二次渲染。这时把 baseLanes 清成 NoLanes,把上次记下的 lanes 通过 pushRenderLanes 压栈,子树在正常 lane 上下文里真正渲染。cachePool 字段配合还没完成的 Cache 功能用,bailout 时把 getOffscreenDeferredCachePool() 拿到的池子存进 state,二次渲染时 restoreSpawnedCachePool 恢复,让挂起树里的请求能续上。

mode 切回 visible 时走另外一条线:如果之前隐藏过(prevState 非 null),把欠下的 baseLanes 和这次的 renderLanes 合并,保证隐藏期间积累的更新在重新可见这一次全部做掉,然后 memoizedState 置回 null。如果一直可见,什么也不做,原样压栈。

这套设计的核心是把「隐藏」建模成「延后到 OffscreenLane 的渲染」,和 Lane 模型严丝合缝:隐藏子树不阻塞当前提交,欠的账记在 state 里,低优先级补渲染时一并还清。第 2 篇位图里那个孤零零的最高位,在这里接上了实际的组件语义。OffscreenLane 的用途也不止这一处,mountDehydratedSuspenseComponent 里服务端已给出正确内容、不着急注水的边界,同样是挂一条 OffscreenLane 延后到空闲时处理,和第 13 篇选择性注水是一条线。

拿一个具体场景把四个分支串一遍。一个 tab 容器,点击切换时把要隐藏的 tab 包上 mode="hidden" 的 Offscreen。用户点击触发的是 SyncLane 的渲染,走进第一个分支:隐藏子树的更新被记进 baseLanes,fiber 挂上 OffscreenLane,本次提交只处理新 tab 的内容,界面立刻响应。提交后主线程空闲,Scheduler 用 OffscreenLane 发起第二次渲染,走进第二个分支,被隐藏的 tab 在这时真正渲染,渲染完立刻被 commit 阶段的 hideOrUnhideAllChildren 加上 display: none。用户切回来时 mode 变回 visible,走第三个分支,隐藏期间如果子树里又积累过更新,baseLanes 里记着,这次一并做掉。预渲染场景用的是同一套机制:初始就挂 mode="hidden",首个并发渲染直接 bailout,内容在 OffscreenLane 的空闲渲染里准备好,切过去那次几乎零成本。

渲染阶段还有个配套细节在 complete 里:隐藏且并发模式的边界不做 bubbleProperties,子树的 lanes 和 flags 不往上冒,父链看到的 childLanes 就是 begin 阶段挂上的那条 OffscreenLane;只有轮到 OffscreenLane 那次渲染才正常冒泡。冒泡之后紧跟着就是 7 月 26 日那个修复加的检查:subtreeFlags 里带 Placement 或 Update 就补一个 Visibility flag。先冒泡、后检查,这个顺序保证了隐藏期间新插入的节点能被发现,commit 阶段才有依据去补 display: none

隐藏时的 effect 生命周期

渲染阶段只决定「这次做不做」,DOM 和 effect 的处理在 commit 阶段。ReactFiberCommitWork.new.js 的 mutation 阶段有一个 OffscreenComponent 分支,可见性变化时做两件事。

第一件是 DOM。hideOrUnhideAllChildren 从边界往下遍历,只找最顶层的 host 节点,直接改实例的 style.display。找到一层 host 节点就不再深入,更内层的节点靠 CSS 继承隐藏,所以隐藏一棵大树只动几个 DOM 属性,成本树高无关。这条只影响 mutation 模式;persistent 模式在 complete 阶段克隆 host 树时处理。

第二件是 layout effect。从可见切到隐藏时,对子树跑 disappearLayoutEffects_begin:函数组件执行所有 layout effect 的销毁函数,class 组件调 componentWillUnmount 并 detach ref,host 组件 detach ref。遍历时遇到已经隐藏的嵌套 Offscreen 边界会跳过,不重复卸载。

关键在「卸载 effect」和「卸载组件」的边界。fiber 树没有删除,更新队列、hooks 的 memoizedState 链表、class 实例都原样保留。隐藏期间子树只是不渲染、effect 不存活,state 一直在。所以切回可见时走的是 reappearLayoutEffects_begin:函数组件重新执行 layout effect 的 mount(相当于重新运行一遍 effect 主体),class 组件重新调 componentDidMount 并重新 attach ref。对组件作者来说,隐藏再显示的可观察行为是:layout effect 的销毁函数跑一次,之后 mount 再跑一次,state 不丢。class 组件会看到一对 componentWillUnmount / componentDidMount,但实例是同一个。

还有一层兜底在 layout 阶段。隐藏子树以 OffscreenLane 补渲染后,fiber 上会带着 flags,如果照常走 layout 遍历会把 effect 挂上去。commitLayoutEffects_begin 里维护了一个 offscreenSubtreeIsHidden 状态,进入隐藏子树时直接 commitLayoutMountEffects_complete 跳过整棵子树,重新可见的边界则由 reappearLayoutEffects_begin 接管。这套语义挂在 enableSuspenseLayoutEffectSemantics 这个 flag 后面,当前值是 true。

被动 effect(useEffect)当时没有对应的 disappear/reappear 处理。6 月 1 日那个 commit 的 message 里明说了「We also need an API to enable the same thing for passive effects. This is not yet implemented」,到 9 月底的快照里搜不到对应遍历,这块还是空白。

四个 commit 的演进线

Offscreen 这条线在 6 月到 7 月的两个月里有四个关键 commit,按时间排:

6 月 1 日 e16d61c300「[Offscreen] Mount/unmount layout effects (#21386)」。这是起点,做了三件事:把 Offscreen 类型暴露出来,实现可见性切换时 layout effect 的 mount/unmount,以及在 layout 阶段加隐藏子树的整体跳过。message 里记录了后一件事的取舍:隐藏子树低优先级补渲染后 subtreeFlags 上有 effect,作者选择在 layout 阶段判断跳过,另一个方案是在渲染阶段清掉这些 flags,他选了前者,理由是「harder to mess up」。同一个 commit 还补了从隐藏状态起步的测试用例。

7 月 16 日 34600f4fad「Refactor “reappear” logic into its own traversal (#21898)」。把重新可见时重挂 layout effect 的逻辑从 commit 阶段的主遍历里拆出来,变成独立的递归遍历。动机写在 message 里:计划提供命令式的 appear/reappear API,这种调用发生在任意事件里,不在任何一次 commit 中,所以逻辑不能绑死在 commit 遍历上。message 还说下一步要把 disappear 也做同样的拆分,当时 disappear 还住在 hideOrUnhideAllChildren 里。

7 月 20 日 9ab90de602「Clean-up: Move Offscreen logic from Suspense fiber (#21925)」。可见性 effect 的责任从 Suspense fiber 挪到内层 Offscreen fiber。message 解释了当年为什么挂在 Suspense 上:以前内层 Offscreen fiber 只在 fallback 状态下才存在,切回可见时没有 fiber 能承接这个 effect;另一个原因是旧 effect 链表实现下,bailout 的子树进不了 effect 链。现在两个前提都消失了,primary children 永远包着 Offscreen fiber,effect 链表也早在 2020 年换成了 subtreeFlags(第 1 篇讲过这轮重构),挂回 Offscreen 自己身上顺理成章。

7 月 26 日 419cc9c379「Fix: Hide new/updated nodes in already hidden tree (#21929)」。修一个边界 bug:已经隐藏的 Offscreen 树里新插入或更新的节点,之前不会被隐藏,会直接显示出来。修法是在 complete 阶段检查 subtreeFlags 里有没有 Placement 或 Update,有就补一个 Visibility effect。message 里有两个限定:只影响 Offscreen,Suspense 在 fallback 状态下边界内容不会更新,踩不到这个分支;只影响 mutation 模式,persistent 模式每次更新都克隆 host 树,天然覆盖。

四个 commit 连起来看,Offscreen 的走向很清楚:先能跑(6 月),再把逻辑从 Suspense 和 commit 遍历里剥离出来(7 月),最后补边界 case。disappear 的独立遍历和 passive effect 语义还挂在 TODO 上。

一处核实记录

简报里让我核对 ReactFiberTransition.new.jsgetOffscreenDeferredCache。核实结果:快照里没有 ReactFiberTransition.new.js 这个文件,只有无后缀的 ReactFiberTransition.js,内容也只有一个 requestCurrentTransitiongetOffscreenDeferredCache 这个名字在整个仓库里不存在。功能上最接近的是 ReactFiberCacheComponent.new.jsgetOffscreenDeferredCachePool,挂在 enableCache flag 后面,就是上面讲的 bailout 时保存缓存池的函数。规划文档里的名字和位置都对不上当时的代码,照实记录,以免后面的人按错名字去找。

四个月回顾

最后把这四个月的 16 篇摆在一起看。

6-9 月技术演进时间线

6 月打的是地基。Fiber 数据结构、Lane 的 31 位位图、getNextLanes 的调度决策、Concurrent 工作循环和时间切片,四篇全部在 reconciler 内部,回答的问题是「并发渲染这台机器长什么样」。6 月 8 日 React 18 Alpha 公布,给这个系列一个正式的由头。

7 月转向入口和语义。createRoot 转正、ReactDOM.render 进废弃警告,flushSync 统一同步刷新入口,自动批处理把 17 里要手动 batchedUpdates 的场景收编,Suspense 的挂起恢复链路梳理完。这个月的特点是反复,flushSync 合入又回滚过一次,正是追着 master 写才能看到的中间态,等正式版发布再回头看文档,这些痕迹全都抹平了。Offscreen 的三个 commit 也在这个月。

8 月爬到 API 层。useTransition 和 useDeferredValue 第一次把 Lane 机制交到用户手上,startTransition 内部就是给更新分配 TransitionLane,和第 2、3 篇接上。useOpaqueIdentifier 解决 SSR 一致的 id 生成,事件系统和 Context 传播那篇把「点击事件到事件优先级到 Lane」的映射补全。

9 月往外走。Fizz 流式 SSR 和选择性注水把服务端这条线铺开,useInsertionEffect 和 Flight 还是实验性质的雏形,离能用还远,那篇特意压着写。useSyncExternalStore 用三周时间演了一场替换又回滚再加回的完整戏码,最后本篇的 Offscreen 收官。

回头看第 1 篇开头定的追踪方法:盯住 master 分支,pin 住具体 commit,所有引用给到仓库相对路径和函数名,读者可以自己 checkout 对照。四个月下来这个方法没让我踩过大坑,倒是几次「凭印象」都在核实阶段被拦下了:7 月 flushSync 的回滚、8 月稿子里一个当时并不存在的 hook 名、本篇的 getOffscreenDeferredCache,都是先写了再删的。master 还在每天动,Cache、命令式的 appear API、disappear 遍历拆分都挂在 TODO 上,后面的事后面再说。系列到此停笔,方法留给读者:clone 仓库,看 commit message,用 git show <commit>:<path> 看当时的代码,别信任何二手转述,包括本系列的,自己核实一遍才算数。


1146 字 · 43 段落
xi ming

Written by xi mingFollow onGitHub