React源码分析8-状态更新的优先级机制

43 阅读21分钟

为什么需要优先级

优先级机制最终目的是为了实现高优先级任务优先执行,低优先级任务延后执行

实现这一目的的本质就是在低优先级任务执行时,有更高优先级任务进来的话,可以打断低优先级任务的执行。

同步模式下的react运行时

我们知道在同步模式下,从 setState 到 虚拟DOM遍历,再到真实DOM更新,整个过程都是同步执行且无法被中断的,这样可能就会出现一个问题 —— 用户事件触发的更新被阻塞。

什么是用户事件触发的更新被阻塞?如果 React 正在进行更新任务,此时用户触发了交互事件,且在事件回调中执行了 setState,在同步模式下,这个更新任务需要 等待 当前正在更新的任务完成之后,才会被执行。假如当前 React 正在进行的更新任务耗时比较久,用户事件触发的更新任务不能及时被执行,造成下个更新任务被阻塞,从而形成了卡顿。

这时候,我们就希望能够及时响应用户触发的事件,优先执行用户事件触发的更新任务,也就是我们说的异步模式

我们可以比较一下,同步模式下和异步模式(优先级机制)下更新任务执行的差异

import React from "react";
import "./styles.css";

export default class extends React.Component {
  constructor() {
    super();
    this.state = {
      list: new Array(10000).fill(1),
    };
    this.domRef = null;
  }
  componentDidMount() {
    setTimeout(() => {
      console.log("setTimeout 准备更新", performance.now());
      this.setState(
        {
          list: new Array(10000).fill(Math.random() * 10000),
          updateLanes: 16
        },
        () => {
          console.log("setTimeout 更新完毕", performance.now());
        }
      );
    }, 100);
    setTimeout(() => {
      this.domRef.click();
    }, 150);
  }

  render() {
    const { list } = this.state;
    return (
      <div
        ref={(v) => (this.domRef = v)}        className="App"        onClick={() => {          console.log("click 准备更新", performance.now());          this.setState(            { list: new Array(10000).fill(2), updateLanes: 1 },            () => {              console.log("click 更新完毕", performance.now());            }          );        }}      >        {list.map((i, index) => (          <h1 key={i + +index}>Hello {i}</h1>
        ))}      </div>
    );
  }
}

click事件 触发的更新,会比 setTimeout 触发的更新更优先执行,做到了及时响应用户事件,打断 setTimeout 更新任务(低优先级任务)的执行。

如何运用优先级机制优化react运行时

为了解决同步模式渲染下的缺陷,我们希望能够对 react 做出下面这些优化

  1. 确定不同场景下所触发更新的优先级,以便我们可以决定优先执行哪些任务
  2. 若有更高优先级的任务进来,我们需要打断当前进行的任务,然后执行这个高优先级任务
  3. 确保低优先级任务不会被一直打断,在一定时间后能够被升级为最高优先级的任务

确定不同场景下的调度优先级

看过 react 源码的小伙伴可能都会有一个疑惑,为什么源码里面有那么多优先级相关的单词??怎么区分他们呢?

其实在 react 中主要分为两类优先级,scheduler 优先级和 lane 优先级,lane优先级下面又派生出 event 优先级

  • lane 优先级:主要用于任务调度前,对当前正在进行的任务和被调度任务做一个优先级校验,判断是否需要打断当前正在进行的任务
  • event 优先级:本质上也是lane优先级,lane优先级是通用的,event优先级更多是结合浏览器原生事件,对lane优先级做了分类和映射
  • scheduler 优先级:主要用在时间分片中任务过期时间的计算

lane优先级

可以用赛道的概念去理解lane优先级,lane优先级有31个,我们可以用31位的二进制值去表示,值的每一位代表一条赛道对应一个lane优先级,赛道位置越靠前,优先级越高

