三种优先级机制
并发渲染往往意味着同一个时间段(更严谨点说是 react 还在 render 阶段的时候)用户触发了多个更新请求,多个更新请求往往意味着多个界面更新任务的产生(批量更新模式下,多个更新请求被 react 压缩到一个任务里面去完成。)。
如果特别说明,后文提起的「任务」指代的是「界面更新任务」
鉴于浏览器是用一个单线程去执行 js ,所以本质上来说,在足够小的时间窗口来看,react 只能执行一个界面更新任务。手上同时接到这么多的任务,那 react 是怎么决定要执行哪个任务呢?答案是:"任务的优先级"。优先级越高,则代表该任务越紧急;任务越紧急,则代表着这个任务越先执行。
凡是深入到 react 源码中的人都知道,在 react 的源码内部,存在三种优先级机制:
- scheduler 优先级;
- 事件优先级;
- 基于 lane 模型的优先级(以下简称"lane 优先级");
scheduler 优先级
其实,我们在上一篇文章《2023 年,你是时候要掌握 react 的并发渲染了(2) - scheduler》已经提到过了,在 scheduler 里面存在一个优先级系统。该系统里面有五种优先级等级:
js
// TODO: Use symbols?
const ImmediatePriority = 1;
const UserBlockingPriority = 2;
const NormalPriority = 3;
const LowPriority = 4;
const IdlePriority = 5;
同时,我也指出了,上面的优先级变量数值类型的值是没有特别含义的(因为它没有如它的类型语义那样参与到算术运算里面去),正如源码中注释所表达的那样,它们的值完全用 ES6 的 symbol 来代表。
在 scheduler 的内部,这五种优先级等级只是用来映射到对应的 timeout
值(数值类型的常量,单位都是ms
):
js
// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
var maxSigned31BitInt = 1073741823;
// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;
function unstable_scheduleCallback(priorityLevel, callback, options) {
...
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
}
...
}
简单总结为:
js
ImmediatePriority -> -1ms
UserBlockingPriority -> 250ms
NormalPriority -> 5000ms
LowPriority -> 10000ms
IdlePriority -> 1073741823ms
而 timeout
又有什么用呢?timeout
是用来参与任务过期时间 expirationTime
的计算:
js
function unstable_scheduleCallback(priorityLevel, callback, options) {
...
// 对于普通任务而言,`startTime` 就是当前的时间戳
var expirationTime = startTime + timeout;
...
}
继续往下问,expirationTime
又有什么用呢?expirationTime
会作为 scheduler 中任务在 task queue
中的排序(最小堆排序)的依据值 - sortIndex
。任务的 expirationTime
值越小,则越先被执行。
js
function unstable_scheduleCallback(priorityLevel, callback, options) {
...
newTask.sortIndex = expirationTime;
...
}
综上所述,scheduler 的优先级等级最终会被转化为一个任务的 expirationTime
值。这里的转化路径为:
简化版的转化图如下:
小结
总体来说,scheduler 的 priority level 只是向外暴露的优先级机制。从 scheduler 的内部实现来看,任务的优先级衡量值是任务的过期时间。换句话说,在 scheduler 内部,任务的真正优先级取决于两个要素:
- 五种优先级等级;
- 任务的入队时刻的时间戳;
所以,理论上来说,从 scheduler 这边来看,优先级等级高的任务并不一定会比优先级等级低的任务先执行。为什么?因为还要看「任务入队时间」。
下面举例说明。当前 task queue 中有一个优先级等级为
NormalPriority
的任务 A,但是它入队的时间戳为1000ms
(记住,time origin 是标签页被打开的瞬间)。然后,在它等待执行的期间 - 时间戳为6002ms
的时刻,我们入队了一个高优先级ImmediatePriority
的任务 B。能够入队任务则表达着 scheduler 的 wrok loop 马上要跑起来了。那请问:"接下来,任务 A 和 任务 B 谁先被执行呢?" 答案是:"A 任务"。原因是 A 任务的过期时间戳为(1000 + 5000 = )6000ms,而 B 任务的过期时间戳为(6002 + (-1) = )6001ms。6000 < 6001,所以,即使任务 A 的优先级等级比任务 B 的优先级等级低,它也先于任务 B 执行。
事件优先级
「事件」到底是指什么事件?
这里的「事件」指的就是 DOM 事件 。DOM 事件(Document Object Model Events)是一个广泛的概念,包括了所有在文档对象模型(DOM)中发生的事件,而其中一部分就是用户与交互所产生的的事件,即 「UI 事件」。在 react 中,有哪些 DOM 事件呢?从下面的 Flow 类型变量中可以一览无遗:
js
export type DOMEventName =
| 'abort'
| 'afterblur' // Not a real event. This is used by event experiments.
// These are vendor-prefixed so you should use the exported constants instead:
// 'animationiteration' |
// 'animationend |
// 'animationstart' |
| 'beforeblur' // Not a real event. This is used by event experiments.
| 'beforeinput'
| 'blur'
| 'canplay'
| 'canplaythrough'
| 'cancel'
| 'change'
| 'click'
| 'close'
| 'compositionend'
| 'compositionstart'
| 'compositionupdate'
| 'contextmenu'
| 'copy'
| 'cut'
| 'dblclick'
| 'auxclick'
| 'drag'
| 'dragend'
| 'dragenter'
| 'dragexit'
| 'dragleave'
| 'dragover'
| 'dragstart'
| 'drop'
| 'durationchange'
| 'emptied'
| 'encrypted'
| 'ended'
| 'error'
| 'focus'
| 'focusin'
| 'focusout'
| 'fullscreenchange'
| 'gotpointercapture'
| 'hashchange'
| 'input'
| 'invalid'
| 'keydown'
| 'keypress'
| 'keyup'
| 'load'
| 'loadstart'
| 'loadeddata'
| 'loadedmetadata'
| 'lostpointercapture'
| 'message'
| 'mousedown'
| 'mouseenter'
| 'mouseleave'
| 'mousemove'
| 'mouseout'
| 'mouseover'
| 'mouseup'
| 'paste'
| 'pause'
| 'play'
| 'playing'
| 'pointercancel'
| 'pointerdown'
| 'pointerenter'
| 'pointerleave'
| 'pointermove'
| 'pointerout'
| 'pointerover'
| 'pointerup'
| 'popstate'
| 'progress'
| 'ratechange'
| 'reset'
| 'resize'
| 'scroll'
| 'seeked'
| 'seeking'
| 'select'
| 'selectstart'
| 'selectionchange'
| 'stalled'
| 'submit'
| 'suspend'
| 'textInput' // Intentionally camelCase. Non-standard.
| 'timeupdate'
| 'toggle'
| 'touchcancel'
| 'touchend'
| 'touchmove'
| 'touchstart'
// These are vendor-prefixed so you should use the exported constants instead:
// 'transitionend' |
| 'volumechange'
| 'waiting'
| 'wheel';
实际上,上面罗列的绝大部分都是 UI 事件(剩余的就是非 UI 事件的 DOM 事件,比如:load
事件和loadstart
事件等)。因此,几乎可以理解为:"react 的事件优先级关注的是 UI 事件"。
DOM 事件的分类
从优先级的角度出发,react 把 DOM 事件分成了四大类:
- 离散型事件(discrete event);
- 连续型事件(continuous event);
- 默认型事件(default event);
- 空闲型事件(idle event);
什么是「离散型事件」?Dan 叔在 Glossary + Explain Like I'm Five中已经解释过了。
简单来说,就是一个事件代表(用户的)一次意图明确的动作。这样的事件我们就称之为「离散型事件」。典型的离散型事件就是「click 事件」。离散型事件之所以称之为「离散」,这是因为它的一个普通的表现特征是事件与事件之间一般会存在一个可察觉的时间间隙。
「连续型事件」,顾名思义就是「连续发生的事件」。相对于「离散型事件」,我们可以得出「连续型事件」的定义:连续多个事件才能代表(用户的)一次意图明确的动作,这样的事件我们就称之为「连续型事件」。也就是说,连续触发的多个事件被识别为一个动作。最典型的连续性事件非「mouseover 事件和滚轮事件」莫属。连续性事件之间是连续的,所以不会存在一个可察觉的时间间隙。
具体来讲,哪些事件是被归类为离散型事件,哪些事件被归类为连续型事件呢?在源码中的 getEventPriority()
函数里面, react 用枚举法都一一归纳起来了:
js
// react@18.2.0/packages/react-dom/src/events/ReactDOMEventListener.js
export function getEventPriority(domEventName: DOMEventName): * {
switch (domEventName) {
// Used by SimpleEventPlugin:
case 'cancel':
case 'click':
case 'close':
case 'contextmenu':
case 'copy':
case 'cut':
case 'auxclick':
case 'dblclick':
case 'dragend':
case 'dragstart':
case 'drop':
case 'focusin':
case 'focusout':
case 'input':
case 'invalid':
case 'keydown':
case 'keypress':
case 'keyup':
case 'mousedown':
case 'mouseup':
case 'paste':
case 'pause':
case 'play':
case 'pointercancel':
case 'pointerdown':
case 'pointerup':
case 'ratechange':
case 'reset':
case 'resize':
case 'seeked':
case 'submit':
case 'touchcancel':
case 'touchend':
case 'touchstart':
case 'volumechange':
// Used by polyfills:
// eslint-disable-next-line no-fallthrough
case 'change':
case 'selectionchange':
case 'textInput':
case 'compositionstart':
case 'compositionend':
case 'compositionupdate':
// Only enableCreateEventHandleAPI:
// eslint-disable-next-line no-fallthrough
case 'beforeblur':
case 'afterblur':
// Not used by React but could be by user code:
// eslint-disable-next-line no-fallthrough
case 'beforeinput':
case 'blur':
case 'fullscreenchange':
case 'focus':
case 'hashchange':
case 'popstate':
case 'select':
case 'selectstart':
return DiscreteEventPriority;
case 'drag':
case 'dragenter':
case 'dragexit':
case 'dragleave':
case 'dragover':
case 'mousemove':
case 'mouseout':
case 'mouseover':
case 'pointermove':
case 'pointerout':
case 'pointerover':
case 'scroll':
case 'toggle':
case 'touchmove':
case 'wheel':
// Not used by React but could be by user code:
// eslint-disable-next-line no-fallthrough
case 'mouseenter':
case 'mouseleave':
case 'pointerenter':
case 'pointerleave':
return ContinuousEventPriority;
case 'message': {
// We might be in the Scheduler callback.
// Eventually this mechanism will be replaced by a check
// of the current priority on the native scheduler.
const schedulerPriority = getCurrentSchedulerPriorityLevel();
switch (schedulerPriority) {
case ImmediateSchedulerPriority:
return DiscreteEventPriority;
case UserBlockingSchedulerPriority:
return ContinuousEventPriority;
case NormalSchedulerPriority:
case LowSchedulerPriority:
// TODO: Handle LowSchedulerPriority, somehow. Maybe the same lane as hydration.
return DefaultEventPriority;
case IdleSchedulerPriority:
return IdleEventPriority;
default:
return DefaultEventPriority;
}
}
default:
return DefaultEventPriority;
}
}
四种优先级
「事件指什么,有哪些事件」上面已经讨论完毕。因为有四种类型的事件,所以,react 就定义了四种事件优先级:
js
// react@18.2.0/packages/react-reconciler/src/ReactEventPriorities.old.js
export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
export const IdleEventPriority: EventPriority = IdleLane;
可以看出,事件优先级最终是用 lane 优先级来代表的。
事件优先级有什么用?
为了响应用户触发的 UI 事件,大部分情况下,我们是需要通过 react 来更新界面的。换句话说,react 所接收到的更新请求大部分都是来自于 UI 事件。react 团队认为,不同类型下的 UI 事件是能代表着不同优先级的更新请求的。那么通过给事件指定一个优先级,我们间接地给更新请求赋予了优先级。这就是事件优先级的用途。
至于 react 在源码层面是如何给每个 UI 事件去指定一个事件优先级的,下面的章节中会讲到。
lane 优先级
深入讨论 lane 优先级将放在「聚焦 lane 模型」的章节。
三个优先级机制之间的转换
事件优先级 -> lane 优先级
这个转换,在定义「事件优先级」的时候就发生了:
js
// react@18.2.0/packages/react-reconciler/src/ReactEventPriorities.old.js
export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
export const IdleEventPriority: EventPriority = IdleLane;
可以看出:
- 离散型事件的 lane 优先级是
SyncLane
; - 连续型事件的 lane 优先级是
InputContinuousLane
; - 默认事件的 lane 优先级是
DefaultLane
; - 空闲事件的 lane 优先级是
IdleLane
;
lane 优先级 -> 事件优先级
这个转换发生在 lanesToEventPriority
函数里面:
js
function lanesToEventPriority(lanes) {
const lane = getHighestPriorityLane(lanes);
if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
return DiscreteEventPriority;
}
if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
return ContinuousEventPriority;
}
if (includesNonIdleWork(lane)) {
return DefaultEventPriority;
}
return IdleEventPriority;
}
事件优先级 -> scheduler priority
这个转换发生在 ensureRootIsScheduled()
里面:
js
function ensureRootIsScheduled(root, currentTime) {
...
let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediatePriority;
break;
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdlePriority;
break;
default:
schedulerPriorityLevel = NormalPriority;
break;
}
newCallbackNode = scheduleCallback$2(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root)
);
...
}
从上面的代码可以看到,因为是外部向 scheduler 发起调度请求,所以,lane 优先级最终需要转换为 scheduler priority。整个转换链路为:lane -> event priority -> scheduler priority
。在这里,我们聚焦到最后一个环节。我们最终得到这样的转换关系:
DiscreteEventPriority
->ImmediatePriority
;ContinuousEventPriority
->UserBlockingPriority
;DefaultEventPriority
->NormalPriority
;IdleEventPriority
->IdlePriority
;
scheduler priority -> 事件优先级
这个转换发生在 getEventPriority()
函数里面。在这个函数里面,react 会通过 scheduler 包提供的 getCurrentPriorityLevel()
方法获取到当前被 scheduler 调度的任务的 scheduler priority,然后在 react 中把 scheduler priority 转为对应的事件优先级:
js
function getEventPriority(domEventName) {
...
case "message": {
// We might be in the Scheduler callback.
// Eventually this mechanism will be replaced by a check
// of the current priority on the native scheduler.
const schedulerPriority = getCurrentPriorityLevel();
switch (schedulerPriority) {
case ImmediatePriority:
return DiscreteEventPriority;
case UserBlockingPriority:
return ContinuousEventPriority;
case NormalPriority:
case LowPriority:
// TODO: Handle LowSchedulerPriority, somehow. Maybe the same lane as hydration.
return DefaultEventPriority;
case IdlePriority:
return IdleEventPriority;
default:
return DefaultEventPriority;
}
}
...
}
我们最终得到这样的转换关系:
ImmediatePriority
->DiscreteEventPriority
;UserBlockingPriority
->ContinuousEventPriority
;NormalPriority
->DefaultEventPriority
;LowPriority
->DefaultEventPriority
;IdlePriority
->IdleEventPriority
;
总结
基本上,我们主要关注两个转换流程即可:
- 流程 1 - lanes 如何转成 scheduler 中任务的过期时间
expirationTime
; - 流程 2 - 发生在某个 DOM 事件中的更新请求如何转成 lane;
流程 1
js
lanes -> lane -> event priority -> scheduler priority -> timeout -> task expirationTime
当 react 还在 render 阶段,用户此时触发了很多更新请求的话,那么这就到造成了在 root 上面累计了很多的 lane(用 root.pendingLanes
来记录),这时候就就有一个 lanes,它所代表的是一批等待处理的任务集合。接下来,react 就会尝试向 scheduler 发出一个任务调度请求。在发出任务调度请求之前,react 要决定出到底要执行哪种类型的任务(毕竟 js 是执行在一个单线程环境)。因为一个 lane 就是代表一种类型的任务。也就是说 react 要从 lanes 决定出一个 lane。这里采用的逻辑是:从 lanes 中取出优先级最高的那个 lane。具体实现是:
js
function lanesToEventPriority(lanes) {
const lane = getHighestPriorityLane(lanes);
...
}
决策出单个 lane 之后,react 会把这个 lane 转换完成一个事件优先级。这是 lanesToEventPriority()
函数要做的另外一件事情:
js
function lanesToEventPriority(lanes) {
...
if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
return DiscreteEventPriority;
}
if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
return ContinuousEventPriority;
}
if (includesNonIdleWork(lane)) {
return DefaultEventPriority;
}
return IdleEventPriority;
}
可以看出,react 会取一个离要转换的 lane 最近,但是优先级小于或者等于它的事件优先级(记住,事件优先级是用 lane 来表示的)作为最终的值。
到这里,我们大致讲述了 lanes -> lane -> event priority
的过程,至于 event priority -> scheduler priority -> timeout -> task expirationTime
这个过程是怎么转化的,上面的小节中已经讲过,这里就不赘述了。
流程 2
js
对 DOM 事件进行分类 -> 找出所属的事件类型 -> 什么类型的事件就有什么类型的时事件优先级 -> lane
因为 reactjs 是一个用户 UI 类库。所以,响应用户的各种事件是它最重要的一部分职责之一。针对这个转换流程中,react 提前做了很多功课。其中一件就是 - 从优先级的角度出发,给 DOM 事件进行分类:
- 离散型事件;
- 连续型事件;
- 默认事件;
- 空闲事件;
并且,提前包裹了我们的合成事件处理器,其中的一层包裹层要做的就是对不同的类型的事件预设好一个事件优先级。这样一来,当事件发生的时候,我们就能根据事件名称来找到它所属的分类,从而就知道了当前更新请求的事件优先级。
因为,在 react 内部,事件优先级其实是用 lane 来表示的,所以,最终我们就得到了一个 lane。
最后想说的是,到这里还没有完。在下一篇,我们会深入 lane 模型,这才是重头戏!