React进阶笔记【10_Concurrent模式原理】

2022/4/6 ReactJS

# 同步和异步入口判定

在 React 中,current 树与 workInProgress 树,两棵树可以对标 “双缓冲” 模式下的两套缓冲数据:当 current 树呈现在用户眼前时,所有的更新都会由 workInProgress 树来承接。workInProgress 树将会在用户看不到的地方(内存里)悄悄地完成所有改变,直到 current 指针指向它的时候,此时就意味着 commit 阶段已经执行完毕,workInProgress 树变成了那棵呈现在界面上的 current 树。

一般情况下,挂载就是一种特殊的更新,**ReactDOM.render 和 setState 一样,也是一种触发更新的姿势。**在 React 中,ReactDOM.rendersetStateuseState 等方法都是可以触发更新的,这些方法发起的调用链路很相似,是因为它们最后都会通过创建 update 对象来进入同一套更新工作流。

以下是更新链路:

dispatchAction 会完成 update 对象的创建:

function dispatchAction(fiber, queue, action){
  // ...
  var update = {
    lane: lane,
    action: action,
    eagerReducer: null,
    eagerState: null,
    next: null
  };
  // ...
}
1
2
3
4
5
6
7
8
9
10
11

dispatchAction 中,调度的是当前触发更新的节点,这一点和挂载过程需要区分开来。在挂载过程中,updateContainer 会直接调度根节点。其实,对于更新这种场景来说,大部分的更新动作确实都不是由根节点触发的,而 render 阶段的起点则是根节点。因此在 scheduleUpdateOnFiber 中:

function scheduleUpdateOnFiber(fiber, lane, eventTime){
  // ...
  var root = markUpdateLaneFromFiberToRoot(fiber, lane);
}
1
2
3
4

markUpdateLaneFromFiberToRoot 将会从当前 Fiber 节点开始,向上遍历直至根节点,并将根节点返回。

在区分同步异步链路的地方有这么一个判断:

// schedueUpdateOnFiber
if(lane === SyncLane){
  // ...
  performSyncWorkOnRoot(root)
}else{
  ensureRootIsScheduled(root, eventTime);
  // ...
}
1
2
3
4
5
6
7
8

在同步的渲染链路中,lane === SyncLane 这个条件是成立的,因此会直接进入 performSyncWorkOnRoot 的逻辑,开启同步的 render 流程;而在异步渲染模式下,则将进入 else 的逻辑。

在 else 的逻辑里,ensureRootIsScheduled 方法决定了如何开启当前更新所对应的 render 阶段:

// ensureRootIsScheduled 
if (newCallbackPriority === SyncLanePriority) {
    // 同步更新的 render 入口
    newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  } else {
    // 将当前任务的 lane 优先级转换为 scheduler 可理解的优先级
    var schedulerPriorityLevel = lanePriorityToSchedulerPriority(newCallbackPriority);
    // 异步更新的 render 入口
    newCallbackNode = scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
  }
1
2
3
4
5
6
7
8
9
10

performSyncWorkOnRoot 是同步更新模式下的 render 阶段入口,performConcurrentWorkOnRoot 是异步模式下的 render 阶段入口。它们对应的调度任务用到的函数分别是 scheduleSyncCallbackscheduleCallback这两个函数在内部都是通过调用 unstable_scheduleCallback 方法来执行任务调度的。而 unstable_scheduleCallback 正是 Scheduler(调度器)中导出的一个核心方法。

# 时间切片

以下第一张图就是未切片的效果,浏览器不可中断,很容易卡死,第二张图是 createRoot 切片后的效果(即开启 ConCurrent 模式),解决了 JS对主线程的超时占用问题。

再同步渲染中,循环创建 Fiber 节点,构建 Fiber 树的过程是由 workLoopSync 函数来触发的:

function workLoopSync(){
  while(workInProgress !== null){
    performUnitOfWork(workInProgress);
  }
}
1
2
3
4
5

workLoopSync 中,只要 workInProgress 不为空,while 循环就不会结束,它所触发的是一个同步的 performUnitOfWork 循环调用过程。

而在异步渲染模式下,这个循环是由 workLoopConcurrent 来开启的。workLoopConcurrent 的工作内容和 workLoopSync 非常相似,仅仅在循环判断上有一处不同:

function workLoopConcurrent(){
  while(workInProgress !== null && !shouldYield()){
    performUnitOfWork(workInProgress);
  }
}
1
2
3
4
5

