2023 年,你是时候要掌握 react 的并发渲染了(3) - 优先级机制

三种优先级机制

并发渲染往往意味着同一个时间段(更严谨点说是 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 值。这里的转化路径为:

graph TD A["调用方调用 `unstable_scheduleCallback()` 传入一个 scheduler 的优先级等级"] --> B["scheduler 将该优先级等级转化为一个 timeout(类似于加权策略的权值)"] --> C["通过公式 `expirationTime = startTime + timeout` 计算出 expirationTime "] --> D["把 `expirationTime` 作为 task queue 排序(最小堆排序)的依据 - sortIndex"] --> E["任务的 `expirationTime` 值越小,则意味着优先级越高,越是先被执行"]

简化版的转化图如下:

graph TD A["priority level"] --> timeout --> expirationTime --> sortIndex --> End["任务的 sortIndex 越小,则越先执行"]

小结

总体来说,scheduler 的 priority level 只是向外暴露的优先级机制。从 scheduler 的内部实现来看,任务的优先级衡量值是任务的过期时间。换句话说,在 scheduler 内部,任务的真正优先级取决于两个要素:

  1. 五种优先级等级
  2. 任务的入队时刻的时间戳

所以,理论上来说,从 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 模型,这才是重头戏!

相关推荐
豆豆40 分钟前
为什么用PageAdmin CMS建设网站?
服务器·开发语言·前端·php·软件构建
twins35202 小时前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky2 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~2 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
安冬的码畜日常2 小时前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺
l1x1n03 小时前
No.3 笔记 | Web安全基础:Web1.0 - 3.0 发展史
前端·http·html
昨天;明天。今天。3 小时前
案例-任务清单
前端·javascript·css
zqx_74 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己4 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称5 小时前
Pikachu-csrf-CSRF(get)
前端·csrf