React 学习笔记(二)

渲染调度的意义

如果你阅读过 The deepest reason why modern JavaScript frameworks exist 这篇文章,你就会知道,现代前端框架存在的意义就是将 state 与 UI 那繁琐的同步工作抽象出来,让开发者得以将注意力放在数据流的处理上。尽管在数据流的部分有很多话题可以讲,不过现在让我们现将注意力放在如何实现高效的 state/UI 同步工作上。

在以 React/Vue 为代表的依赖于 Virtual DOM 实现的前端框架下,一次同步工作就是比较新旧 Virtual DOMs 组,然后依据差异来执行同步工作。注意到这个新 Virtual DOM 组可能是由于数据变动而生成的。

那么,我们就有了一个问题:我该以怎样的频率去执行更新工作?

粗略地说,这个频率肯定不能太低。如果一个 UI 界面需要 1 秒才能执行一次更新,那跟看 PPT 没啥区别。但如果更新的太快,又会导致系统占用过高,不过其实对于一个 UI 界面,60帧的速度就很不错了。

那么,我们应当如何安排这些更新任务?或者更确切的说,我们管理这些任务的调度器究竟该怎样实现?

调度器实现的基础

注意到我们这里提到的 UI 渲染是一种狭义的概念,换句话说,就是在浏览器端的渲染工作。
所以我们就需要依托浏览器提供的能力来实现渲染调度。更确切的说,是依靠浏览器处理异步任务的能力来实现渲染调度。

异步

JavaScript 本身单线程的设计和浏览器的异步处理机制都是老生常谈的话题了。这里我们再次引用浏览器与 Event Loop 的图:

JavaScript 运行时

简单来说,JavaScript 所依托的 runtime 并不具有处理多线程/多进程的能力。这是早期设计的问题。浏览器为了解决这个问题,实现了一套处理异步任务的机制。这套机制就是由浏览器接管了异步任务的处理,在任务完成后,将异步任务的 callback 推入 task queue 中。等到主执行栈的任务清空后,就会从 task queue 中取出 callback 执行。

调度策略

为了评价一个调度算法的效率,我们提出了两个概念来定量的评价一个调度算法的实现:

  • 周转时间:任务的周转时间定义为任务完成时间减去任务到达系统的时间。
  • 响应时间:任务的响应时间定义为任务首次运行时间减去任务到达系统的时间。

由此,我们可以引出三种基本的调度算法:

  • 先入先出FIFO
  • 最短任务优先SJF
  • 最短完成时间优先STCF
  • 轮转调度RR

总的来说,这四种基本算法都是仅仅依据某一个方面出发而设计的结果。尽管在一些要求不高的系统中,粗暴简单的设计也能解决问题。但在前端框架中,这些设计很显然都无法完成要求。原因很简单,首先异步任务的重要度并不是一样的,其次 UI 的刷新率也是一个很重要的指标。

在经典调度算法中,有一个多级反馈队列MLFQ的算法思想可以借鉴。简单来说,这个算法会依据任务的优先级来调度任务。事实上这也是我们需要的,我们需要根据任务的优先级来调度。这样我们就可以精细的控制各种任务的调度了。
如果你之前了解过调度器设计思想,那么你会很容易的知道,这个优先级的机制就是为了解决调度系统中的经典问题:饥饿问题。简单来说就是防止某个任务被长时间阻塞而无法运行。

React 调度器的设计

在 React 调度器的设计中,主要有两个核心概念:

  • 任务优先级
  • 时间片

更进一步的,React 将其实现为两个核心功能:

  • 基于任务优先级的任务队列管理
  • 引入时间片后任务的中断与恢复

接下来,我们分别谈谈这两个主题。

Fiber Tree

链表结构。我们可以将对树的递归变为链表的循环遍历。

1
2
3
4
5
6
const fiber = {
stateNode, // 对应组件或者 dom 的实例
child, // 指向自己的第一个子节点
sibling, // 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
return, // 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
}

任务优先级

1
2
3
4
5
6
export const NoPriority = 0; // 没有任何优先级
export const ImmediatePriority = 1; // 立即执行的优先级,级别最高
export const UserBlockingPriority = 2; // 用户阻塞级别的优先级
export const NormalPriority = 3; // 正常的优先级
export const LowPriority = 4; // 较低的优先级
export const IdlePriority = 5; // 优先级最低,表示任务可以闲置

前面提到了,React Scheduler 是一个可以配置任务优先级的调度器。那么我们如何实现这个优先级机制呢?答案就是使用过期时间。而任务优先级就是过期时间的重要计算依据。下面来看一下过期时间的计算逻辑:

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
// packages/scheduler/src/forks/SchedulerDOM.js -> unstable_shceduleCallback

