React 源码分析 8- 状态更新的优先级机制
为什么需要优先级
优先级机制最终目的是为了实现高优先级任务优先执行,低优先级任务延后执行。
实现这一目的的本质就是在低优先级任务执行时,有更高优先级任务进来的话,可以打断低优先级任务的执行。
同步模式下的 react 运行时
我们知道在同步模式下,从 setState
到 虚拟 DOM 遍历,再到真实 DOM 更新,整个过程都是同步执行且无法被中断的,这样可能就会出现一个问题 —— 用户事件触发的更新被阻塞。
什么是用户事件触发的更新被阻塞?如果 React
正在进行更新任务,此时用户触发了交互事件,且在事件回调中执行了 setState
,在同步模式下,这个更新任务需要 等待
当前正在更新的任务完成之后,才会被执行。假如当前 React
正在进行的更新任务耗时比较久,用户事件触发的更新任务不能及时被执行,造成下个更新任务被阻塞,从而形成了卡顿。
这时候,我们就希望能够及时响应用户触发的事件,优先执行用户事件触发的更新任务,也就是我们说的异步模式
我们可以比较一下,同步模式下和异步模式(优先级机制
)下更新任务执行的差异
click事件
触发的更新,会比 setTimeout
触发的更新更优先执行,做到了及时响应用户事件,打断 setTimeout
更新任务(低优先级任务
)的执行。
如何运用优先级机制优化 react 运行时
为了解决同步模式渲染下的缺陷,我们希望能够对 react
做出下面这些优化
确定不同场景下所触发更新的优先级,以便我们可以决定优先执行哪些任务
若有更高优先级的任务进来,我们需要打断当前进行的任务,然后执行这个高优先级任务
确保低优先级任务不会被一直打断,在一定时间后能够被升级为最高优先级的任务
确定不同场景下的调度优先级
看过 react
源码的小伙伴可能都会有一个疑惑,为什么源码里面有那么多优先级相关的单词??怎么区分他们呢?
其实在 react
中主要分为两类优先级,scheduler
优先级和 lane
优先级,lane
优先级下面又派生出 event
优先级
lane 优先级:主要用于任务调度前,对当前正在进行的任务和被调度任务做一个优先级校验,判断是否需要打断当前正在进行的任务
event 优先级:本质上也是 lane 优先级,lane 优先级是通用的,event 优先级更多是结合浏览器原生事件,对 lane 优先级做了分类和映射
scheduler 优先级:主要用在时间分片中任务过期时间的计算
lane 优先级
可以用赛道的概念去理解 lane 优先级,lane 优先级有 31 个,我们可以用 31 位的二进制值去表示,值的每一位代表一条赛道对应一个 lane 优先级,赛道位置越靠前,优先级越高
event 优先级
scheduler 优先级
优先级间的转换
lane 优先级 转 event 优先级(参考
lanesToEventPriority
函数)转换规则:以区间的形式根据传入的 lane 返回对应的 event 优先级。比如传入的优先级不大于 Discrete 优先级,就返回 Discrete 优先级,以此类推
event 优先级 转 scheduler 优先级(参考
ensureRootIsScheduled
函数)转换规则:可以参考上面 scheduler 优先级表
event 优先级 转 lane 优先级(参考
getEventPriority
函数)转换规则:对于非离散、连续的事件,会根据一定规则作转换,具体课参考上面 event 优先级表,
优先级机制如何设计
说到优先级机制,我们可能马上能联想到的是优先级队列,其最突出的特性是最高优先级先出,react
的优先级机制跟优先级队列类似,不过其利用了赛道的概念,配合位与运算丰富了队列的功能,比起优先级队列,读写速度更快,更加容易理解
相关参考视频讲解:进入学习
设计思路
合并赛道:维护一个队列,可以存储被占用的赛道
释放赛道:根据优先级释放对应被占用赛道
找出最高优先级赛道:获取队列中最高优先级赛道
快速定位赛道索引:根据优先级获取赛道在队列中所在的位置
判断赛道是否被占用:根据传入优先级判断该优先级所在赛道是否被占用
合并赛道
场景
比如当前正在调度的任务优先级是 DefaultLane,用户点击触发更新,有一个高优先级的任务 SyncLane 产生,需要存储这个任务所占用的赛道
运算过程
运算方式:位或运算 -
a | b
运算结果:DefaultLane 和 SyncLane 分别占用了第 1 条和第 5 条赛道
释放赛道
场景
SyncLane 任务执行完,需要释放占用的赛道
运算过程
运算方式:位与+位非 -
a & ~b
运算结果:SyncLane 赛道被释放,只剩下 DefaultLane 赛道
找出最高优先级赛道
场景
当前有 DefaultLane 和 SyncLane 两个优先级的任务占用赛道,在进入 ensureRootIsScheduled 方法后,我需要先调度优先级最高的任务,所以需要找出当前优先级最高的赛道
运算过程
运算方式:位与+符号位取反 -
a & -b
运算结果:找到了最高优先级的任务 SyncLane,SyncLane 任务为同步任务,Scheduler 将以同步优先级调度当前应用根节点
快速定位赛道索引
场景
饥饿任务唤醒:在发起调度前,我们需要对队列中的所有赛道进行一个判断,判断该赛道的任务是否过期,如果过期,就优先执行该过期任务。为此,需要维护一个长度为 31 的数组,数组的每个元素的下标索引与 31 个优先级赛道一一对应,数组中存储的是任务的过期时间,在判断时,我们希望能根据优先级快速找到该优先级在数组中对应的位置。
运算过程
运算方式:Math.clz32
运算结果:找到了 DefaultLane 的索引位置为 4,那就可以释放应用根节点上的 eventTimes、expirationTimes,将其所在位置的值赋值为-1,然后执行对应的过期任务
Math.clz32 是用来干什么的?
获取一个十进制数字对应二进制值中开头 0 的个数。
所以用 31 减去
Math.clz32
的值就能得到该赛道的索引
判断赛道是否被占用
异步模式下会存在高优先级任务插队的情况,此情况下 state
的计算方式会跟同步模式下**有些不同。
场景
我们 setState 之后并不是马上就会更新
state
,而是会根据 setState 的内容生成一个Update
对象,这个对象包含了更新内容、更新优先级等属性。更新
state
这个动作是在processUpdateQueue
函数里进行的,函数里面会判断Update
对象的优先级所在赛道是否被占用,来决定是否在此轮任务中计算这个Update
对象的state
如果被占用,代表
Update
对象优先级和当前正在进行的任务相等,可以根据Update
对象计算state
并更新到 Fiber 节点的memoizedState
属性上如果未被占用,代表当前正在进行的任务优先级比这个
Update
对象优先级高,相应的这个低优先级的Update
对象将暂不被计算 state,留到下一轮低优先级任务被重启时再进行计算运算过程
运算方式:位与
(renderLanes & updateLanes) == updateLanes
运算结果:0 代表当前调度优先级高于某个 Update 对象优先级
如何将优先级机制融入 React 运行时
生成一个更新任务
生成任务的流程其实非常简单,入口就在我们常用的 setState
函数,先上图
setState
函数内部执行的就是 enqueueUpdate
函数,而 enqueueUpdate
函数的工作主要分为 4 步:
获取本次更新的优先级。
创建
Update
对象将本次更新优先级关联到当前 Fiber 节点、父级节点和应用根节点
发起
ensureRootIsScheduled
调度。
步骤一:获取本次更新的优先级
步骤一的工作是调用 requestUpdateLane
函数拿到此次更新任务的优先级
如果当前为非
concurrent
模式当前不在 render 阶段。返回 syncLane
当前正在 render 阶段。返回 workInProgressRootRenderLanes 中最高的优先级(这里就用到上面的优先级运算机制,找出最高优先级赛道)
如果当前为
concurrent
模式需要执行延迟任务的话,比如
Suspend
、useTransition
、useDefferedValue
等特性。在transition
类型的优先级中寻找空闲的赛道。transition
类型的赛道有 16 条,从第 1 条到第 16 条,当到达第 16 条赛道后,下一次transition
类型的任务会回到第 1 条赛道,如此往复。执行
getCurrentUpdatePriority
函数。获取当前更新优先级。如果不为NoLane
就返回执行
getCurrentEventPriority
函数。返回当前的事件优先级。如果没有事件产生,返回DefaultEventPriority
总的来说,requestUpdateLane
函数的优先级选取判断顺序如下:
估计有很多小伙伴都会很困惑一个问题,为什么会有这么多获取优先级的函数,这里我整理了一下其他函数的职责
步骤二:创建 Update 对象
这里的代码量不多,其实就是将 setState 的参数用一个对象封装起来,留给 render 阶段用
步骤三:关联优先级
在这里先解释两个概念,一个是 HostRoot
,一个是 FiberRootNode
HostRoot
:就是ReactDOM.render
的第一个参数,组件树的根节点。HostRoot
可能会存在多个,因为ReactDOM.render
可以多次调用FiberRootNode
:react 的应用根节点,每个页面只有一个 react 的应用根节点。可以从HostRoot
节点的stateNode
属性访问
这里关联优先级主要执行了两个函数
markUpdateLaneFromFiberToRoot
。该函数主要做了两个事情将优先级合并到当前 Fiber 节点的 lanes 属性中
将优先级合并到父级节点的 childLanes 属性中(告诉父节点他的子节点有多少条赛道要跑)但因为函数传入的 Fiber 节点是
HostRoot
,也就是ReactDOM.render
的根节点,也就是说没有父节点了,所以第二件事情没有做markRootUpdated
。该函数也是主要做了两个事情将待调度任务优先级合并到当前 react 应用根节点上
计算当前任务优先级赛道占用的开始时间(eventTime)
由此可见,react
的优先级机制并不独立运行在每一个组件节点里面,而是依赖一个全局的 react
应用根节点去控制下面多个组件树的任务调度
将优先级关联到这些 Fiber 节点有什么用?
先说说他们的区别
lanes:只存在非 react 应用根节点上,记录当前 Fiber 节点的 lane 优先级
childLanes:只存在非 react 应用根节点上,记录当前 Fiber 节点下的所有子 Fiber 节点的 lane 优先级
pendingLanes:只存在 react 应用根节点上,记录的是所有
HostRoot
的 lane 优先级
具体应用场景
释放赛道。上面说的优先级运算机制提到了任务执行完毕会释放赛道,具体来说是在 commit 阶段结束之后释放被占用的优先级,也就是
markRootFinished
函数。判断赛道是否被占用。在 render 阶段的
beginWork
流程里面,会有很多判断childLanes
是否被占用的判断
步骤四:发起调度
调度里面最关键的一步,就是 ensureRootIsScheduled
函数的调用,该函数的逻辑就是由下面两大部分构成,高优先级任务打断低优先级任务 和 饥饿任务问题
高优先级任务打断低优先级任务
该部分流程可以分为三部曲
cancelCallback
pop(taskQueue)
低优先级任务重启
cancelCallback
上面是 ensureRootIsScheduled
函数的一些代码片段,先对变量做解释
existingCallbackNode
:当前 render 阶段正在进行的任务existingCallbackPriority
:当前 render 阶段正在进行的任务优先级newCallbackPriority
:此次调度优先级
这里会判断 existingCallbackPriority
和 newCallbackPriority
两个优先级是否相等,如果相等,此次更新合并到当前正在进行的任务中。如果不相等,代表此次更新任务的优先级更高,需要打断当前正在进行的任务
如何打断任务?
关键函数
cancelCallback(existingCallbackNode)
,cancelCallback
函数就是将root.callbackNode
赋值为 nullperformConcurrentWorkOnRoot
函数会先把root.callbackNode
缓存起来,在函数末尾会再判断root.callbackNode
和开始缓存起来的值是否一样,如果不一样,就代表root.callbackNode
被赋值为 null 了,有更高优先级任务进来。此时
performConcurrentWorkOnRoot
返回值为 null
下面是 performConcurrentWorkOnRoot
代码片段
由上面 ensureRootIsScheduled
的代码片段可以知道,performConcurrentWorkOnRoot
函数是被 scheduleCallback
函数调度的,具体返回后的逻辑需要到 Scheduler
模块去找
pop(taskQueue)
上面是 Scheduler
模块里面 workLoop
函数的代码片段,currentTask.callback
就是 scheduleCallback
的第二个参数,也就是performConcurrentWorkOnRoot
函数
承接上个主题,如果 performConcurrentWorkOnRoot
函数返回了 null,workLoop
内部就会执行 pop(taskQueue)
,将当前的任务从 taskQueue
中弹出。
低优先级任务重启
上一步中说道一个低优先级任务从 taskQueue
中被弹出。那高优先级任务执行完毕之后,如何重启回之前的低优先级任务呢?
关键是在 commitRootImpl
函数
markRootFinished
函数刚刚上面说了是释放已完成任务所占用的赛道,那也就是说未完成任务依然会占用其赛道,所以我们可以重新调用 ensureRootIsScheduled
发起一次新的调度,去重启低优先级任务的执行。我们可以看下重启部分的判断
饥饿任务问题
上面说到,在高优先级任务执行完毕之后,低优先级任务就会被重启,但假设如果持续有高优先级任务持续进来,我的低优先级任务岂不是没有重启之日?
所以 react 为了处理解决饥饿任务问题,react 在 ensureRootIsScheduled
函数开始的时候做了以下处理:(参考markStarvedLanesAsExpired
函数)
遍历 31 条赛道,判断每条赛道的过期时间是否为
NoTimestamp
,如果是,且该赛道存在待执行的任务,则为该赛道初始化过期时间如果该赛道已存在过期时间,且过期时间已经小于当前时间,则代表任务已过期,需要将当前优先级合并到
expiredLanes
,这样在下一轮 render 阶段就会以同步优先级调度当前HostRoot
可以参考 render 阶段执行的函数 performConcurrentWorkOnRoot
中的代码片段
可以看到只要 shouldTimeSlice
只要返回 false,就会执行 renderRootSync
,也就是以同步优先级进入 render 阶段。而 shouldTimeSlice
的逻辑也就是刚刚的 expiredLanes
属性相关
总结
react
的优先级机制在源码中并不是一个独立的,解耦的模块,而是涉及到了 react 整体运行的方方面面,最后回归整理下优先级机制在源码中的使用,让大家对优先级机制有一个更加整体的认知。
时间分片。涉及到任务打断、根据优先级计算分片时长
setState 生成 Update 对象。每个 Update 对象里面都有一个 lane 属性,代表此次更新的优先级
高优先级任务打断低优先级任务。每一次调度都会对正在进行任务和当前任务最高优先级做比较,如果不相等,就代表有高优先级任务进来,需要打断当前正在的任务。
低优先级任务重启。协调
(reconcile)
的下一个阶段是渲染(renderer)
,也就是我们说的 commit 阶段,在此阶段末尾,会调用ensureRootIsScheduled
发起一次新的调度,执行尚未完成的低优先级任务。饥饿任务唤醒。每次调度的开始,都会先检查下有没有过期任务,如果有的话,下一次就会以同步优先级进行 render 任务
(reconcile)
,同步优先级就是最高的优先级,不会被打断
评论