React 18 追踪:Suspense 的挂起与恢复

6 分钟阅读
·

上一篇看完自动批处理,7 月 master 上另一个值得记录的动向在 Suspense 这块。7 月 20 日的 9ab90de6 把一批可见性切换逻辑从 Suspense fiber 挪进了内部的 Offscreen fiber,7 月 13 日的 9090257e6e 修了渲染出错重试后 executionContext 没恢复的问题。借这个机会把 Suspense 的挂起与恢复链路完整过一遍:组件 throw 出一个 promise 之后,React 怎么捕获、怎么挂起当前渲染、什么时候提交 fallback,promise 兑现之后又怎么恢复。参考代码是 master 分支 c76e4dbb,并发实现都在 .new.js fork 里,主要文件是 packages/react-reconciler/src/ReactFiberThrow.new.js

系列目录

日期 标题
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 的挂起与恢复(本篇)

从 throw 开始

数据没就绪的组件(比如一个读缓存的 lazy 组件)在 render 中直接把 promise 对象 throw 出去。work loop 的 handleError 捕获到这个值,转交 throwException,这是整条链路的入口。

throwException 先做两件小事:给抛出的 fiber 打上 Incomplete flag,然后检查这个值是不是 wakeable,判定方式是「对象上存在 then 方法」。是 wakeable 就走 Suspense 路径,否则走错误处理路径,交给 error boundary 或 HostRoot。

进入 Suspense 路径后还有两个前置动作值得记下。enableLazyContextPropagation 开启时,会调 propagateParentContextChangesToDeferredTree 把父链上的 context 变更预先传播到这棵没来得及渲染的子树里。注释解释了原因:挂起的组件子树这次根本没被访问到,重试时如果不补这一步,context 变更就会被漏掉;错误处理不用做这个,因为错误会立刻重试、中间没有 commit。另一个是 legacy 模式下的 hooks 状态重置:非并发的函数组件挂起时,要把 updateQueuememoizedStatelanes 从 alternate 上拷回来,回到尝试渲染之前的样子,这是只对 hooks 组件有效的 legacy 兼容行为。

Suspense 路径沿 return 指针向上找最近的边界,判定函数是 shouldCaptureSuspenseReactFiberSuspenseComponent.new.js),规则按顺序排除:

  • dehydrated 状态的边界总是捕获,这是 hydration 场景。
  • memoizedState 非空的边界(已经在显示 fallback)不捕获,继续向上冒泡。
  • 普通边界直接捕获。
  • unstable_avoidThisFallback 的边界,如果父级边界当前不可见就让给父级。父级是否可见通过 ReactFiberSuspenseContext.new.jssuspenseStackCursor 判断,里面的 InvisibleParentSuspenseContext 是一个 subtree 标志位,父边界处于 fallback 或未挂载时会被置上。

找到边界后做三件事:把 wakeable 加进边界的 updateQueue(一个 Set,攒下这次渲染里所有挂起它的 promise);给边界打上 ShouldCapture flag,并把 workInProgress.lanes 置为当前渲染的 rootRenderLanes;调 attachPingListener 给 promise 挂 ping 回调。然后函数返回,work loop 开始 unwind。如果一路找到 HostRoot 都没有边界,wakeable 会被包成一个 Error(“suspended while rendering, but no fallback UI was specified”),重新走错误路径,这时它就真的按异常处理了。

这段代码中间夹着一大块注释,标题是 Suspense Heuristics,讲「这一轮渲染该直接跑完、重启、还是挂起等一等」的几条启发规则:初渲染的新边界触发 fallback 时不要挂起,尽快把 loading 状态摆出来;从内容切回 fallback(Delayed 情况)时应该挂起或重启,transition 正属于这种情况;已经有 fallback 在显示、重试又暴露出内层 fallback 时,按距上次 fallback 上屏的时间节流一轮,把渐进加载整成节奏稳定的提交序列。这套注释是后面 finishConcurrentRender 里各分支的设计依据,读代码时建议先读注释再对分支。

