React 18 追踪:事件系统与 Context 传播

6 分钟阅读
·

第 6 篇讲 flushSync 重构时,事件入口还包着一层 discreteUpdates。7 月 12 日的 bfa50f8272(Inline discreteUpdates)把这层也拆掉了:离散事件的回调不再先抬执行上下文,入口函数直接改一个模块级变量。事件入口变薄之后,事件系统和调度之间只剩一个接口:事件优先级。这篇把事件系统和 Context 传播放在一起看,因为两条链路在 lane 上会合:事件决定一次更新拿哪条 lane,Context 传播负责把 lane 送到该重算的 fiber 手上。参考代码是 master 64f83a6f(2021-08-19),并发实现都在 .new 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 传播(本篇)

监听器挂在 root 容器上

React 17 把事件委托点从 document 挪到了 root 容器(848bb2426e,Attach Listeners Eagerly to Roots and Portal Containers,2020 年 8 月),18 沿用这个结构。createRoot 创建容器时调 listenToAllSupportedEventspackages/react-dom/src/events/DOMPluginEventSystem.js):对 allNativeEvents 里的每个事件名,在 root 容器上把捕获和冒泡两个原生监听器各挂一遍,容器上打一个 _reactListening 开头的随机属性做去重。有几类例外:scroll、load、toggle 这些不冒泡的事件,加上一批 media 事件,收在 nonDelegatedEvents 里直接挂到目标元素上;selectionchange 挂在 ownerDocument 上。

每个原生监听器都经过 createEventListenerWrapperWithPrioritypackages/react-dom/src/events/ReactDOMEventListener.js)包装,包装时调 getEventPriority(domEventName) 给事件分档:click、keydown、input、pointerdown 这类返回 DiscreteEventPriority,mousemove、scroll、wheel、drag 这类返回 ContinuousEventPriority,其余一律 DefaultEventPriority。分档结果决定包出哪个入口函数:离散事件走 dispatchDiscreteEvent,连续事件走 dispatchContinuousEvent,默认走 dispatchEvent

挂监听时还有一处浏览器兼容的手动模拟。Chrome 把 document 上的 touchstart、touchmove、wheel 默认改成 passive,React 的监听器已经不挂在 document 上,但直接改行为会丢掉当时的性能收益,所以 addTrappedEventListener 里对这三个事件手动把 isPassiveListener 置成 true,源码注释指向 issue 19651。分档表里也有一个特殊的 case:message 事件没有固定档位,它读 Scheduler 的当前优先级再映射回来,注释说这套机制以后会被原生 scheduler 的优先级检查取代。映射规则是一张 switch:ImmediateSchedulerPriority 折成 DiscreteEventPriority,UserBlockingSchedulerPriority 折成 ContinuousEventPriority,Normal 和 Low 折成 DefaultEventPriority(Low 旁边挂着一条 TODO,说也许该和水合用同一条 lane),IdleSchedulerPriority 折成 IdleEventPriority。Scheduler 的五档优先级在这里被压回 React 的四档。

一次 click 的派发路径

以 click 为例。原生事件冒泡到 root 容器,dispatchDiscreteEvent 被调起,它做三件事:把 ReactCurrentBatchConfig.transition 清零,调 setCurrentUpdatePriority(DiscreteEventPriority),然后把事件交给 dispatchEvent,finally 里把两个值都恢复。清零 transition 的含义是:就算这个回调之前处于 transition 上下文,离散事件里的更新也必须按离散优先级走,点击不能被降级。

dispatchEvent 先查事件重放队列:队列里已有挂起的离散事件时,新事件直接进 queueDiscreteEvent 排队,保证重放顺序。然后 attemptToDispatchEventgetClosestInstanceFromNode 从 DOM 目标找到 fiber,getNearestMountedFiber 确认它已挂载。目标落在还没注水完成的 Suspense 边界里时,函数返回这个边界的实例,事件挂进重放队列,等边界就绪再发。事件重放和选择性注水的联动留到后面细讲,这里先记住有这个拦截点。