当 shouldYield() 调用返回为 true 时,就说明当前需要对主线程进行让出了,此时 whille 循环的判断条件整体为 false,while 循环将不再继续

在其他源码里有这两句:

var Scheduler_shouldYield = Scheduler.unstable_shouldYield,
// ......
var shouldYield = Scheduler_shouldYield;
1
2
3

shouldYield 的本体其实是 Scheduler.unstable_shouldYield,也就是 Scheduler 包中导出的 unstable_shouldYield 方法,该方法本身比较简单:

{
  exports.unstable_shouldYield = function(){
    return exports.unstable_now() >= deadline;
  }
}
1
2
3
4
5

其中 unstable_now 这里实际取的就是 performance.now() 的值,即 “当前时间”。那么 deadline 又是什么呢?它可以被理解为当前时间切片的到期时间,它的计算过程在 Scheduler 包中的 performWorkUntilDeadline 方法里可以找到:

var performWorkUntilDeadline = function(){
  // ...
  deadline = currentTime + yieldInterval;
  // ...
}

// 在这行算式里,currentTime 是当前时间,yieldInterval 是时间切片的长度。注意,时间切片的长度并不是一个常量,它是由 React 根据浏览器的帧率大小计算所得出来的,与浏览器的性能有关。
1
2
3
4
5
6
7

做个小总结:React 会根据浏览器的帧率,计算出时间切片的大小,并结合当前时间计算出每一个切片的到期时间。在 workLoopConcurrent 中,while 循环每次执行前,会调用 shouldYield 函数来询问当前时间切片是否到期,若已到期,则结束循环、出让主线程的控制权。

# 优先级调度的实现

无论是 scheduleSyncCallback 还是 scheduleCallback,最终都是通过调用 unstable_scheduleCallback 来发起调度的。unstable_scheduleCallbackScheduler 导出的一个核心方法,它将 结合任务的优先级信息为其执行不同的调度逻辑

function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 获取当前时间
  var currentTime = exports.unstable_now();
  // 声明 startTime,startTime 是任务的预期开始时间
  var startTime;
  // 以下是对 options 入参的处理
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;

    // 若入参规定了延迟时间,则累加延迟时间
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }
  // timeout 是 expirationTime 的计算依据
  var timeout;
  // 根据 priorityLevel,确定 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;
  }
  // 优先级越高,timout 越小,expirationTime 越小
  var expirationTime = startTime + timeout;

  // 创建 task 对象
  var newTask = {
    id: taskIdCounter++,
    callback: callback,
    priorityLevel: priorityLevel,
    startTime: startTime,
    expirationTime: expirationTime,
    sortIndex: -1
  };

  {
    newTask.isQueued = false;
  }
  // 若当前时间小于开始时间,说明该任务可延时执行(未过期)
  if (startTime > currentTime) {
    // 将未过期任务推入 "timerQueue"
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);

    // 若 taskQueue 中没有可执行的任务,而当前任务又是 timerQueue 中的第一个任务
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      ......
          // 那么就派发一个延时任务,这个延时任务用于检查当前任务是否过期
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // else 里处理的是当前时间大于 startTime 的情况,说明这个任务已过期
    newTask.sortIndex = expirationTime;
    // 过期的任务会被推入 taskQueue
    push(taskQueue, newTask);
    ......

    // 执行 taskQueue 中的任务
    requestHostCallback(flushWork);
  }
  return newTask;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

可以看出,unstable_scheduleCallback 的主要工作是针对当前任务创建一个 task,然后结合 startTime 信息将这个 task 推入 timerQueuetaskQueue,最后根据 timerQueuetaskQueue 的情况,执行延时任务或即时任务。

  • startTime:任务的开始时间。
  • expirationTime:这是一个和优先级相关的值,expirationTime 越小,任务的优先级就越高。
  • timerQueue:一个以 startTime 为排序依据的小顶堆,它存储的是 startTime 大于当前时间(也就是待执行)的任务。
  • taskQueue:一个以 expirationTime 为排序依据的小顶堆,它存储的是 startTime 小于当前时间(也就是已过期)的任务。

小堆顶

