React的调度-v16

本来看到《Scheduling in React》有想法将其翻译一下。不过既然有掘金大佬捷足先登,我就不再做这事了,转过来看看React里面的调度的使用和原理。

这里也就结合自己理解和原文做一些小总结和阐发。主要是归总学习性质,这篇探索性的东西不多。

文章写到一半的时候, 因为一些疑问查资料,又看到了《深入剖析 React Concurrent》。索性对结构有大修了。所以这里把Concurrent放在了前面。

并发 & 调度

并发: Concurrent React

Concurrent React 或者叫Time Slicing,实际上对个人而言,是一个很艰难的话题。在之前文章里,面对它我都采取了略过的态度。

一则同步都没搞明白,更何况异步的处理?二则实际上直到16.9.0,关于它的特性也依然没有正式发布。

后来慢慢一步步深入之后大抵也有了继续探寻的基础。

不过这之前,先得说明白,为什么需要Time Slicing。

  • 动画的流畅性原理。一个动画如果想在人眼中显得『流畅』,那么起码需要24帧每秒(React这里默认是按30帧算),现在显示屏一般技术规格是60Hz(相当于每秒60帧),这足以保证流畅了。算下来就是1000/60≈16.67ms。但是,这里要求的是每个帧是变化运动的。如果一个帧占用多个帧的时间,看起来就是卡顿。
  • 浏览器Event Loop && requestAnimationFrame && requestIdleCallback。这里需要重点理解,读不懂它们,就读不懂全文。

rAF

首先是requestAnimationFrame(简称rAF)。在一个帧里面,它的生命周期如下,每一帧都包含了 用户交互、js执行、rAF调用,布局计算以及页面重绘 等工作。在这个过程中rAF是一个必须执行完成的过程,如果它耗时长,那么帧就会一直等它技术然后再进行 布局计算和重绘。这个过程中可能会超过理想值16.67ms。

1_ad-k5hYKQnRQJF8tv8BIqg

1_atEwskfs0gtIryRrgnAPkw

pollyfill

因为提到的rAF会在页面切换时候进行冻结的问题,这里React做了一个pollyfill。

1
2
3
4
5
6
7
8
9
10
11
12
13
var requestAnimationFrameWithTimeout = function(callback) {
// schedule rAF and also a setTimeout
rAFID = localRequestAnimationFrame(function(timestamp) {
// cancel the setTimeout
localClearTimeout(rAFTimeoutID);
callback(timestamp);
});
rAFTimeoutID = localSetTimeout(function() {
// cancel the requestAnimationFrame
localCancelAnimationFrame(rAFID);
callback(getCurrentTime());
}, ANIMATION_FRAME_TIMEOUT);
};

核心是rAF优先级高于setTimeout。如果页面正在显示那么和rAF没区别,因为setTimeout会被rAF取消,但是如果在页面被隐藏时候,此时rAF就不会运行了,此时setTimeout会接替它的工作。

requestIdleCallback

其次是requestIdleCallback。如果说rAF是每一帧必须执行的话,那么requestIdleCallback相反。它是选择性执行的。如果一个帧执行完毕时候耗时不到16.67ms,那么此时浏览器就处于空闲状态,此时它完成了它的任务,下个任务有没有开始。

此时就可以执行requestIdleCallback的任务了。注意的是,requestIdleCallback执行的时候,整个帧都已经完成了,收尾了,处于可以无缝交接给下一个帧的情况。如果在这个任务里面有改变布局、处理DOM、触发Promise.resolve的情况,会导致下一个帧开头就需要重新计算,或者干脆因为Promise异步导致这个帧重新开始处理拉长耗时下个帧无法开始工作。

这里流程的图片是:

1566444717989

当然,如果多个帧一直没有空闲,那么requestIdleCallback就无法开始执行,为了保障它的执行,它有第二个参数可以设置一个timeout。规定它最迟执行的时间(当然限于浏览器eventLopp也不可能完全准)。

这里参考你应该知道的requestIdleCallback

pollyfill

requestIdleCallback兼容性很差。所以这里还是需要进行pollyfill。
这里找到的hack办法是window.MessageChannel。这个api可以创建一个新的消息通道,实现sub/pub模式。最关键的地方是,响应订阅的函数,执行时机是Paint之后的空余时间。

小总结

