上一篇看了 Fizz 怎么把 HTML 分段流到浏览器。流式 SSR 带来的直接结果是页面可见时间大大提前,但可见不等于可交互:HTML 到了,React 还没在它上面完成 hydration,事件监听还没绑。这段时间里用户点了页面上一个还没注水的按钮,会发生什么?在 React 17 的 ReactDOM.hydrate 下答案是事件丢失,click handler 不会执行。master 分支上对这个问题有一整套机制,就是这篇要看的 selective hydration。参考代码是 9 月 9 日的快照 cb8a50619b,并发实现照例都在 .new.js fork 里。
系列目录
| 日期 | 标题 |
|---|---|
| 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(本篇) |
每个 Suspense 边界是独立的注水单元
先交代注水本身怎么被调度。hydrateRoot 挂载后,root 的第一次渲染只把树的上半部分 hydrate 完。遇到脱水的 Suspense 边界时,mountDehydratedSuspenseComponent(packages/react-reconciler/src/ReactFiberBeginWork.new.js)直接 bailout,不钻进去,而是给这个 fiber 的 lanes 留一条待处理的 lane,把边界内部的注水挪到以后的渲染批次里。留哪条 lane 分两种情况:服务器只给了 fallback 的边界挂 DefaultHydrationLane,因为要尽快把真内容渲染出来;服务器内容完整的边界挂 OffscreenLane,页面上显示的本来就是对的,不急。
这个默认值解释了插队的必要性。OffscreenLane 是 31 个位里优先级最低的一条,排在 IdleLane 后面。没有外部刺激时,边界会等所有正经工作做完才在后台慢慢注水。用户交互就是那个外部刺激。
遗留模式没有这个机会。同一个函数里,如果 fiber 不在 ConcurrentMode 下,会直接打一条 console.error,提示「Cannot hydrate Suspense in legacy mode」,然后把边界降到 SyncLane 同步注水。也就是说选择性注水这套东西只在 createRoot 或 hydrateRoot 创建的并发 root 上成立,和第 5 篇讲的入口迁移是同一条线。
注水渲染怎么认领 DOM
提权之前先看注水渲染本身怎么工作,这块在 packages/react-reconciler/src/ReactFiberHydrationContext.new.js。这个文件没有 class,状态就是几个模块级变量:nextHydratableInstance 指向下一个待认领的 DOM 节点,hydrationParentFiber 记住当前在哪个 fiber 下面认领,isHydrating 是总开关。
root 渲染开始时 enterHydrationState 把 nextHydratableInstance 设为容器的第一个子节点。之后每个 host fiber 在 beginWork 里调 tryToClaimNextHydratableInstance 认领自己对应的 DOM:tryHydrate 拿节点的 tag 和类型跟 fiber 比对,匹配就认领成功,把游标推进到这个节点的第一个子节点,继续往下走。不匹配时再试一次下一个兄弟节点,如果兄弟匹配上了,就认为前一个节点是服务器多渲染的,调 deleteHydratableInstance 挂一个删除副作用,commit 阶段再删。文件里对这一次跳跃留了条诚实的注释:「It’s based on intuition and not data so it might be flawed or unnecessary」。两次都配不上就放弃 hydration,把这个 fiber 当成全新插入(insertNonHydratedInstance)。这就是 hydration 容错的全部分支,比对失败不会中断整棵树。
脱水的 Suspense 边界是这套认领逻辑里的特例。第一次渲染遇到它时直接跳过(skipPastDehydratedSuspenseInstance),游标跨过整个边界的内容。等这个边界自己的 lane(默认 OffscreenLane,或者被交互提权后的 SyncLane、SelectiveHydrationLane)被 getNextLanes 选中渲染时,updateDehydratedSuspenseComponent 调 reenterHydrationStateFromDehydratedSuspenseInstance,把游标重新定位到边界实例的下一个兄弟,打开 isHydrating,边界内部的 fiber 按上面同一套逻辑逐个认领。所以提权之后的注水是局部 hydration,只处理这个边界的子树,跟整棵树重来一遍没有关系。认领会把 fiber 和 DOM 节点的关联写进节点的内部属性,从这一刻起事件系统才能从这个 DOM 节点向上找到 fiber,这块区域才真正可交互。
点击怎么被拦下来
事件入口在第 11 篇讲过一半。createRoot 和 hydrateRoot 都会调 listenToAllSupportedEvents,把所有受支持事件的监听挂在 root 容器上,所以未注水边界里的 DOM 上虽然没有 React 绑定,事件冒泡到容器时照样进得了 React 的分发函数。click 这类离散事件走 dispatchDiscreteEvent,它先把当前更新优先级设为 DiscreteEventPriority,然后调 dispatchEvent(都在 packages/react-dom/src/events/ReactDOMEventListener.js,这篇引用的函数当时在这个文件里,不在 DOMPluginEventSystem.js)。
dispatchEvent 委托 attemptToDispatchEvent 做真正的分发,这个函数的返回值设计得很讲究:分发成功返回 null,被阻塞时返回阻塞物,要么是一个 SuspenseInstance(脱水边界的注释节点),要么是一个 Container。阻塞判定走这几步:getClosestInstanceFromNode 从事件目标向上找最近的 fiber,未注水边界里的 DOM 还没被任何 fiber 认领,一直爬到边界自己;getNearestMountedFiber 确认它是已挂载的 SuspenseComponent;getSuspenseInstanceFromFiber 取出脱水实例,确认边界还在脱水状态。三条都中,说明这块 UI 看得见但 fiber 这层还没有对应的可交互实体,事件不能照常分发,否则等注水完成后会再触发一次,同一点击执行两遍。于是函数中止分发,把 SuspenseInstance 作为 blockedOn 交回去。root 自己还在 hydrate 时是另一个分支,返回容器。
拿到 blockedOn 之后,dispatchEvent 按事件类型分流。离散事件(click、keydown、input、submit 等,isReplayableDiscreteEvent 列了一张表)调 queueDiscreteEvent 入队。连续事件走 queueIfContinuousEvent,focus、drag、mouseover 各有一个槽位,pointer 事件按 pointerId 存 Map,同类只留最后一个,因为连续事件重放最后一次就足够恢复状态。「只留最后一个」具体在 accumulateOrCreateContinuousQueuedReplayableEvent 里做:同一个 nativeEvent 对象再次到达(不同事件系统挂了各自的 DOM 监听,同一事件会重复进来)时只合并 eventSystemFlags、把新容器追加进 targetContainers 数组,换成新的事件对象才整体替换槽位。focusin、dragenter、mouseover 入队前还会把 relatedTarget 置成 null,注释解释是要让重放效果等同于「从窗口外直接移到这个目标上」。两类都装不下的,比如不可重放的事件,退化为不带 target 调用 dispatchEventForPluginEventSystem,让事件系统至少能记录到它发生过。
队列里的保序和首个事件的特权
队列在 packages/react-dom/src/events/ReactDOMEventReplaying.js,核心结构是 queuedDiscreteEvents 数组。离散事件要保证重放顺序,所以 dispatchEvent 开头有一个前置检查:只要队列非空,新的离散事件不管目标在哪里,直接入队。否则用户在已注水区域点了一下,这一下会先执行,队列里那次点击后执行,顺序就反了。
首个离散事件有特权。queueDiscreteEvent 里,如果入队后队列长度是 1,会在一个 while 循环里立刻对这个边界调 attemptSynchronousHydration,尝试当帧同步注水。代码注释写了动机:「If this was the first discrete event, we might be able to synchronously unblock it so that preventDefault still works」。native event 对象只在当前事件循环里活着,preventDefault 拖到一个宏任务之后就失效了,所以第一次阻塞值得花一次同步渲染去赌。赌赢了调 replayUnblockedEvents 就地重放;注水没完成(边界的数据还没到,又挂起了)就放弃同步,等异步重放。
attemptSynchronousHydration 本体在 packages/react-reconciler/src/ReactFiberReconciler.new.js,对 SuspenseComponent 分支是 flushSync 包一个 SyncLane 的 scheduleUpdateOnFiber,把这个边界当成最高优先级更新立刻渲染。对 HostRoot 分支取 root 当前最高优先级的 pending lane 直接 flushRoot。
插队用的车道
事件入队只是保住不丢,插队发生在提权这一步。ReactFiberReconciler.new.js 里一组四个函数,react-dom 通过 setAttemptDiscreteHydration 这类注册函数拿到引用(reconciler 不反向依赖 DOM 包,靠注入):
attemptSynchronousHydration:上面说的同步路径,第一个离散事件用。attemptDiscreteHydration:离散事件用,给边界 fiber 调度一条 SyncLane 更新。attemptContinuousHydration:连续事件用,调度 SelectiveHydrationLane。attemptHydrationAtCurrentPriority:按当前事件优先级取 lane,给显式指定目标的场景用。
SelectiveHydrationLane 值得单独说。它在第 2 篇那张位图的第 27 位,夹在 RetryLanes(22 到 26 位)和 IdleHydrationLane(28 位)之间,比前面所有车道(含 transition 和 retry)都低,只高于 idle 系(IdleHydration、Idle)和 OffscreenLane。查 git log,这条 lane 是 2020 年 12 月 4 日的 88ef95712d「Fork ReactFiberLane (#20371)」引入的,就是那个 commit 把 lane 文件拆成 new/old 双 fork 时一起加的。它的语义是「这个边界因为用户正在和它交互需要优先注水,但又不至于像点击本身那样要求当帧同步」。连续交互提权到这里,离散交互直接上 SyncLane,两档分级。
四个 attempt 函数殊途同归,最后都会调 markRetryLaneIfNotHydrated,把 lane 写进 suspenseState.retryLane(current 和 alternate 两棵树上都写)。第 8 篇讲 Suspense 重试时提过这个字段:边界挂起后,promise resolve 触发的重试渲染跑在 retryLane 指定的车道上。注水场景下边界内部可能还要等数据,提前把 retryLane 抬上去,等数据到了那次重试渲染也跑在高优先级,不会出现「边界注水了,里面的数据 resolve 后却按普通优先级慢慢渲染」的半截加速。
还有一条相关路径顺带提一下。getBumpedLaneForHydration(ReactFiberLane.new.js)负责另一方向的映射:边界注水渲染途中 props 变了、没法继续 hydrate 时,按当前渲染 lane 查出对应的 hydration 变体 lane(DefaultLane 对 DefaultHydrationLane,InputContinuousLane 对 InputContinuousHydrationLane,诸如此类),用变体 lane 再试一次注水,试不动才退回客户端渲染。带 Hydration 后缀那几条 lane 的主要消费者就在这里。
注水完成后怎么重放
边界注水渲染提交时,commit 阶段的 commitWork 对刚注水的 Suspense 边界调 commitHydratedSuspenseInstance(packages/react-dom/src/client/ReactDOMHostConfig.js),函数体里就一行关键调用:retryIfBlockedOn(suspenseInstance)。root 容器注水提交后走 commitHydratedContainer,同样调 retryIfBlockedOn(container)。
retryIfBlockedOn 回到事件重放模块,扫所有队列,把 blockedOn 等于这个实例的条目清成 null。调度策略有点意思:只有离散队列的第一个条目会安排一次 scheduleCallback(NormalPriority, replayUnblockedEvents),后面的条目只标记解除,不各自安排回调。源码里留了一段注释解释这个取舍,大意是「这是每个边界提交时的一次指数搜索,值得,因为我们预期离散事件排队的本来就很少,一旦完全解阻,重放会很快」。hasScheduledReplayAttempt 标志保证同一时刻最多只有一个重放回调在排队。连续事件的三个槽位和两个 pointer Map 逐个检查,显式目标队列则从头开始把已解阻的条目依次处理掉。
replayUnblockedEvents 从队列头开始处理:条目还有 blockedOn 就停下来,对这个新阻塞物调 attemptDiscreteHydration 再提一次权,等下一轮;没有 blockedOn 就重新走 attemptToDispatchEvent 把事件发出去,同一个事件对象,只是 eventSystemFlags 上多了个 IS_REPLAYED 标记。重新分发可能再次被阻塞,典型场景是嵌套边界:外层边界注水完成后事件能往里走一层,落进还没注水的内层边界,attemptToDispatchEvent 返回内层的 SuspenseInstance,条目记下新 blockedOn,内层边界被提权,等它提交后接着重放。这个过程可以递归下去,队列头始终压在「用户最早交互的那个点」上。整个过程对应用代码透明,handler 最终按用户点击的顺序执行,只是晚了一些。连续事件在离散事件全部重放完之后处理,同一套逻辑,提权换成 attemptContinuousHydration。
几个边界条件
这套机制当时的完成度可以照实记录几点。enableSelectiveHydration 这个 feature flag 在默认构建和 www 构建里都是 true,但 queueDiscreteEvent 的同步尝试、queueExplicitHydrationTarget 这些分支都挂在 flag 后面,属于仍在收敛的代码。dispatchEvent 开头有一条 TODO,说 capture 阶段事件的重放目前是坏的,因为捕获和冒泡监听分开挂了,重放逻辑只对冒泡阶段生效。
手动干预的口子也留了。ReactDOM.unstable_scheduleHydration(target) 把任意 DOM 节点放进 queuedExplicitHydrationTargets,按当前事件优先级排序后调 attemptHydrationAtCurrentPriority,让库作者(比如代码分割方案)可以在不触发事件的情况下提前给某块区域提权。这个 API 带着 unstable 前缀,语义还在变。
这个显式队列的处理也有讲究。入队按 getCurrentUpdatePriority 取到的当前优先级排序插入,只有落在队首的目标会立刻调 attemptExplicitHydrationTarget 试一次,排在后面的要等前面解阻。这个函数的阻塞判定基本是 attemptToDispatchEvent 的复制(源码留了 TODO 说两处该合并),找到脱水边界后用入队时记下的优先级提权;一路爬到 HostRoot 且 root 还在 hydrate 时则没有提权手段,只记下 blockedOn,等容器注水提交后由 retryIfBlockedOn 顺带清掉。
整体看下来,选择性注水是 lane 模型的第一个完整应用案例:优先级信息从 DOM 事件一路传到调度器(事件优先级决定 attempt 函数,attempt 函数决定 lane),调度结果又在 commit 阶段回流到事件系统(retryIfBlockedOn 触发重放)。和第 3 篇讲的 getNextLanes 决策、第 11 篇讲的事件优先级映射正好拼成一个闭环。接下来几篇我打算看看 hooks 侧的动静,useSyncExternalStore 相关的包刚在 master 上出现,另外 react-server 目录下还有些实验期的新东西值得翻一翻。