优先级十进制值二进制值赛道位置
NoLane000000000000000000000000000000000
SyncLane100000000000000000000000000000010
InputContinuousHydrationLane200000000000000000000000000000101
InputContinuousLane400000000000000000000000000001002
DefaultHydrationLane800000000000000000000000000010003
DefaultLane1600000000000000000000000000100004
TransitionHydrationLane3200000000000000000000000001000005
TransitionLane16400000000000000000000000010000006
TransitionLane212800000000000000000000000100000007
TransitionLane325600000000000000000000001000000008
TransitionLane451200000000000000000000010000000009
TransitionLane51024000000000000000000001000000000010
TransitionLane2048000000000000000000010000000000011
TransitionLane74096000000000000000000100000000000012
TransitionLane88192000000000000000001000000000000013
TransitionLane916384000000000000000010000000000000014
TransitionLane1032768000000000000000100000000000000015
TransitionLane1165536000000000000001000000000000000016
TransitionLane12131072000000000000010000000000000000017
TransitionLane13262144000000000000100000000000000000018
TransitionLane14524288000000000001000000000000000000019
TransitionLane151048576000000000010000000000000000000020
TransitionLane162097152000000000100000000000000000000021
RetryLane14194304000000001000000000000000000000022
RetryLane28388608000000010000000000000000000000023
RetryLane316777216000000100000000000000000000000024
RetryLane433554432000001000000000000000000000000025
RetryLane567108864000010000000000000000000000000026
SelectiveHydrationLane134217728000100000000000000000000000000027
IdleHydrationLane268435456001000000000000000000000000000028
IdleLane536870912010000000000000000000000000000029
OffscreenLane1073741824100000000000000000000000000000030

event优先级

EventPriorityLane数值
DiscreteEventPriority离散事件。click、keydown、focusin等,事件的触发不是连续,可以做到快速响应SyncLane1
ContinuousEventPriority连续事件。drag、scroll、mouseover等,事件的是连续触发的,快速响应可能会阻塞渲染,优先级较离散事件低InputContinuousLane4
DefaultEventPriority默认的事件优先级DefaultLane16
IdleEventPriority空闲的优先级IdleLane536870912

scheduler优先级

SchedulerPriorityEventPriority大于>17.0.2小于>17.0.2
ImmediatePriorityDiscreteEventPriority199
UserblockingPriorityUserblocking298
NormalPriorityDefaultEventPriority397
LowPriorityDefaultEventPriority496
IdlePriorityIdleEventPriority595
NoPriority090

优先级间的转换

  • lane优先级 转 event优先级(参考 lanesToEventPriority 函数)

    • 转换规则:以区间的形式根据传入的lane返回对应的 event 优先级。比如传入的优先级不大于 Discrete 优先级,就返回 Discrete 优先级,以此类推
  • event优先级 转 scheduler优先级(参考 ensureRootIsScheduled 函数)

    • 转换规则:可以参考上面scheduler优先级表
  • event优先级 转 lane优先级(参考 getEventPriority 函数)

    • 转换规则:对于非离散、连续的事件,会根据一定规则作转换,具体课参考上面 event 优先级表,

优先级机制如何设计

说到优先级机制,我们可能马上能联想到的是优先级队列,其最突出的特性是最高优先级先出react 的优先级机制跟优先级队列类似,不过其利用了赛道的概念,配合位与运算丰富了队列的功能,比起优先级队列,读写速度更快,更加容易理解

相关参考视频讲解:进入学习

设计思路

  1. 合并赛道:维护一个队列,可以存储被占用的赛道
  2. 释放赛道:根据优先级释放对应被占用赛道
  3. 找出最高优先级赛道:获取队列中最高优先级赛道
  4. 快速定位赛道索引:根据优先级获取赛道在队列中所在的位置
  5. 判断赛道是否被占用:根据传入优先级判断该优先级所在赛道是否被占用

合并赛道

  • 场景
    • 比如当前正在调度的任务优先级是DefaultLane,用户点击触发更新,有一个高优先级的任务SyncLane产生,需要存储这个任务所占用的赛道
  • 运算过程
    • 运算方式:位或运算 - a | b
    • 运算结果:DefaultLane和SyncLane分别占用了第1条和第5条赛道
DefaultLane优先级为16SyncLane优先级为1

16 | 1 = 17

17的二进制值为10001
16的二进制值为100001的二进制值为00001

释放赛道

  • 场景
    • SyncLane 任务执行完,需要释放占用的赛道
  • 运算过程
    • 运算方式:位与+位非 - a & ~b
    • 运算结果:SyncLane赛道被释放,只剩下DefaultLane赛道
17 & ~1 = 16
17的二进制值为10001

为什么用位非?
~1 = -2
2 的二进制是00010,-2的话符号位取反变为10010
1000110010进行位与运算得到10000,也就是十进制的16

找出最高优先级赛道

  • 场景
    • 当前有 DefaultLane 和 SyncLane 两个优先级的任务占用赛道,在进入 ensureRootIsScheduled 方法后,我需要先调度优先级最高的任务,所以需要找出当前优先级最高的赛道
  • 运算过程
    • 运算方式:位与+符号位取反 - a & -b
    • 运算结果:找到了最高优先级的任务SyncLane,SyncLane任务为同步任务,Scheduler将以同步优先级调度当前应用根节点
17 & -17 = 1

17的二进制值为10001
-17的二进制值为00001
1000100001进行位与运算得到1,也就是SyncLane