还有一条 legacy 分支要提一下:边界不在 ConcurrentMode 下(目前所有用 ReactDOM.render 挂载的应用)时不走挂起。sourceFiber 被标上 DidCapture、清掉生命周期相关 flag、补一条 SyncLane,假装这个组件渲染了 null 继续往下跑,commit 阶段再补一次同步更新来渲染 fallback。这是 16、17 时代 Suspense 行为的延续,本篇主要讲并发路径。

unwind 与 fallback 的第二遍渲染

边界打了 ShouldCapture 之后,ReactFiberUnwindWork.new.jsunwindWork 逐层向上弹栈,沿途的 ContextProvider 弹 provider、HostComponent 弹 host context,把 render 阶段压入的各种栈恢复原位。遇到 SuspenseComponent 时:pop 掉 suspense context,dehydrated 边界还要调 resetHydrationState 清理 hydration 现场,然后把 ShouldCapture 转成 DidCapture,返回这个边界 fiber。unwind 停在边界上,work loop 从边界重新进入 begin 阶段。顺带一提,SuspenseListComponent 在 unwindWork 里只弹栈不捕获,注释写明它自己不接异常,应该由内嵌的边界接住,否则继续向上冒泡。

updateSuspenseComponentReactFiberBeginWork.new.js)这次检测到 DidCapture,走 fallback 分支:primary children 被包进一个 mode'hidden' 的 Offscreen fiber,fallback 内容作为它的 sibling,边界的 memoizedState 置为 SUSPENDED_MARKER。注意 primary 子树不会被卸载,组件 state 保留着,只是整棵子树被标成隐藏。Suspense 与「数据没到就渲染别的」这种条件渲染的区别就在这里:挂起期间旧内容还在 fiber 树上,恢复时不需要从头重建。

complete 阶段边界完成时,根据这次挂起有没有引入新的延迟,分别调 renderDidSuspendrenderDidSuspendDelayIfPossible,把 workInProgressRootExitStatus 置成 RootSuspended 或 RootSuspendedWithDelay。

挂起的 lane 与 fallback 的提交时机

渲染走完后,finishConcurrentRenderReactFiberWorkLoop.new.js)按 exitStatus 决定 fallback 什么时候上屏。两个分支的开头都是 markRootSuspended,把这批 lane 从 pending 挪到 suspended。这个函数的本体在 ReactFiberLane.new.jssuspendedLanes 按位或上这批 lane,pingedLanes 按位清掉对应位,expirationTimes 里对应的槽位清零,挂起的 lane 不再参与饥饿计算。之后 getNextLanes 选 lane 时会跳过 suspended 中没被 ping 的部分,第 3 篇讲过这个筛选顺序。

RootSuspended 表示有一个可以接受的 fallback 状态。如果这批 lane 全是 retry(includesOnlyRetries),说明这次渲染只是重试触发的、没有新更新,就按 500 毫秒节流:距上一次 fallback 上屏(globalMostRecentFallbackTime,常量 FALLBACK_THROTTLE_MS = 500)不足一个窗口时,不急着提交。提交前还有两层检查:root 上如果还有其他待处理的 lane,让位给它们先渲染;如果挂起着的 lane 里有没被这批 retry 覆盖的部分,用 markRootPinged 把最后挂起的层级 ping 回去再试一次。都不满足才用 scheduleTimeout 把 commit 推迟到节流窗口结束,避免 loading 状态闪得太快。不在节流窗口内就直接 commitRoot

RootSuspendedWithDelay 表示触发了需要延迟的状态,比如从内容切回 fallback。如果这批 lane 全是 transition,直接退出不提交,界面停留在旧内容上等数据,transition 语义允许这样做。其他情况按 JND(just noticeable difference)阶梯算一个延迟:jnd 函数按已等待时间分档,已等 120ms 以内就等到 120,480 以内等到 480,往上依次是 1080、1920、3000、4320,再往上按 1960 的倍数取整。思路是已经等得越久,用户越不容易察觉多等的一段,就多给数据一点时间。延迟窗口内数据到了,fallback 就不用上屏。