堆是一种特殊的 完全二叉树 (opens new window)。如果对一棵完全二叉树来说,它每个结点的结点值都不大于其左右孩子的结点值,这样的完全二叉树就叫 “小顶堆 (opens new window)”。小顶堆自身特有的插入和删除逻辑,决定了无论我们怎么增删小顶堆的元素,其根节点一定是所有元素中值最小的一个节点。这样的性质,使得小顶堆经常被用于实现 优先队列

// 以下为整个 unstable_scheduleCallback 方法中的核心逻辑

// 若当前时间小于开始时间,说明该任务可延时执行(未过期)
if (startTime > currentTime) {
  // 将未过期任务推入 "timerQueue"
  newTask.sortIndex = startTime;
  push(timerQueue, newTask);

  // 若 taskQueue 中没有可执行的任务,而当前任务又是 timerQueue 中的第一个任务
  if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
    // ......
    // 那么就派发一个延时任务,这个延时任务用于将过期的 task 加入 taskQueue 队列
    requestHostTimeout(handleTimeout, startTime - currentTime);
  }
} else {
  // else 里处理的是当前时间大于 startTime 的情况,说明这个任务已过期
  newTask.sortIndex = expirationTime;
  // 过期的任务会被推入 taskQueue
  push(taskQueue, newTask);
  // ......

  // 执行 taskQueue 中的任务
  requestHostCallback(flushWork);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

若判断当前任务是待执行任务,那么该任务会在 sortIndex 属性被赋值为 startTime 后,被推入 timerQueue。随后,会进入这样的一段判断逻辑:

// 若 taskQueue 中没有可执行的任务,而当前任务又是 timerQueue 中的第一个任务
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
  ......
    // 那么就派发一个延时任务,这个延时任务用于将过期的 task 加入 taskQueue 队列
  requestHostTimeout(handleTimeout, startTime - currentTime);
}
1
2
3
4
5
6

peek() 的入参是一个小顶堆,它将取出这个小顶堆的堆顶元素,taskQueue 里存储的是已过期的任务,peek(taskQueue) 取出的任务若为空,则说明 taskQueue 为空、当前并没有已过期任务。在没有已过期任务的情况下,会进一步判断 timerQueue,也就是未过期任务队列里的情况。

小顶堆是一个相对有序的数据结构。timerQueue 作为一个小顶堆,它的排序依据其实正是 sortIndex 属性的大小。这里的 sortIndex 属性取值为 startTime意味着小顶堆的堆顶任务一定是整个 timerQueue 堆结构里 startTime 最小的任务,也就是需要最早被执行的未过期任务

若当前任务(newTask)就是 timerQueue 中需要最早被执行的未过期任务,那么 unstable_scheduleCallback 会通过调用 requestHostTimeout,为当前任务发起一个延时调用。

注意,这个延时调用(也就是 handleTimeout)并不会直接调度执行当前任务——它的作用是在当前任务到期后,将其从 timerQueue 中取出,加入 taskQueue 中,然后触发对 flushWork 的调用。真正的调度执行过程是在 flushWork 中进行的。flushWork 中将调用 workLoop,workLoop 会逐一执行 taskQueue 中的任务,直到调度过程被暂停(时间片用尽)或任务全部被清空

// 以下是对过期任务的处理逻辑

{
  // else 里处理的是当前时间大于 startTime 的情况,说明这个任务已过期
  newTask.sortIndex = expirationTime;
  // 过期的任务会被推入 taskQueue
  push(taskQueue, newTask);
  ......
  // 执行 taskQueue 中的任务
  requestHostCallback(flushWork);
}
1
2
3
4
5
6
7
8
9
10
11

timerQueue 不同的是,taskQueue 是一个以 expirationTimesortIndex(排序依据)的小顶堆。对于已过期任务,React 在将其推入 taskQueue 后,会通过 requestHostCallback(flushWork) 发起一个针对 flushWork 的即时任务,而 flushWork 会执行 taskQueue 中过期的任务。

从 React 17.0.0 源码来看,当下 React 发起 Task 调度的姿势有两个:setTimeoutMessageChannel。在宿主环境不支持 MessageChannel 的情况下,会降级到 setTimeout。但不管是 setTimeout 还是 MessageChannel,它们发起的都是异步任务

因此 requestHostCallback 发起的“即时任务”最早也要等到下一次事件循环才能够执行。“即时”仅仅意味它相对于“延时任务”来说,不需要等待指定的时间间隔,并不意味着同步调用。

以下是 unstable_scheduleCallback 的工作流: