这是从测试看react源码的第一篇,先从一个独立的 Scheduler 模块入手。正如官方所说,Scheduler模块是一个用于协作调度任务的包,防止浏览器主线程长时间忙于运行一些事情,关键任务的执行被推迟。用于 react 内部,现在将它独立出来,将来会成为公开API
环境配置
- 下载源码到本地,我使用的分支是 master(4c7036e80)
- 由于 react 测试代码太多,如果想要仅测试 scheduler 部分代码,需要修改一下jest配置
打开 package.json,可以看到 test 命令的配置文件是 config.source.js
1
"test": "cross-env NODE_ENV=development jest --config ./scripts/jest/config.source.js"
在其中我们可以看到又引入了 config.base.js 文件,接下来就是修改 config.base.js
1
2
3
4
5
6- testRegex: '/__tests__/[^/]*(\\.js|\\.coffee|[^d]\\.ts)$',
+ testRegex: '/__tests__/Scheduler-test.js',
moduleFileExtensions: ['js', 'json', 'node', 'coffee', 'ts'],
rootDir: process.cwd(),
- roots: ['<rootDir>/packages', '<rootDir>/scripts'],
+ roots: ['<rootDir>/packages/scheduler'],在项目根目录下运行 yarn run test,不出意外会看到测试成功运行
jest 相关介绍
在开始之前,先了解一下jest的运行环境,省的一会找不到代码对应位置
- jest.mock()用于mock某个模块,比如
jest.mock('scheduler', () => require('scheduler/unstable_mock'));
, 那么之后require('scheduler')
其实就是引入的scheduler/unstable_mock.js
- 如果你看到以 hostConfig 结尾的文件名中内容是
throw new Error('This module must be shimmed by a specific build.');
,那么就去scripts/jest/setupHostConfig.js
中去查看到底使用了哪个文件。 - expect(xx).Function() 这里的 Function 被称为 matcher。 在 jest 中有些默认的 matcher,比如说 toEqual,也可以自定义。scripts/jest/matchers 就是一些 react 自定义的。
miniHeap
scheduler 用于调度任务,防止浏览器主线程长时间忙于运行一些事情,关键任务的执行却被推迟。那么任务就要有一个优先级,每次优先执行优先级最高的任务。在 schuduler 中有两个任务队列,taskQueue 和 timerQueue 队列,是最小堆实现的优先队列。数组第一项永远为优先级最高的子项。
Scheduler-test.js
1. flushes work incrementally
1 | it('flushes work incrementally', () => { |
Scheduler.unstable_yieldValue
函数比较简单,就是将参数push到一个数组中,用于结果的比较。scheduleCallback
中会构造 Task, 如下1
2
3
4
5
6
7
8
9// 构造Task
var newTask = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
并将其加入 taskQueue 或者 timerQueue。如果当前没有任务在执行则调用 requestHostCallback(flushWork);
用于执行任务。
在测试环境中 requestHostCallback 的实现如下:1
2
3export function requestHostCallback(callback: boolean => void) {
scheduledCallback = callback;
}
仅仅是保存了 flushWork 函数, 并没有执行。
传入的 flushWork
函数返回 boolean 变量,当 true 时为有 task 可以执行,但是当前时间切片已结束,将空出主线程给浏览器。其中执行 workLoop
,workLoop
是任务执行的真正地方。其首先会从当前 timerQueue 中取出定时已经到期的timer,将其加入到 taskQueue 中。接来下就是取出 taskQueue 中的任务并执行。workLoop
代码如下: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// 取出已经到时的 timer,放入 taskQueue 中。
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
const callback = currentTask.callback;
if (callback !== null) {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
markTaskRun(currentTask, currentTime);
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// 当前任务执行过程中被中断,则将之后需要继续执行的callback保存下来
currentTask.callback = continuationCallback;
markTaskYield(currentTask, currentTime);
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
advanceTimers(currentTime);
} else {
// 已经取消的任务,调用unstable_cancelCallback方法取消任务
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
循环取出 taskQueue 中的任务,直到 taskQueue 为空,或者当前时间切片时间已到并且任务也还没有过期,中断循环。把任务放到下一个时间切片执行。注意一下 shouldYieldToHost()
,这个也是判断是否继续执行的条件,之后会用到。
1-4 行代码结果就是将四个 task 推入taskQueue,等待执行。然后去看一下 matcher toFlushAndYieldThrough
, 位置在 scripts/jest/matchers/schedulerTestMatchers.js
。1
2
3
4
5
6
7
8function toFlushAndYieldThrough(Scheduler, expectedYields) {
assertYieldsWereCleared(Scheduler);
Scheduler.unstable_flushNumberOfYields(expectedYields.length);
const actualYields = Scheduler.unstable_clearYields();
return captureAssertion(() => {
expect(actualYields).toEqual(expectedYields);
});
}
执行与 expectedYields(就是test里的[‘A’, ‘B’],[‘C’],[‘D’] ) 数量相同的任务,看结果是否相等unstable_flushNumberOfYields
代码如下,这里的cb就是 flushWork,第一个参数为 true表示当前切片有剩余时间1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24export function unstable_flushNumberOfYields(count: number): void {
if (isFlushing) {
throw new Error('Already flushing work.');
}
if (scheduledCallback !== null) {
const cb = scheduledCallback;
expectedNumberOfYields = count;
isFlushing = true;
try {
let hasMoreWork = true;
do {
// 执行任务
hasMoreWork = cb(true, currentTime);
} while (hasMoreWork && !didStop);
if (!hasMoreWork) {
scheduledCallback = null;
}
} finally {
expectedNumberOfYields = -1;
didStop = false;
isFlushing = false;
}
}
}
那么何时停止呢?记得之前的 shouldYieldToHost
函数吗,在 schedulerHostConfig.mock.js 在实现了此函数,并且添加了一个 didStop 变量用于测试中控制数量,当达到 expectedNumberOfYields 数量时退出循环。代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13export function shouldYieldToHost(): boolean {
if (
(expectedNumberOfYields !== -1 &&
yieldedValues !== null &&
yieldedValues.length >= expectedNumberOfYields) ||
(shouldYieldForPaint && needsPaint)
) {
// We yielded at least as many values as expected. Stop flushing.
didStop = true;
return true;
}
return false;
}
2. cancels work
1 | it('cancels work', () => { |
将task callback 设置为 null 就是取消任务,这部分在 workLoop里有判断
3. executes the highest priority callbacks first
1 | it('executes the highest priority callbacks first', () => { |
在 scheduler 中定义了6中优先级:1
2
3
4
5
6export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;
每种优先级又对应了不同的超时时间:1
2
3
4
5
6
7
8// 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;
其中 NoPriority 和 NormalPriority 的超时时间一样,这可以在 scheduler.js中看到:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15function timeoutForPriorityLevel(priorityLevel) {
switch (priorityLevel) {
case ImmediatePriority:
return IMMEDIATE_PRIORITY_TIMEOUT;
case UserBlockingPriority:
return USER_BLOCKING_PRIORITY_TIMEOUT;
case IdlePriority:
return IDLE_PRIORITY_TIMEOUT;
case LowPriority:
return LOW_PRIORITY_TIMEOUT;
case NormalPriority:
default:
return NORMAL_PRIORITY_TIMEOUT;
}
}
在新建任务时会根据当前时间和超时时间计算出过期时间,taskQueue 是按照过期时间排序的。优先级高的任务,过期时间就会越小,所以会先被执行。
4. expires work
1 | it('expires work', () => { |
UserBlockingPriority 类型的任务,新建 task 时设置过期时间为当前时间 + USER_BLOCKING_PRIORITY_TIMEOUT(250), 而 NormalPriority 则为 当前时间 + NORMAL_PRIORITY_TIMEOUT(5000)。
unstable_advanceTime 作用是就是增量当前时间。比如 unstable_advanceTime(100),意味这当前时间增加了 100ms,如果当前时间大于过期时间,则任务过期。
在最后调用 scheduledCallback 时, 代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16export function unstable_flushExpired() {
if (isFlushing) {
throw new Error('Already flushing work.');
}
if (scheduledCallback !== null) {
isFlushing = true;
try {
const hasMoreWork = scheduledCallback(false, currentTime);
if (!hasMoreWork) {
scheduledCallback = null;
}
} finally {
isFlushing = false;
}
}
}
在其中调用 scheduledCallback(也就是 flushWork) 时将 hasTimeRemaining 参数设置为false,当前时间切片未有剩余,仅任务过期才会执行,未过期则放到下一个时间切片执行。这里的第一个测试 Scheduler.unstable_advanceTime(249),有些多余,不管设置成多少都不会有变化。
这里需要注意的是D, E 任务过期时间为 5249,所以最后 D 任务没有过期 而 E 任务过期了。
5. continues working on same task after yielding
1 | it('continues working on same task after yielding', () => { |
这里主要看下任务C,其 callback 其最后又返回了自身,相当于任务的子任务,被中断之后,下一个时间切片继续执行子任务。子任务继承了父任务的所有参数,当执行完当前子任务之后,仅仅需要设置父任务 callback 函数为下一个子任务 callback,在 workLoop 中的相关代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// 当前任务执行过程中被中断,则将之后需要继续执行的callback保存下来
currentTask.callback = continuationCallback;
markTaskYield(currentTask, currentTime);
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
总结
目前讲解了5个测试,而关于 scheduler 中的测试还有很多,更多的细节还需要自己去分析,希望本文可以起到抛砖引玉的作用,给你起了个阅读源码的头,勿急勿躁。下次会分享 schedulerBrowser-test,模拟浏览器环境对 scheduler 进行测试。