将这些归纳起来说,就可以明白这个Time Slicing的含义了。它的意义在于,将大量耗时js操作打碎,将通过类似rAF来实现分帧进行必须的渲染,避免阻塞UI。

但是这带来一个问题就是优先级问题。一个任务,究竟该如何确定是放到rAF,还是放到requestIdleCallback?——所以这里有了调度器,通过它可以对任务进行优先级划分。

调度:调度器的意义

这里我们继续上一小结的话题继续伸延。

在旧版本(v16之前)的里面,render是一个递归的调用,一个组件的更新会引起下级所有组件的重新计算和渲染。

由于渲染会占据主线程(这是宏任务和微任务的范畴了),当这个计算时间超过一定长度(60Hz显示器上是16.67ms)时候,用户就会有卡顿的感觉。

这在需要即时响应用户输入并输出到屏幕的场景特别明显。

针对这个问题,有两个难题需要去解决

  • 一是受制于微任务(render|update)长时间占据主线程,使得浏览器无法对页面进行重新渲染,导致页面卡顿。
  • 二是优先级。微任务耗时可以通过任务分解的方式解决,但是分解之后,任务之间优先级如何安排则是一个问题。

React的解决方案

  • Concurrent React (或Time Slicing)。上一小节介绍过它了。
  • Scheduler(调度器)。它将任务优先级设定了优先级。
    • Immediate。立刻执行
    • UserBlocking。250ms timeout,响应用户界面。
    • Normal。5s timeout,不是必须立刻响应用户的更新
    • Low。10s timeout,可以延迟执行,但是最终必须执行的更新。
    • Idle。这个任务优先级不是很好描述,它是那种视情况进行更新的那种优先级,不是所有场景下都需要执行。比如屏幕之外的内容的更新。

常规调用

我们知道这些其实远远不够。所以这里需要看看更深入一些的东西。

这里首当其冲的,是React在更新阶段链表的构建、更新的标记。所以这里看看事件触发之后,这一块发生的事情。

调用入口

我们回顾一下之前Reconciler文章提到的事件调用栈。

1
2
3
4
5
6
dispatchInteractiveEvent
->interactiveUpdates
-->dispatchEvent
--->performSyncWork
---->performWork
----->performWorkOnRoot -> renderRoot & completeRoot

然后展开一下performWork函数

1
2
3
4
5
performWork () {
findHighestPriorityRoot()
performWorkOnRoot -> renderRoot & completeRoot
findHighestPriorityRoot()
}

这其中findHighestPriorityRoot能确保fiberRoot在调度中(或者是null)。

当然,这里是对FiberRoot上的东西进行遍历然后对比差异。

我们还需要看看如何标记一个节点有变更。

标记变更

这里仔细思考,实际上变更大多情况下是setState引起的。

不管是一个粒度非常小的组件更新内部text,还是通过redux的dispatch来更新App组件的props。实际上都是通过setState标记本身变更,然后由它进行的下级children进行的变更。

这里归总一下《react-v16-Update renderPhase篇》里面提到的setState相关。这里关联的是classComponentUpdater.enqueueSetState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enqueueSetState(inst, payload, callback) {
// 此时inst为App实例 payload={text: 'Hello World'} callback=undefined或setState第二参数
const fiber = getInstance(inst);
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);

const update = createUpdate(expirationTime);
update.payload = payload;
if (callback !== undefined && callback !== null) {
update.callback = callback;
}
flushPassiveEffects(); // 这个例子中这个函数什么也没做
enqueueUpdate(fiber, update);
scheduleWork(fiber, expirationTime);
},

这里很容易可以观察到update对象里面会赋值新的state值(payload参数),如果setState传入第二参数,也会赋值给updata.callback。

完成之后,使用enqueueUpdate将update放到对应fiberNode上。这个函数核心部分:

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
if (queue2 === null || queue1 === queue2) {
// There's only a single queue.
// 此时只有一个queue
appendUpdateToQueue(queue1, update);
} else {
// There are two queues. We need to append the update to both queues,
// while accounting for the persistent structure of the list — we don't
// want the same update to be added multiple times.
// 此时有两个更新队列。我们需要将update操作同时添加到每个队列上
// 但是考虑到更新队列的持久化结构 我们不希望相同的update被添加多次
if (queue1.lastUpdate === null || queue2.lastUpdate === null) {
// One of the queues is not empty. We must add the update to both queues.
// 如果两个queue钟有其中一个队列是空的 那么将这个update加到两个queue队列上
appendUpdateToQueue(queue1, update);
appendUpdateToQueue(queue2, update);
} else {
// Both queues are non-empty. The last update is the same in both lists,
// because of structural sharing. So, only append to one of the lists.
// 如果他们都不是空的。最后一个update在两个队列中则是相同的 因为structural sharing
// 所以将update加入到其中一个队列就可以了。这里将update加入到了queue1 但是却将queue2.lastUpdate指向了update
appendUpdateToQueue(queue1, update);
// But we still need to update the `lastUpdate` pointer of queue2.
queue2.lastUpdate = update;
}
}