function unstable_scheduleCallback(priorityLevel, callback, options) {
var startTime = getCurrentTime() + delay;

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;
}

var expirationTime = startTime + timeout;

// ...
}

事实上,总结一句话,expirationTime 由以下三个部分组成:

1
var expirationTime = getCurrentTime() + delay + timeout;

其中,timeout 指的就是优先级。

任务

1
2
3
4
5
6
7
8
var newTask = {
id: taskIdCounter++,
callback, // 被调度的任务函数
priorityLevel, // 任务优先级
startTime, // 任务开始时间
expirationTime, // 任务过期时间
sortIndex: -1, // 排序依据,在区分为任务的过期或非过期后,它会被赋值为 expirationTime 或 startTime
};

注意到 callback 是被调度的任务,我们下文所说的执行任务,实际上执行的是 newTask 上的 callback
其次,我们也需要根据 callback 的返回值来判断任务的完成情况。具体的说,如果 callback 返回了一个 function,那么说明任务还未完成。这个 function 将会被再次赋值给任务的 callback,且任务不会从队列中被移除,将会被继续调度。如果返回值不是 function,则说明任务完成,则任务会被剔除。

任务调度准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 生成调度任务
// 2. 将任务推入 timerQueue / taskQueue
// 3. 触发调度行为

var taskQueue = [];
var timerQueue = [];

function unstable_scheduleCallback(priorityLevel, callback, options) {
// 1. compute expirationTime
// 2. generate task

if (startTime > currentTime) {
// 任务未过期
newTask.sortIndex = startTime;
push(timerQueue, newTask);
// ...
} else {
// 任务已过期
newTask.sortIndex = expirationTime;
push(taskQueue, 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
export function peek(heap: Heap): Node | null {
return heap.length === 0 ? null : heap[0];
}

function unstable_scheduleCallback(priorityLevel, callback, options) {
// ...
if (startTime > currentTime) {
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// 同一时间只有一个 requestHostTimeout 任务调度在运行
if (isHostTimeoutScheduled) {
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}

// 插入任务,开始调度
requestHostTimeout(handleTimeout, startTime - currentTime);
} else {
// ...
}
}
}

// 等待至 timerQueue 第一个任务的时间
function requestHostTimeout(callback, ms) {
taskTimeoutId = setTimeout(() => {
callback(getCurrentTime());
}, ms);
}

function handleTimeout(currentTime) {
isHostTimeoutScheduled = false;
// 将 timerQuee 中过期的任务放入 taskQueue 中
advanceTimers(currentTime);

// 1. 若未开始调度,即调度线程空闲
if (!isHostCallbackScheduled) {
// 2. advanceTimers 向 taskQueue 中推入了任务
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
} else {
// 3. 若没有,则再次检查过期任务
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}

function advanceTimer(currentTime) {
// Check for tasks that are no longer delayed and add them to the queue.
// ...
}

未过期任务根据其开始时间,会被放入 timerQueue 中。然后根据最早开始的任务,等待一段时间,然后拿到那些过期任务,放进 taskQueue 中执行。
注意到 push 时就会排序。

触发过期任务的调度

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
function unstable_scheduleCallback(priorityLevel, callback, options) {
// ...
if (startTime > currentTime) {
// ...
} else {
// 对于过期任务,以过期时间为 taskQueue 的排序依据
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);

if (enableProfiling) {
markTaskStart(newTask, currentTime);
newTask.isQueued = true;
}

if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
// 使用 flushWork 执行 taskQueue
requestHostCallback(flushWork);
}
}
}

function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}

过期任务的调度很简单,直接执行 taskQueue 就完事了。

开始调度

在实际调度中,为浏览器环境和非浏览器环境分别实现了一套逻辑。原因显而易见,在非浏览器环境下没有帧的概念,也就不需要判断任务执行时间是否超出了时间片限制。但这不是我们需要关注的重点。

1
2
3
4
5
6
7
8
9
10
11
12
13
let shcedulePerformWorkUntilDeadline;
if (typeof setImmediate === 'function') {
schedulePerformWorkUntilDeadline = () => {
setImmediate(performWorkUntilDeadline);
}
} else {
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
}
}

任务执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const performWorkUntilDeadline = () => {
// shceduledHostCallback 已经被赋值为 flushWork
if (scheduledHostCallback !== null) {
// ...
try {
hasMoreWork = shceduledHostCallback(hasTimeRemaining, currentTime);
} finally {
// ...
}
} else {
// ...
}
// ...
}