快速定位赛道索引

  • 场景
    • 饥饿任务唤醒:在发起调度前,我们需要对队列中的所有赛道进行一个判断,判断该赛道的任务是否过期,如果过期,就优先执行该过期任务。为此,需要维护一个长度为31的数组,数组的每个元素的下标索引与31个优先级赛道一一对应,数组中存储的是任务的过期时间,在判断时,我们希望能根据优先级快速找到该优先级在数组中对应的位置。
  • 运算过程
    • 运算方式:Math.clz32
    • 运算结果:找到了DefaultLane的索引位置为4,那就可以释放应用根节点上的eventTimes、expirationTimes,将其所在位置的值赋值为-1,然后执行对应的过期任务
// 找出 DefaultLane 赛道索引
31 - Math.clz32(16) = 4

16的二进制值为10000
索引4对应的就是第五个赛道
  • Math.clz32是用来干什么的?
    • 获取一个十进制数字对应二进制值中开头0的个数。
    • 所以用31减去 Math.clz32 的值就能得到该赛道的索引

判断赛道是否被占用

异步模式下会存在高优先级任务插队的情况,此情况下 state 的计算方式会跟同步模式下**有些不同。

  • 场景
    1. 我们 setState 之后并不是马上就会更新 state,而是会根据 setState 的内容生成一个 Update 对象,这个对象包含了更新内容、更新优先级等属性。
    2. 更新 state 这个动作是在 processUpdateQueue 函数里进行的,函数里面会判断 Update 对象的优先级所在赛道是否被占用,来决定是否在此轮任务中计算这个 Update 对象的 state
      • 如果被占用,代表 Update 对象优先级和当前正在进行的任务相等,可以根据 Update 对象计算 state 并更新到 Fiber 节点的 memoizedState 属性上
      • 如果未被占用,代表当前正在进行的任务优先级比这个 Update 对象优先级高,相应的这个低优先级的 Update 对象将暂不被计算state,留到下一轮低优先级任务被重启时再进行计算
  • 运算过程
    • 运算方式:位与 (renderLanes & updateLanes) == updateLanes
    • 运算结果:0代表当前调度优先级高于某个Update对象优先级
运算公式
(1 & 16) == 16

1的二进制值为00001
16的二进制值为10000 
0000110000进行位与运算得到0

如何将优先级机制融入React运行时

生成一个更新任务

生成任务的流程其实非常简单,入口就在我们常用的 setState 函数,先上图

setState.png

setState 函数内部执行的就是 enqueueUpdate 函数,而 enqueueUpdate 函数的工作主要分为4步:

  1. 获取本次更新的优先级。
  2. 创建 Update 对象
  3. 将本次更新优先级关联到当前Fiber节点、父级节点和应用根节点
  4. 发起 ensureRootIsScheduled 调度。

步骤一:获取本次更新的优先级