说白了核心调用是appendUpdateToQueue。主要就是讲update添加到fiber.updateQueue或者fiber.alternate。updateQueue两个链表上。

当这些处理完毕之后,使用scheduleWork(fiber, expirationTime)进行任务调度,开始遍历fiberNode链表。

但是这里我们假设setState进而引发了一个新的组件的props变化。它会发生什么?这里往下走一走逻辑,观察调用栈:

1
2
scheduleWork
->requestWork(fiberNode, rootExpirationTime)

这里requestWork有三个调用分支

  • performWorkOnRoot(root, Sync, false)
  • performSyncWork() === performWork(Sync, false)
  • scheduleCallbackWithExpirationTime(root, expirationTime)

这里performSyncWork这个和开头提到的『调用入口一致』,最终还是到了performWorkOnRoot。前面两个都是同步的处理。

scheduleCallbackWithExpirationTime则是异步的处理,核心是对scheduleDeferredCallback(performAsyncWork, {timeout})的调用。这个函数指向Scheduler.unstable_scheduleWork,他根据回调和超时时间生成了一个callbackNode,加入到链表并返回。

所以分为同步和异步两种情况来讲。

同步路径

这里performWorkOnRoot的宏观理解需要理解链表是如何模拟树遍历的,这必须优先理解。这块着重理解之前的render篇里面的『遍历理论』就可以了。

但是不要忽略,performWorkOnRoot必然从fiberRoot开始。

微观上来讲的话,就必须看workLoop -> beginWork + completeUnitOfWork。他负责每个fiberNode节点具体处理。

因为这里关注点主要是调度,所以就不考虑初始渲染情况下根据全新VDOM节点构建fiberNode链表的情况。这里需要关注再更新环节它的处理。

beginWork

beginWork要操作的组件类型过多,这里仅仅就HostRoot(fiberRoot) & HostComponent & ClassComponent做一些共性说明。

  • HostComponent处理的入口函数是updateHostComponent,它主要执行reconcileChildren(args) && return workInProgress.child

  • ClassComponent的入口函数式updateClassComponent,它在这里场景下,主要通过updateClassInstance判断是否更新,在这场判断过程中,它调用了componentWillReceiveProps,根据workInProgress上的updateQueue、props、lifecyclesHooks更新了stateNode属性,也就是组件实例 新的props之类都在这个stateNode上保存起来了。当然最重要的,是更新了updateQueue链表。

完毕之后返回finishClassComponent返回值。finishClassComponent检测到变化后,主要调用则如下:

1
2
3
4
5
6
7
8
const instance = workInProgress.stateNode;
nextChildren = instance.render(); // 更新后的实例,render执行会返回一个VDOM树
reconcileChildren( // reconcileChildren等价于ChildReconciler(true)
current,
workInProgress,
nextChildren,
renderExpirationTime,
);

这里的reconcileChildren实际上就是和HostComponent分支这里的处理事同一个入口了。

这里更细致的细节,参见之前提到的文章《react-v16-ChildReconciler》。这里不再做赘述,但是有一些基于它的细节却必须说一下。

reconcileChildren这里实质上只有两种处理逻辑,当它的child是单节点时候按单节点处begininWork + completeUnitOfWork,他负责单个fiberNode节点具体处理,如果有多个节点,那么它就按数组方式处理。但是无论怎样,它只处理自己VDOM树结构下一层对应fiberNode。