挂起 lane 有独立状态这件事,正是 expirationTime 单值模型做不到的,第 2 篇结尾把它列为旧模型的第三个问题,这里就是实际用例:suspendedLanes 和 pingedLanes 两张掩码让「挂起等数据」和「数据到了可以重试」成为调度可见的状态。

ping 与 retry:两层监听,两层去重

恢复侧有两条监听通道,挂在不同时机,去重机制也不同。

Suspense 挂起与恢复链路

第一条是 attachPingListener,渲染中挂起的当下就挂上。去重靠 root 上的 pingCache:一个 WeakMap,key 是 wakeable,value 是一个装 lanes 的 Set,源码注释把 lanes 比作 thread ID。同一个 promise 在同一批 lanes 下只会注册一个 then 回调。promise 兑现后回调是 pingSuspendedRoot:先从 pingCache 删掉这个 wakeable,注释写明理由是兑现过的 promise 不会再被 throw;然后 markRootPinged 把这批 lane 从 suspended 标成 pinged。接下来有个重启启发:如果此刻 root 正在以同一批 lane 渲染,且退出状态是 RootSuspendedWithDelay(或 RootSuspended 且全是 retry 且距上次 fallback 不到 500ms),直接 prepareFreshStack 从头重启这次渲染,因为反正要挂起,不如用新数据重来;否则把 ping 记进 workInProgressRootPingedLanes,这轮渲染收尾时再消化。最后 ensureRootIsScheduled 调度重试。

第二条是 attachSuspenseRetryListenersReactFiberCommitWork.new.js),commit 阶段的 layout 子阶段对 SuspenseComponent 调用。fallback 提交上屏后,边界 updateQueue 里攒的 wakeable Set 被逐个挂上 then 回调,回调是 resolveRetryWakeable,同时 updateQueue 清空。去重靠边界 stateNode 上的 retryCache,一个 WeakSet,同一个 wakeable 只挂一次回调。

为什么有了 pingCache 还要一层 retryCache。两次挂起可能发生在不同轮渲染里:同一个 promise 先挂起一次渲染,fallback 提交后 wakeable 进了 retry 监听;之后另一次渲染又在别处 throw 同一个 promise,pingCache 按 lanes 去重管不住跨批次的重复,retryCache 在边界维度上兜底,保证一个 promise 对同一个边界只触发一次重试调度。resolveRetryWakeable 触发时同样先从 retryCache 删掉 wakeable,再调 retryTimedOutBoundary:从 claimNextRetryLane 领一条 Retry 车道(第 2 篇说过 5 条 RetryLane 轮转分配),markRootUpdated 挂到 root,ensureRootIsScheduled 调度,边界以这条 lane 重新渲染。这次数据已就绪,走回正常分支显示内容。

两层缓存的分工可以归纳成一句:pingCache 按「wakeable + lanes」去重,保护渲染期;retryCache 按「wakeable + 边界」去重,保护 commit 之后。两边在 promise 兑现时都会删掉记录,配合 WeakMap 和 WeakSet,挂起过的 promise 不会在这两个结构里滞留。

补两个 commit 阶段的细节。attachSuspenseRetryListeners 旁边还有一个 commitSuspenseCallback:如果边界上挂了 suspenseCallback 这个 prop(目前还没定是否加 unstable_ 前缀,源码里留着 TODO),commit 时会把攒下的 wakeable Set 交给这个回调,业务侧可以用它聚合 loading 状态,比如多个边界共享一个全局的加载指示。另一个在恢复链路上:resolveRetryWakeable 里有一个分支差异,普通边界的 retryLane 取 NoLane,由 requestRetryLane 现领一条;dehydrated 边界(hydration 场景)的 SuspenseState 上存着专门的 retryLane 字段,直接取出来用,不经过轮转分配。SuspenseState 这个类型一共就两个字段,dehydrated 和 retryLane,memoizedState 是否为 null 同时充当「是否在显示 fallback」的标志,上一节的 SUSPENDED_MARKER 就是这个类型的常量实例。

9ab90de6:把 Offscreen 逻辑从 Suspense fiber 拆出去

7 月 20 日的 9ab90de6 是一次纯清理,改动落在 CompleteWork 和 CommitWork 两个 fork 各一份,删除远多于新增(合计净删 120 行)。它把 Suspense 与 Offscreen 的职责边界重新划了一遍,值得对照拆分前后看。

拆分前,可见性切换的职责在 Suspense fiber 自己身上。completeWork 里 Suspense 完成时,隐藏状态有变化就给 Suspense fiber 打 Visibility flag,mutation 和 persistence 两种宿主模式各有一段;appendAllChildren(拼宿主树)里有一段 SuspenseComponent 特例,遇到带 Visibility 的边界要手动遍历 primary 子树和 fallback 子树;commit 阶段 SuspenseComponent 的 case 负责调 hideOrUnhideAllChildren 切换 DOM 显隐,还要对内部 Offscreen 子树逐个跑 disappearLayoutEffects

拆分后,Visibility flag 移到了内部那个 Offscreen fiber 上。completeWork 里 Suspense 的 case 只在一种情况动手:从内容态切到 fallback 态(nextDidTimeout && !prevDidTimeout)时,内部 Offscreen fiber 因为 bailout 没有 complete 阶段,由 Suspense 替它打上 Visibility;反方向切回内容时,Offscreen 自己的 complete 阶段会处理。appendAllChildren 的特例改成匹配「OffscreenComponent 且 memoizedState 非空」,这段代码不再认识 Suspense。commit 阶段 SuspenseComponent 的 case 只剩一件事:刚进入隐藏态时调 markCommitTimeOfFallback 记录时间,上一节的 500ms fallback 节流用的 globalMostRecentFallbackTime 就是它更新的。显隐切换整个交给了 Offscreen 的实现。

commit message 解释了为什么以前不这么做、现在可以了,两个历史前提都失效了。其一,内部 Offscreen fiber 以前只在 fallback 状态下才条件性地挂载,切回内容时没有内部 fiber 承接这个 effect;现在 primary children 始终包在 Offscreen fiber 里,也就是上面 fallback 分支看到的结构。其二,旧的 effect 链表实现里,fallback 态下内部 fiber 走 bailout 没有 complete 阶段,就进不了 effect list,自然没有 commit 阶段;去年改成递归遍历 flags 之后没有链表要维护,在内部 fiber 上打个 flag 就足够调度 commit effect。前提消失,重复代码就收进了 Offscreen 一侧。附带收益是顺手修了 Offscreen 在 persistent(Fabric)模式下的一个 TODO:已隐藏子树里新插入的节点现在也会保持隐藏,mutation(DOM)模式的同样问题留待后续。

当月动态:9090257e6e

7 月 13 日的 9090257e6e 是个小修复,来自社区贡献者。渲染抛错后 work loop 会把 executionContext 加上 RetryAfterError 位,做一次同步重试渲染,争取把所有待处理更新一起带上。问题是旧代码重试完没把这一位摘掉,executionContext 停在残留状态,后续的更新调度会被它影响,附带的测试用 act 复现了这个异常。修复就是标准的保存恢复模式:重试前记下 prevExecutionContext,重试后赋回。当前快照里这段代码已经是修好的样子。这类位掩码上下文忘记恢复的 bug 在 work loop 里出过不止一次,读这块代码时最好多留意进出的配对。

这篇把 Suspense 挂起与恢复的链路看完了:throwException 的边界查找、unwind 后二遍渲染 fallback、lane 的挂起与 fallback 提交节流、ping 和 retry 两层监听恢复渲染。下一篇回到 API 层,看 startTransition 和 useDeferredValue 怎么用第 2、3 篇的 Lane 机制落地。


1041 字 · 40 段落
xi ming

Written by xi ming You should follow him on Github