步骤一的工作是调用 requestUpdateLane 函数拿到此次更新任务的优先级

  1. 如果当前为非 concurrent 模式
    • 当前不在 render 阶段。返回 syncLane
    • 当前正在 render 阶段。返回 workInProgressRootRenderLanes 中最高的优先级(这里就用到上面的优先级运算机制,找出最高优先级赛道
  2. 如果当前为 concurrent 模式
    • 需要执行延迟任务的话,比如 SuspenduseTransitionuseDefferedValue 等特性。在 transition 类型的优先级中寻找空闲的赛道。transition类型的赛道有 16 条,从第 1 条到第 16 条,当到达第 16 条赛道后,下一次 transition 类型的任务会回到第 1 条赛道,如此往复。
    • 执行 getCurrentUpdatePriority 函数。获取当前更新优先级。如果不为 NoLane 就返回
    • 执行 getCurrentEventPriority 函数。返回当前的事件优先级。如果没有事件产生,返回 DefaultEventPriority

总的来说,requestUpdateLane 函数的优先级选取判断顺序如下:

SyncLane  >>  TransitionLane  >>  UpdateLane  >>  EventLane

估计有很多小伙伴都会很困惑一个问题,为什么会有这么多获取优先级的函数,这里我整理了一下其他函数的职责

image.png

步骤二:创建 Update 对象

这里的代码量不多,其实就是将 setState 的参数用一个对象封装起来,留给 render 阶段用

function createUpdate(eventTime, lane) {
  var update = {
    eventTime: eventTime,
    lane: lane,
    tag: UpdateState,
    payload: null,
    callback: null,
    next: null
  };
  return update;
}

步骤三:关联优先级

在这里先解释两个概念,一个是 HostRoot,一个是 FiberRootNode

  • HostRoot:就是 ReactDOM.render 的第一个参数,组件树的根节点。HostRoot可能会存在多个,因为 ReactDOM.render 可以多次调用
  • FiberRootNode:react 的应用根节点,每个页面只有一个 react 的应用根节点。可以从 HostRoot 节点的 stateNode 属性访问

这里关联优先级主要执行了两个函数

  1. markUpdateLaneFromFiberToRoot。该函数主要做了两个事情
    • 将优先级合并到当前 Fiber 节点的 lanes 属性中
    • 将优先级合并到父级节点的 childLanes 属性中(告诉父节点他的子节点有多少条赛道要跑) 但因为函数传入的 Fiber 节点是 HostRoot,也就是 ReactDOM.render 的根节点,也就是说没有父节点了,所以第二件事情没有做
  2. markRootUpdated。该函数也是主要做了两个事情
    • 将待调度任务优先级合并到当前 react 应用根节点上
    • 计算当前任务优先级赛道占用的开始时间(eventTime)

由此可见,react 的优先级机制并不独立运行在每一个组件节点里面,而是依赖一个全局的 react 应用根节点去控制下面多个组件树的任务调度

优先级关联到这些Fiber节点有什么用?

先说说他们的区别

  • lanes:只存在非 react 应用根节点上,记录当前 Fiber 节点的 lane 优先级
  • childLanes:只存在非 react 应用根节点上,记录当前 Fiber 节点下的所有子 Fiber 节点的 lane 优先级
  • pendingLanes:只存在 react 应用根节点上,记录的是所有 HostRoot 的 lane 优先级

具体应用场景

  1. 释放赛道。上面说的优先级运算机制提到了任务执行完毕会释放赛道,具体来说是在 commit 阶段结束之后释放被占用的优先级,也就是 markRootFinished 函数。
  2. 判断赛道是否被占用。在 render 阶段的 beginWork 流程里面,会有很多判断 childLanes 是否被占用的判断

步骤四:发起调度

调度里面最关键的一步,就是 ensureRootIsScheduled 函数的调用,该函数的逻辑就是由下面两大部分构成,高优先级任务打断低优先级任务饥饿任务问题

高优先级任务打断低优先级任务

该部分流程可以分为三部曲

  1. cancelCallback
  2. pop(taskQueue)
  3. 低优先级任务重启

cancelCallback

var existingCallbackNode = root.callbackNode;
var existingCallbackPriority = root.callbackPriority;
var newCallbackPriority = getHighestPriorityLane(nextLanes);
if (existingCallbackPriority === newCallbackPriority) {
    ...
    return;
}
if (existingCallbackNode != null) {
    cancelCallback(existingCallbackNode);
}

newCallbackNode = scheduleCallback(
    schedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root)
);
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;

上面是 ensureRootIsScheduled 函数的一些代码片段,先对变量做解释

  • existingCallbackNode:当前 render 阶段正在进行的任务
  • existingCallbackPriority:当前 render 阶段正在进行的任务优先级
  • newCallbackPriority:此次调度优先级

这里会判断 existingCallbackPrioritynewCallbackPriority 两个优先级是否相等,如果相等,此次更新合并到当前正在进行的任务中。如果不相等,代表此次更新任务的优先级更高,需要打断当前正在进行的任务

如何打断任务?

  1. 关键函数 cancelCallback(existingCallbackNode)cancelCallback 函数就是将 root.callbackNode 赋值为null
  2. performConcurrentWorkOnRoot 函数会先把 root.callbackNode 缓存起来,在函数末尾会再判断 root.callbackNode 和开始缓存起来的值是否一样,如果不一样,就代表 root.callbackNode 被赋值为null了,有更高优先级任务进来。
  3. 此时 performConcurrentWorkOnRoot 返回值为null

下面是 performConcurrentWorkOnRoot 代码片段

...
var originalCallbackNode = root.callbackNode;
...
// 函数末尾
if (root.callbackNode === originalCallbackNode) {
    return performConcurrentWorkOnRoot.bind(null, root);
}
return null;

由上面 ensureRootIsScheduled 的代码片段可以知道,performConcurrentWorkOnRoot 函数是被 scheduleCallback 函数调度的,具体返回后的逻辑需要到 Scheduler 模块去找

pop(taskQueue)

var callback = currentTask.callback;
if (typeof callback === 'function') {
  ...
} else {
  pop(taskQueue);
}

上面是 Scheduler 模块里面 workLoop 函数的代码片段,currentTask.callback 就是 scheduleCallback 的第二个参数,也就是performConcurrentWorkOnRoot 函数