更深层次的fiberNode,自然有workLoop函数继续调用beginWork处理。

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
function completeUnitOfWork(workInProgress: Fiber): Fiber | null {
// 尝试完成当前的工作单元,然后转到下一个兄弟fiberNode。
// 如果没有兄弟姐妹,请返回父fiberNode。
while (true) {
const current = workInProgress.alternate;
const returnFiber = workInProgress.return;
const siblingFiber = workInProgress.sibling;

if ((workInProgress.effectTag & Incomplete) === NoEffect) {
// 只会返回Suspense 或者 null
nextUnitOfWork = completeWork(
current,
workInProgress,
nextRenderExpirationTime,
);
if (nextUnitOfWork !== null) {
// Completing this fiber spawned new work. Work on that next.
return nextUnitOfWork;
}
// 注意此时nextUnitOfWork === null
if (
returnFiber !== null && (returnFiber.effectTag & Incomplete) === NoEffect
) {
// 将workInProgress 挂到父节点的side-effect链表上
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = workInProgress.firstEffect;
}
if (workInProgress.lastEffect !== null) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = workInProgress.firstEffect;
}
returnFiber.lastEffect = workInProgress.lastEffect;
}
}
// 按siblingFiber、returnFiber、null顺序返回。如果returnFiber 重新开始前面任务
// 这意味着: 任务在不断往上伸展,节点上的effects都挂载到父fiberNode上了。
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
return siblingFiber;
} else if (returnFiber !== null) {
// If there's no more work in this returnFiber. Complete the returnFiber.
workInProgress = returnFiber;
continue;
} else {
// We've reached the root.
return null;
}
} else {}
}
}

completeUnitOfWork: 标记变更

这个函数在beginWork强大的功能面前可能容易被忽略。但是它做的事情却并非那么容易让人忽视。
我们已经知道,v16的更新统一从FiberRoot起,那么问题来了,如果全部从头到尾的进行遍历,岂不是太费劲而且不必要?所以我们需要一个变更节点列表,以便进行更新时候只更新它们。
这就是这个函数的作用。它里面有一些核心的调用。这里做一些说明。

  • 首先是处理完毕后对fiberNode做一些属性更新,捕获boundary错误之类。
  • 其次,从底部往上遍历,将子节点上的effects链到父节点上,这样,最终我们到fiberRoot节点就有一个完整的side-effects链表了。

变更引用

这里直接上之前提到的代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function workLoop () {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
}
function performUnitOfWork(workInProgress: Fiber): Fiber | null {
// 略
next = beginWork(current, workInProgress, nextRenderExpirationTime);
workInProgress.memoizedProps = workInProgress.pendingProps;
if (next === null) {
next = completeUnitOfWork(workInProgress);
}
ReactCurrentOwner.current = null;
return next;
}

每次当beginWork结束,都会执行一个completeUnitOfWork(workInProgress)。在这个函数中,有一个引用:

resetChildExpirationTime(workInProgress, nextRenderExpirationTime)

这个函数批量更新了后续所有fiberNode的child节点上的ExpirationTime。

再往上一点说,workLoop上面是renderRoot,renderRoot上面还有performWorkOnRoot,而在这个函数里面,renderRoot结束之后,会执行completeRoot函数。
这个函数就是render commitphase环节的入口级调用。
然后这里就到了之前Update篇之commitPhase环节了。这里直接上代码了(commitRoot)。主要是side-effects的遍历处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// commit tree中所有的side-effects。这里分两个步骤
// 这里是第一个步骤:执行所有host的插入、更新、删除和ref卸载(注意后面第二个步骤)
nextEffect = firstEffect;
startCommitHostEffectsTimer();
while (nextEffect !== null) {
let didError = false;
let error;
if (__DEV__) { } else {
try {
commitAllHostEffects();
} catch (e) {
didError = true;
error = e;
}
}
if (didError) { /* 错误捕获并将指针移动到下一个Effect */ }
}

异步逻辑

scheduleCallbackWithExpirationTime

这里的异步处理,实际上上面已经简单提到过了。

scheduleCallbackWithExpirationTime是异步的处理,核心是对scheduleDeferredCallback(performAsyncWork, {timeout})的调用。这个函数指向Scheduler.unstable_scheduleCallback

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
function unstable_scheduleCallback(callback, deprecated_options) {
var startTime =
currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime();

var expirationTime;
if (
typeof deprecated_options === 'object' &&
deprecated_options !== null &&
typeof deprecated_options.timeout === 'number'
) {
// FIXME: Remove this branch once we lift expiration times out of React.
expirationTime = startTime + deprecated_options.timeout;
} else {
switch (currentPriorityLevel) {
case ImmediatePriority:
expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
expirationTime = startTime + USER_BLOCKING_PRIORITY;
break;
case IdlePriority:
expirationTime = startTime + IDLE_PRIORITY;
break;
case LowPriority:
expirationTime = startTime + LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT;
}
}
// 生成一个节点, 存储回调函数和超时时间
var newNode = {
callback,
priorityLevel: currentPriorityLevel,
expirationTime,
next: null,
previous: null,
};

// 排序插入
return newNode;
}