能正常派发时进入 dispatchEventForPluginEventSystem。这里先处理一个多 root 场景:每个 root 容器和 portal 容器上都挂了监听器,事件在嵌套 root 里触发时可能收到不止一次,所以派发前要沿 fiber 树向上找,确认目标 fiber 属于当前这个 rootContainer;目标是别的 root 里的 portal 时直接 return,让事件从 portal 自己那条链冒泡过来。确认归属后,用 batchedUpdatespackages/react-dom/src/events/ReactDOMUpdateBatching.js)包住 dispatchEventsForPlugins

SimpleEventPlugin 的 extractEventsaccumulateSinglePhaseListeners:从目标 fiber 沿 return 指针一路向上,收集路径上每个 HostComponent 挂着的对应 prop,捕获阶段收 onClickCapture,冒泡阶段收 onClick。收集结果在 processDispatchQueueItemsInOrder 里执行:捕获阶段从数组尾部倒着执行,root 侧先触发;冒泡阶段正着执行。每换一个 fiber 检查一次 isPropagationStopped()。DOM 的捕获冒泡语义是在 fiber 树上用一次向上遍历加两个执行顺序模拟出来的,原生层只依赖挂在 root 容器上的那一对监听器。

SimpleEventPlugin 之外还有 EnterLeave、Change、Select、BeforeInput 四个 polyfill 插件,它们只在冒泡阶段处理。源码注释给了原因:这些插件要么本身依赖冒泡语义(EnterLeave),要么内部维护着本地状态,捕获阶段先改一次状态、冒泡阶段的事件再到,启发式判断就会被自己污染,所以干脆统一只在冒泡阶段跑。

顺带看一眼 batchedUpdates 现在的样子。它已经很薄:isInsideEventHandler 标志位防重入,finally 里跑 finishEventHandler,检查有没有受控组件处于待恢复状态(needsStateRestore,2014 年 1698 号 issue 就存在的老逻辑),有就 flushSync 一次再 restoreStateIfNeeded。文件里还留着 discreteUpdates 的导出,但事件入口已经不再调它,上面挂着 TODO: Replace with flushSync。批处理相关的老 API 正在一个个被清掉。

代码里还躺着一个计划内的行为变化。SimpleEventPlugin 里 accumulateTargetOnly 目前只对 scroll 生效,注释写明意图是让 scroll 在 React 里也不冒泡,向浏览器行为对齐,并且说这批改动「can wait until React 18」。onScroll 不冒泡是 18 的 breaking change,现在用 alpha 就能观察到。

EventPriority 的内部类型就是 Lane

第 2 篇提过 ReactEventPriorities.new.js 的映射,这篇把它说完整。文件开头:

export opaque type EventPriority = Lane;

export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
export const IdleEventPriority: EventPriority = IdleLane;

EventPriority 对外是 opaque type,内部就是 Lane。这个形态是今年 3 月一串重构定下来的:d0eaf78293(03-23)先把优先级拆成独立模块打破循环依赖,fa868d6be7(03-24)把 EventPriority 的内部类型改成 Lane,03ede83d2e(03-25)开始用它追踪更新优先级。事件优先级和 lane 是同一个数的两个名字,好处在消费端能看到。

requestUpdateLaneReactFiberWorkLoop.new.js)的分支按顺序排下来:不在并发模式返回 SyncLane;render 阶段更新并入当前渲染的 lanes;transition 上下文里领一条 transition 车道;然后轮到 getCurrentUpdatePriority(),值不为 NoLane 就直接当 lane 返回,中间没有任何换算。click 回调里的 setState 走的就是这条分支:dispatchDiscreteEvent 放进去的 DiscreteEventPriority,取出来就是 SyncLane。整条链路上优先级只在事件入口设置一次,之后以 lane 的形态原样流动。

反向也有一次换算。lanesToEventPriority 把一组 lanes 折回事件优先级档位,ensureRootIsScheduled 用它决定这次渲染按什么级别向 Scheduler 注册任务,第 4 篇讲的调度分叉就发生在这里。正向设值、反向折算,两个函数分别完成事件优先级到 lane、lane 到事件优先级的换算。