承接上个主题,如果 performConcurrentWorkOnRoot 函数返回了null,workLoop 内部就会执行 pop(taskQueue),将当前的任务从 taskQueue 中弹出。

低优先级任务重启

上一步中说道一个低优先级任务从 taskQueue 中被弹出。那高优先级任务执行完毕之后,如何重启回之前的低优先级任务呢?

关键是在 commitRootImpl 函数

var remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
markRootFinished(root, remainingLanes);

...

ensureRootIsScheduled(root, now());

markRootFinished 函数刚刚上面说了是释放已完成任务所占用的赛道,那也就是说未完成任务依然会占用其赛道,所以我们可以重新调用 ensureRootIsScheduled 发起一次新的调度,去重启低优先级任务的执行。我们可以看下重启部分的判断

var nextLanes = getNextLanes(
    root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes
);
// 如果 nextLanes 为 NoLanes,就证明所有任务都执行完毕了
if (nextLanes === NoLanes) {
    ...
    root.callbackNode = null;
    root.callbackPriority = NoLane;
    // 只要 nextLanes 为 NoLanes,就可以结束调度了
    return;
}
// 如果 nextLanes 不为 NoLanes,就代表还有任务未执行完,也就是那些被打断的低优先级任务
...

饥饿任务问题

上面说到,在高优先级任务执行完毕之后,低优先级任务就会被重启,但假设如果持续有高优先级任务持续进来,我的低优先级任务岂不是没有重启之日?

所以 react 为了处理解决饥饿任务问题,react 在 ensureRootIsScheduled 函数开始的时候做了以下处理:(参考markStarvedLanesAsExpired函数)

var lanes = pendingLanes;
while (lanes > 0) {
    var index = pickArbitraryLaneIndex(lanes);
    var lane = 1 << index;
    var expirationTime = expirationTimes[index];
    if (expirationTime === NoTimestamp) {
      if ((lane & suspendedLanes) === NoLanes || (lane & pingedLanes) !== NoLanes) {
        expirationTimes[index] = computeExpirationTime(lane, currentTime);
      }
    } else if (expirationTime <= currentTime) {
      root.expiredLanes |= lane;
    }
    lanes &= ~lane;
}
  1. 遍历31条赛道,判断每条赛道的过期时间是否为 NoTimestamp,如果是,且该赛道存在待执行的任务,则为该赛道初始化过期时间
  2. 如果该赛道已存在过期时间,且过期时间已经小于当前时间,则代表任务已过期,需要将当前优先级合并到 expiredLanes,这样在下一轮 render 阶段就会以同步优先级调度当前 HostRoot

可以参考 render 阶段执行的函数 performConcurrentWorkOnRoot 中的代码片段

var exitStatus = shouldTimeSlice(root, lanes) && ( !didTimeout) ? 
                    renderRootConcurrent(root, lanes) : 
                    renderRootSync(root, lanes);

可以看到只要 shouldTimeSlice 只要返回 false,就会执行 renderRootSync,也就是以同步优先级进入 render 阶段。而 shouldTimeSlice 的逻辑也就是刚刚的 expiredLanes 属性相关

function shouldTimeSlice(root, lanes) {
  // 如果 expiredLanes 里面有东西,代表有饥饿任务
  if ((lanes & root.expiredLanes) !== NoLanes) {
    return false;
  }
  var SyncDefaultLanes = InputContinuousHydrationLane | 
                          InputContinuousLane | 
                          DefaultHydrationLane | 
                          DefaultLane;

  return (lanes & SyncDefaultLanes) === NoLanes;
}

总结

react 的优先级机制在源码中并不是一个独立的,解耦的模块,而是涉及到了react整体运行的方方面面,最后回归整理下优先级机制在源码中的使用,让大家对优先级机制有一个更加整体的认知。

  1. 时间分片。涉及到任务打断、根据优先级计算分片时长
  2. setState 生成 Update 对象。每个 Update 对象里面都有一个 lane 属性,代表此次更新的优先级
  3. 高优先级任务打断低优先级任务。每一次调度都会对正在进行任务和当前任务最高优先级做比较,如果不相等,就代表有高优先级任务进来,需要打断当前正在的任务。
  4. 低优先级任务重启。协调 (reconcile) 的下一个阶段是渲染 (renderer),也就是我们说的 commit 阶段,在此阶段末尾,会调用 ensureRootIsScheduled 发起一次新的调度,执行尚未完成的低优先级任务。
  5. 饥饿任务唤醒。每次调度的开始,都会先检查下有没有过期任务,如果有的话,下一次就会以同步优先级进行 render 任务(reconcile),同步优先级就是最高的优先级,不会被打断