这里核心地方就2个,第一个,根据任务优先级获取不同的expirationTime,第二个,根据expirationTime生成任务节点,排序插入链表。

我们这里忽略了排序细节,不过需要说的是,当我们排序完毕,会有一个ensureHostCallbackIsScheduled函数会被执行。这个函数用来对任务进行执行。

这里的调用在排序逻辑中有两种情况:

  • 原链表为空,所加入的节点为唯一节点,此时立即执行
  • 新节点取代旧的firstNode节点成为新的firstNode节点时候,此时立即执行

它对应着两个分支逻辑: 只有一个节点的情况,执行任务;新节点有最高优先级,需要停止继续执行任务转而重新执行任务。它的意义在于,在合适的时候,开始执行任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function ensureHostCallbackIsScheduled() {
if (isExecutingCallback) {
// Don't schedule work yet; wait until the next time we yield.
return;
}
var expirationTime = firstCallbackNode.expirationTime;
if (!isHostCallbackScheduled) { // 此时仅有一个节点
isHostCallbackScheduled = true;
} else { // 新节点有最高优先级,需要停止继续执行任务
// Cancel the existing host callback.
cancelHostCallback();
}
requestHostCallback(flushWork, expirationTime);
}

关于这个requestHostCallback函数,源码里面做了好几个环境分支,比如jest分支,jscore分支,最后才是常规的浏览器环境分支。我们来看看里面大致细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = function(event) {
// 细节略
};

var animationTick = function(rafTime) {
// 细节略
};

requestHostCallback = function(callback, absoluteTimeout) {
scheduledHostCallback = callback;
timeoutTime = absoluteTimeout;
if (isFlushingHostCallback || absoluteTimeout < 0) {
port.postMessage(undefined);
} else if (!isAnimationFrameScheduled) {
isAnimationFrameScheduled = true;
requestAnimationFrameWithTimeout(animationTick);
}
};

代码有点长,删了细节,只保留了主干。它其实就是requestIdleCallback的的pollyfill而已。这里原理环节可参考前面。

但是这里有一些相互的调用,还是得说明白。

首先是animationTick函数。这个函数在每一帧开始的rAF里面的回调,当有任务时候,需要进行递归执行requestAnimationFrameWithTimeout(animationTick)。它核心的作用是对frameDeadline变量进行累加,计算出当前帧的截止时间: 截止时间 = 开始时间 + 渲染时间。

渲染时间默认为33ms,这是为了保证每秒30帧(30Hz)的计算出来的(1000/30)。源码里面有一个对这个值进行优化的逻辑,因为不是重点,这里且就认为它是33ms。

animationTick执行到尾部,会执行

1
2
3
4
5
6
7
8
// isMessageEventScheduled默认为false。进入animationTick后设为true
// 所以不会连续两次rAF调用port.postMessage。
// 后面port1.onmessage进入后其值为false,可以重新rAF进来。
// 保证了rAF和requestIdleCallback的间歇调用
if (!isMessageEventScheduled) {
isMessageEventScheduled = true;
port.postMessage(undefined);
}

接下来是onmessage的内容。这个函数主要是对剩余时间的利用。

  1. 如果当前帧还有时间空余->当前任务已经过期->didTimeout = true立刻执行任务
  2. 如果当前帧还有时间空余->当前任务没过期->执行flushWork && 递归调用rAF

onmessage在上面两个分支处理完毕后针对这两种情况调用scheduledHostCallback函数,里面会针对这两种情况进行分支处理。

这个scheduledHostCallback函数呢,本质上,就是flushWork。

isAnimationFrameScheduled变量本质上和isMessageEventScheduled变量是同一回事。

callback: flushWork

flushWork函数是异步流程里面的实质上的执行者。

我们之前讨论了rAF->requestIdleCallback->rAF->requestIdleCallback不间断直到完成任务的流程里面,实质上就是这个这个函数在执行异步任务。