文件里其余的工具函数也都基于 lane 的数值性质。higherEventPriority(a, b)a !== 0 && a < b ? a : b,值小者优先级高,和第 2 篇的位序约定一致。临时抬优先级的 try/finally 模式在各调用方是内联手写的:flushSync 在 finally 前把 DiscreteEventPriority 设进去、出来后恢复,discreteUpdates 同样。flushPassiveEffects 旁边有段注释交代了来路:以前这里包的是 Scheduler.runWithPriority,现在优先级在 React 内部用变量追踪,直接改值就行。这套手动展开写法换来的是入口和恢复点全部可见。

Context 的读和记

Provider 一侧很薄。pushProviderpackages/react-reconciler/src/ReactFiberNewContext.new.js)把旧值压进 valueCursor 栈,context._currentValue 换成新值;popProvider 出栈恢复。渲染过程中任何时刻读到的 _currentValue 都是栈顶 Provider 的值,嵌套 Provider 的遮蔽关系由栈天然表达。

consumer 一侧的关键在 readContext。组件读 context 时(useContext、class 的 contextType、Consumer 的 render prop 最终都到这里),除了返回 _currentValue,还把一条 {context, memoizedValue, next} 记录挂到当前 fiber 的 dependencies.firstContext 链表上。这份链表是反向索引:Provider 变更时不用猜谁在读,顺着 dependencies 能找到订阅了它的确切 fiber。每次 render 前 prepareToReadContext 会把 workInProgress 的 firstContext 清空,render 过程中重新收集,所以链表永远反映最近一次渲染的真实订阅情况,组件条件渲染里不再执行的读操作会自动从链表里消失。readContext 里还有两处细节:同一个组件重复读同一个 context 不会重复入链,lastFullyObservedContext 把后一次挡掉;context 对象上挂着 _currentValue_currentValue2 两个值槽,readContext 按 isPrimaryRenderer 决定读哪个,次渲染器(比如 ART)走后者,两个值槽互不干扰,同一份 context 对象可以服务两种渲染目标。

Provider 变更怎么传播

updateContextProviderReactFiberBeginWork.new.js)在 push 新值之后比较新旧 value,用的是 is(),即 Object.is 语义。值没变、children 也是同一个引用,直接 bailoutOnAlreadyFinishedWork;值变了,调 propagateContextChange

flag enableLazyContextPropagation 当前是 false,实际走的是 propagateContextChange_eager。它从 Provider 的 child 出发深度优先遍历整棵子树,对每个 fiber 翻 dependencies 链表,找到订阅了这个 context 的 fiber 就做三件事:fiber.lanes 合并 renderLanes,alternate 同步合并,然后 scheduleWorkOnParentPath 从它的父节点开始沿 return 一路向上,把路径上每个祖先(连同各自的 alternate)的 childLanes 都合并上 renderLanes,遇到已包含这些 lane 的节点就停。consumer 是 class 组件时额外塞一个 ForceUpdate 进它的 updateQueue,lane 取 pickArbitraryLane(renderLanes),拦住 shouldComponentUpdate 的拦截。这段入队代码没有复用 enqueueUpdate,环形链表的插入是内联手写的,旁边注释坦白了一个已知问题:这会同时写到 current fiber 上,即使这次 render 被丢弃更新也会留着,作者判断这是 race condition 级别的问题,暂不值得修。

遍历还有两个提前终止规则。遇到同类型的内层 Provider 不再深入:内层的值会盖住外层,那边的 consumer 归内层管,外层这次变更影响不到它们。遇到 DehydratedFragment(未注水的 Suspense 内容)时没法确认里面有没有 consumer,直接把父边界标记上更新,并用 childLanes 表达「子树里有 context 变更」这层信息。

为什么 memo bailout 吞不掉 context 变更

这是两条链的会合点。bailoutOnAlreadyFinishedWork 的判断是:

if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
  return null;
}
cloneChildFibers(current, workInProgress);

fiber 自己没有待办(lanes 不命中 renderLanes),整棵子树也没有待办(childLanes 也不命中),就返回 null 全部跳过。memo 组件靠 props 浅比较、class 组件靠 shouldComponentUpdate,这两层比较管的都是 props 和 state,看不到 context。context 的正确性由 lane 标记保证:传播阶段已经把 consumer 的 lanes 和祖先路径的 childLanes 全部写好,bailout 查到中间层时 childLanes 一定命中,于是 cloneChildFibers 继续向下,直到 consumer 自己的 lanes 命中、重新执行、readContext 读到新的 _currentValue