我们发现,实际上我们在运行调度函数时,实际上运行的是全局存在的一个 scheduledHostCallback 函数。这里我们已经见过这个函数的赋值了。就是在 unstable_scheduleCallback 函数中调用 requestHostCallback(flushWork) 来将 flushWork 赋值给了全局存在的 scheduledHostCallback 指针。

flushWork 的逻辑也非常简单:

1
2
3
4
5
6
7
function flushWork(hasTimeRemaining, initialTime) {
// ...
try {
return workLoop(hasTimeRemaining, initialTime);
}
// ...
}

源码写了很多,但都是在做 profiling 相关的工作,不是核心逻辑。核心逻辑实际上就是去调用了 workLoop 函数。那么我们接下来看看这个函数:

workLoop 函数

在 React 的时间片设计中,任务的执行时间是被限制的。这主要是为了保证页面的刷新率,不让一些需要长时间渲染的工作阻碍了渲染线程,导致页面卡顿。

但这同时也会存在一个问题,就是如果有需要长时间执行的渲染任务的话怎么办?React Scheduler 在阻断了这些任务的执行后,该如何恢复他们呢?

这些问题都将在 workLoop 函数中被解决。

1
2
3
4
function workLoop(hasTimeRemaining, initialTime) {
// 1. 循环 taskQueue 执行任务
// 2. 任务状态的判断(通过任务函数的返回值去识别任务的完成状态)
}

执行任务且判断任务是否执行完成

简单来说,就是从 taskQueue 中取出任务并执行。在我们看实际实现之前,先考虑一下,我们应该如何执行这个被取出来的函数?
也许我们可以直接调用。

1
2
const currentTask = peek(taskQueue);
currentTask();

这是一个正确的,且符合直觉的逻辑。拿到函数的 reference 直接执行就完事了。可这里有个问题,如果这个 callback 返回了另外一个函数呢?换句话说,这个工作如果还未完成怎么办呢?

解决方法也很简单,判断 callback 的返回值是不是一个函数就完事了。

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
function workLoop(hasTimeRemaining, initialTime) {
// ...
currentTask = peek(taskQueue);
while (currentTask !== null && !(enableSchedulerDebugging && isSchedulerPaused)) {
// ...
const callback = currentTask.callback;
if (typeof callback === 'function') {
// ...
const continuationCallback = callback(currentTask.expirationTime <= currentTime);
// ...
if (typeof continuationCallback === 'function') {
currentTask.callback = continuationCallback;
} else {
// ...
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
// ...
} else {
pop(taskQueue);
}

currentTask = peek(taskQueue);
}
// ...
}

简单来说,我们在执行任务时,实际上是执行任务的 callback(还记得 newTask 的数据结构吗?)。在 callback 完成后,如果其返回值是一个 function,那么这个 function 将会被再次赋值给 currentTask.callback,且当前任务并不会从 taskQueue 中被排除。所以下一次 currentTask 获取到的值还是它。
当任务被完成,就会被从 taskQueue 中被排除。就会接着执行下一个任务了。

任务中断与恢复

如果 React 的调度器仅仅到上边所述内容的话,就根本谈不上什么精妙之处了。在 React 中,为了保证渲染的流畅度,设计了 时间片 的概念。简单来说,每一次执行任务的时间都是限制好的,在一次执行中(或者说在一帧中),时间片的停止是不关心任务是否执行完毕了的。
这样做的好处显而易见,页面的帧数将会被很好的保证。

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
function workLoop(hasTimeRemaining, initialTime) {
currentTask = peek(taskQueue);
while (
currentTask !== null
&& !(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime
&& (!hasTimeRemaining || shouldTieldToHost())
) {
break;
}
// 执行任务
}

if (currentTask !== null) {
// 若因为时间片限制,currentTask 不为空,则需要标注任务还未完成
return true;
} else {
// taskQueue 中的任务都完成了,且 timerQueue 中还有任务的话,就去 timerQueue 中取任务,进行下一次的调度
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}

注意到 workLoop 函数的返回值实际上也是 flushWork 函数的返回值。这个值将会被 hasMoreWork 变量接收到。这个变量是在调度入口,即任务执行那一节的 performWorkUntilDeadline 函数中声明的。我们继续来看一下那个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
// ...

let hasMoreWork = true;
try {
hasMoreWokr = shceduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
// ...
}
}
Author: ShroXd
Link: http://www.bebopser.com/2021/04/25/ReactNote2/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.