这个函数处理三种情况下的逻辑:

  1. 任务已经超时 此时走同步逻辑,遍历执行所有已经过期任务
  2. 任务没过期,当前帧有时间富余 那么从队列首部以类似数组pop方法的形式挨个执行未过期的任务。
  3. 异步任务经过上面逻辑还有剩余,那么新开新一轮调度 && 立即执行最高优先级任务

逻辑一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 这里明白这个队列是根据过期时间从小打大排列就可以了
if (didTimeout) { // 任务过期
// Flush all the expired callbacks without yielding.
// 如果任务已经过期遍历回调链表全部执行 无中断、异步
while (
firstCallbackNode !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
var currentTime = getCurrentTime();
if (firstCallbackNode.expirationTime <= currentTime) {
do {
flushFirstCallback();
} while (
firstCallbackNode !== null &&
firstCallbackNode.expirationTime <= currentTime && // 要求任务已过期
!(enableSchedulerDebugging && isSchedulerPaused)
);
continue;
}
break;
}
} else {} // rAF时间富余

逻辑二:

1
2
3
4
5
6
7
8
9
10
11
12
if (firstCallbackNode !== null) {
do {
if (enableSchedulerDebugging && isSchedulerPaused) {
break;
}
flushFirstCallback();
} while (firstCallbackNode !== null && !shouldYieldToHost());
}
// shouldYieldToHost
shouldYieldToHost = function() {
return frameDeadline <= getCurrentTime();
};

这里shouldYieldToHost函数计算的,rAF deadLine时间戳是否有剩余。如果有剩余就继续执行callback链表上的节点。其他和逻辑一雷同。

逻辑三:

1
2
3
4
5
6
7
8
9
10
isExecutingCallback = false;
currentDidTimeout = previousDidTimeout;
if (firstCallbackNode !== null) {
// There's still work remaining. Request another callback.
ensureHostCallbackIsScheduled();
} else {
isHostCallbackScheduled = false;
}
// Before exiting, flush all the immediate work that was scheduled.
flushImmediateWork();

这里ensureHostCallbackIsScheduled函数在上一小节里面有,他用来唤起一个新的调度。重新走rAF->requestIdleCallback->rAF->requestIdleCallback这个流程。

flushImmediateWork函数则是直接把剩余的最高优先级任务一口气执行完毕。但是这里要注意到,它执行完毕之后,又开始执行ensureHostCallbackIsScheduled了。

考虑到函数里面这样一个调用

1
2
3
4
5
6
7
try {} finally {
if (firstCallbackNode !== null) {
ensureHostCallbackIsScheduled();
} else {
isHostCallbackScheduled = false;
}
}

当所有的异步任务都执行完毕,isHostCallbackScheduled = false

异步的贯通

和同步不同,异步本身更加复杂。前面讨论了很多很多东西,但是都没有把整个逻辑贯通成环,这样也就无法在宏观上理解这个环节。

所以这一小节的目标是:基于之前的诠释,贯通这个流程。

但是这里必须知道,脱离实际使用去讲原理是不可能的事情,我们说同步可以默认看之前都知道,但是异步这块不行,所以有后面的案例和分析。

异步案例&分析

注意,这里我在使用用例上根据v16.9做了部分更新。但是实际分析暂时还是使用的16.8.6的源码。

基础使用

首先是外部容器的处理

1
2
3
4
5
6
7
8
9
// v16.8是这种写法 但是注意 16.9有更新
ReactDOM.render((
<React.unstable_ConcurrentMode>
<App />
</React.unstable_ConcurrentMode>
), document.getElementById("root"))
// v16.9的写法 这里废弃了unstable_ConcurrentMode
const root = ReactDOM.unstable_createRoot(container);
root.render(<App />, container);

这里是加了一个React.unstable_ConcurrentMode容器。

关于v16.8这个容器,根据Fiber链表结构,它是第二个FiberNode。它是走的createFiberFromMode函数创建的。不过我看了一下v16.9的逻辑,它这里直接将第一个和第二个节点直接融合为一个了。操作办法就是将FiberRoot的mode设为 ConcurrentRoot。

其次是api调用。

初步的优化,是使用unstable_next将用户交互的优先级保障起来,将后续更新优先级降低,相当于强制提升了用户交互的优先级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { unstable_next } from "scheduler";