consumer 自身还有一道保险。传播时 dependencies 结构上的 list.lanes 也被合并,prepareToReadContext 在组件开始 render 前检查这份记录,命中就调 markWorkInProgressReceivedUpdate() 把 didReceiveUpdate 置真,beginWork 顶层的 bailout 随之失效。也就是说 context 变更从「祖先 bailout」和「自身 bailout」两个方向各堵了一次,用的都是 lanes 标记。

beginWork 里其实还有一道更早的捷径:attemptEarlyBailoutIfNoScheduledUpdate,fiber 没有待办时连 begin 阶段的 switch 都不进,只做必要的栈维护。它的前置检查 checkScheduledUpdateOrContext 只看 lanes 和(lazy 模式下)context 比对,eager 模式下不需要查 context,因为传播阶段已经把该醒的 fiber 全部用 lanes 叫醒了。两道 bailout、一套标记,eager 传播换来的就是让所有快速路径只认 lane 这一个信号。

懒惰传播的实验

ReactFiberNewContext.new.js 里还有一套 lazy 实现,来自 c7b4497988(2021-03-07,[Experiment] Lazily propagate context changes),flag 默认关闭。思路和 eager 相反:Provider 变更时不扫子树,等 render 过程中某个 fiber 要 bailout 了,lazilyPropagateParentContextChanges 才沿父链收集所有变过的 Provider,再向下传播。

收集方式值得展开。propagateParentContextChanges 从当前 fiber 沿 return 向上,对每个 ContextProvider 节点拿 alternate 的 memoizedProps.value 和 pendingProps.value 用 is() 比较,把变过的 context 收进一个数组,然后对这个数组调 propagateContextChanges 在子树里匹配 consumer。匹配逻辑和 eager 版几乎同构,差别在 lazy 版找到匹配后可以不再深入这个 consumer 的子树,反正 render 稍后会走到那里。传播过的子树打上 DidPropagateContext flag,注释解释了它防的是什么:有调度工作的节点进入 begin 阶段时,它的 sibling 也会跟着进来并触发 bailout,sibling 会重复调传播函数,flag 挡住第二遍。另外还有 propagateParentContextChangesToDeferredTree 专门处理 Suspense、Offscreen 这类延迟树:本次 render 不会访问它们,等到以后再看时早就丢了「哪些 Provider 变过」的现场,所以必须当场强制传播整棵子树。

consumer 一侧的责任也变了。lazy 模式下传播不在 dependency 上打脏标记,consumer 要自己在 bailout 前用 checkIfContextChanged 拿 dependencies 里的 memoizedValue 和 _currentValue 逐个比对,有变化就放弃 bailout。readContext 建链表时顺手给 fiber 打上 NeedsPropagation flag,标记它是潜在的传播起点。

eager 的成本集中在 Provider 变更那一刻:value 一变就遍历整棵子树,哪怕这次 render 很快会把大部分分支 bailout 掉。lazy 把这笔账摊进 render,只为实际走到的分支付传播成本。现在的完成度是:propagateContextChange 入口里 lazy 分支只有 Cache 组件在用,注释写明这一点,并且强制传播整棵树;普通 Provider 全部走 eager;checkIfContextChanged 在 flag 关闭时直接 return false。两套代码并存,生产路径是 eager,照实记录。

收束

整条链路画成一张图:

点击事件到 Context 传播的链路

事件系统回答「这次更新拿哪条 lane」,Context 传播回答「这条 lane 怎么送到每个该更新的 fiber」,两个答案都写在 childLanes 上,bailout 是共同的检查点。到这里,从点击到调度的链路就完整了。下一篇把视线移到服务端:Fizz 的流式渲染 6 月已经进了 stable 构建(76f85b3e),下篇拆它的 Segment 和 Task。


1028 字 · 42 段落
xi ming

Written by xi ming You should follow him on Github