function SearchBox(props) {
const [inputValue, setInputValue] = React.useState();

function handleChange(event) {
const value = event.target.value;

setInputValue(value);
unstable_next(function() {
props.onChange(value);
sendAnalyticsNotification(value);
});
}

return <input type="text" value={inputValue} onChange={handleChange} />;
}

这里看看这个函数是怎样做的。

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
function unstable_next(eventHandler) {
let priorityLevel;
switch (currentPriorityLevel) {
case ImmediatePriority:
case UserBlockingPriority:
case NormalPriority:
// Shift down to normal priority
priorityLevel = NormalPriority;
break;
default:
// Anything lower than normal priority should remain at the current level.
priorityLevel = currentPriorityLevel;
break;
}

var previousPriorityLevel = currentPriorityLevel;
var previousEventStartTime = currentEventStartTime;
currentPriorityLevel = priorityLevel;
currentEventStartTime = getCurrentTime();

try {
return eventHandler();
} finally {
currentPriorityLevel = previousPriorityLevel;
currentEventStartTime = previousEventStartTime;

// Before exiting, flush all the immediate work that was scheduled.
flushImmediateWork();
}
}

优先级的处理

关于currentPriorityLevel。我们彻底追踪一下调用栈。这个调用起于dispatchInteractiveEvent函数。

1
2
3
4
5
6
dispatchInteractiveEvent
->dispatchEvent
-->interactiveUpdates
--->dispatchEvent
...
----->unstabel_next

这里初始的定义是在interactiveUpdates函数里面(packages/react-reconciler/src/ReactFiberScheduler.js:)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function interactiveUpdates<A, B, R>(fn: (A, B) => R, a: A, b: B): R {
if (
!isBatchingUpdates &&
!isRendering &&
lowestPriorityPendingInteractiveExpirationTime !== NoWork
) {
performWork(lowestPriorityPendingInteractiveExpirationTime, false);
lowestPriorityPendingInteractiveExpirationTime = NoWork;
}
const previousIsBatchingUpdates = isBatchingUpdates;
isBatchingUpdates = true;
try {
return runWithPriority(UserBlockingPriority, () => {
return fn(a, b);
});
} finally {
isBatchingUpdates = previousIsBatchingUpdates;
if (!isBatchingUpdates && !isRendering) {
performSyncWork();
}
}
}

简而言之,有runWithPriority这个调用在,这里事件相关的回调优先级都是UserBlockingPriority。

所以当我们运行到unstable_next,这里priorityLevel会被强制调整为UserBlockingPriority。这和《Scheduling in React》里面提到的一样。

执行

当我们标记完成了优先级,就要开始干活了,这里运行的是flushImmediateWork()。这是从unstable_next函数中try…finally的finally代码块里面来的。

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
function flushImmediateWork() {
if (
// Confirm we've exited the outer most event handler
currentEventStartTime === -1 &&
firstCallbackNode !== null &&
firstCallbackNode.priorityLevel === ImmediatePriority
) {
isExecutingCallback = true;
try {
do {
flushFirstCallback(); // 执行第一个回调
} while (
// 如果回调链表第一个不是null 且优先级为ImmediatePriority
firstCallbackNode !== null &&
firstCallbackNode.priorityLevel === ImmediatePriority
);
} finally {
isExecutingCallback = false;
if (firstCallbackNode !== null) {
// There's still work remaining. Request another callback.
ensureHostCallbackIsScheduled();
} else {
isHostCallbackScheduled = false;
}
}
}
}
// 执行第一个回调
function flushFirstCallback() {
var flushedNode = firstCallbackNode;
// 将回调节点从链表中拿出来。这样即时出错了也能保持一致。否则结束再移除会导致不一致问题
var next = firstCallbackNode.next;
if (firstCallbackNode === next) {
firstCallbackNode = null;
next = null;
} else {
var lastCallbackNode = firstCallbackNode.previous;
firstCallbackNode = lastCallbackNode.next = next;
next.previous = lastCallbackNode;
}

flushedNode.next = flushedNode.previous = null;

// 可以安全执行回调了
var callback = flushedNode.callback;
var expirationTime = flushedNode.expirationTime;
var priorityLevel = flushedNode.priorityLevel;
var previousPriorityLevel = currentPriorityLevel;
var previousExpirationTime = currentExpirationTime;
currentPriorityLevel = priorityLevel;
currentExpirationTime = expirationTime;
var continuationCallback;
try {
continuationCallback = callback();
} finally {
currentPriorityLevel = previousPriorityLevel;
currentExpirationTime = previousExpirationTime;
}

// 回调执行后可能返回的还是一个函数,此时继续以相同优先级调用它
// 代码略
}

这里我们必须注意,优先调用并不会引起最终结果的变化。

倘若我们正常的更新顺序是A->B->C->D。优化之后,我们的B变成优先级最高了之后,B会被最先执行一次。到了后面再执行时候,我们的firstUpdate会指向A,A的next指向B,所以会重复执行ABCD。

这也是为什么componentWillMount现在会被调用多次。

例子: 正常流程A->B->C->D。优化后更高优先级A&C。当优先执行完毕再次开始新的流程时候,firstUpdate指向B, 会执行BCD。

当我们将最高优先级ImmediatePriority弄完之后,我们可以开始执行低它一级的优先级任务(UserBlockingPriority)了。这个任务流的启动,由ImmediatePriority来启动。这源于之前我们已经提到的一段结论。这里直接引用一下,如果忘了可以回头看看唤起回忆。

flushImmediateWork函数则是直接把剩余的最高优先级任务一口气执行完毕。但是这里要注意到,它执行完毕之后,又开始执行ensureHostCallbackIsScheduled了。

结合之前提到的,我们可以明白这里就开始进行rAF->requestIdleCallback->rAF->requestIdleCallback流程了,直到callback链表为空。

最高优先级之后

这里接着上面说最高优先级之后发生的事情。

当我们走出了这个rAF->requestIdleCallback->rAF->requestIdleCallback互相调用直至回调列表为空的流程。此时核心就是requestIdleCallback函数的执行,rAF相当于一个足够敏感的定时器。

这个requestIdleCallback函数主要是对剩余时间的利用(这里不明白可以继续回头看之前的小结)。

  1. 如果当前帧还有时间空余->当前任务已经过期->didTimeout = true立刻执行任务
  2. 如果当前帧还有时间空余->当前任务没过期->执行flushWork && 递归调用rAF

所以这里核心还是在flushWork函数上。

这个函数处理三种情况下的逻辑:

  1. 任务已经超时 此时走同步逻辑,遍历执行所有已经过期任务
  2. 任务没过期,当前帧有时间富余 那么从队列首部以类似数组pop方法的形式挨个执行未过期的任务。
  3. 异步任务经过上面逻辑还有剩余,那么新开新一轮调度 && 立即执行最高优先级任务

这里我们例子中的代码是

1
2
3
4
setInputValue(value);
unstable_next(function() {
props.onChange(value);
});

意思是对更新做了切割,先将Input里面的东西渲染好。然后开始更新ListItem高亮情况,这里的更新就是异步的更新了。当任务进入回调队列(由props.onChange(value)引起),整个长耗时的渲染会被React分帧走renderPhase & commitPhase,整个页面可以保持流畅帧率而不卡顿。

更低的优先级

我们上面分析时候已经有了提及。

1
2
3
runWithPriority(UserBlockingPriority, () => {
return fn(a, b);
})

如果想要更低的优先级,则可以参考这个写法。再之前提到的文章中,它是这样操作的:

1
2
3
4
5
6
7
function sendDeferredAnalyticsPing(value) {
unstable_runWithPriority(unstable_LowPriority, function() {
unstable_scheduleCallback(function() {
sendAnalyticsPing(value);
});
});
}

和React内部调用比较一致。不过个人感觉这个API等正式发布,可能后面会单独有个封装的API,不然这样使用相对麻烦,而且暴露内部API可能不像React的风格。

结合我们之前的代码(完整例子参见scheduletron3000)。这里输入关键词之后,随后要将很多个ListItem符合关键词的全部高亮。当我们输入一个关键词,首先要将输入到关键词显示到Input中,这是一个unstable_LowPriority执行优先级任务,会被优先执行。

参考文章:

这一篇从开头到结尾实在挺不容易,参考了众多的文章去弥补自己未知的地方。

这里至以诚挚谢意。

Scheduling in React

深入剖析 React Concurrent

使用 requestAnimationFrame 实现性能优化与懒执行

你应该知道的requestIdleCallback

React Scheduler 源码详解(1)

React Scheduler 源码详解(2)