跳到主要内容

原理篇-更新流程:进入调度任务

小贴士:有一些同学反馈调和和调度的章节听着有点懵,不能很好的串联起来,那么笔者打算写本章节作为前两个章节的补充和完善。

一 前言

之前的调度调和章节分别讲解了调度的本质(时间分片,请求帧)和调和的流程(两大阶段 render 和 commit )。本章节将继续围绕着核心的两部分展开。

那么首先来回顾一下两者的概念:

  • 调度

用一段简单的例子描述调度到底做了什么事?假设每一个更新,可以看作一个人拿着材料去办事处办理业务。那么办事处处理每一个人的业务需要时间,并且工作人员,需要维护办事处的正常运转,不能全身心投入给顾客办理业务,那么办事处应该如何处理呢?

1 首先需要所有来访的顾客排成一队。然后工作人员开始逐一受理业务,不能让工作人员一直办理业务,如果一直办理,假设任务过多的情况,那么会一直占用工作人员时间,前面说到办事处需要正常运转,如果这样就无法正常运转了。

2 那么工作人员每次办理一个任务后,就先维持办事处的正常运转,等到工作人员有闲暇的时间,再来办理下一个业务。

那么调度的作用就显而易见了,首先调度一定是在多个任务情况下,单个更新任务就没调度可言了;多个任务情况下,如果一口气执行完所有更新任务,那么就会阻塞浏览器的正常渲染,给用户体验上就是卡住了。那么调度任务就是每一次执行一个任务,然后先让浏览器完成后续的渲染操作,然后在空暇时间,再执行下一个任务。

在 v18 调度任务还有一些调整。还是拿办理业务这个例子。

Legacy 模式下:在 v17 及其以下版本,所有的任务都是紧急任务,那么所有来办理的人员都是平等的,所以工作人员只需要按序办理业务就可以了。

v18 Concurrent 模式下:在 v18 模式下,正常紧急的任务都可以看作是会员,一些优先级低的任务比如 transtion 过渡任务,可以看作非会员。如果会员和非会员排列到一起,那么优先会办理会员的业务(正常的紧急优先任务),正常情况下,会办理完所有的会员业务,才开始办理非会员任务;但是在一些极端的情况下,怕会员一直办理,非会员无法办理(被饿死的情况),所以设置一个超时时间,达到超时时间,会破格执行一个非会员任务。

  • 调和

上面介绍了调度的本质,再来举一个例子描述一个调和流程。 假设我们的应用看作一台设备,那么每一次更新,看作一次检修维护更新,那么维修师傅应该如何检修呢? 维修师傅会用一个机器 (workLoop可以看作这个机器) ,依次检查每一个需要维护更新的零件(fiber可以看作零件),每一个需要检修的零件都会进入检查流程,如果需要更新,那么会更新,如果有子零件更新(子代 fiber),那么父代本身也会进入到机器运转( workloop )流程中。

Legacy 模式下:在这个模式下,所有的零件维修,没有优先级的区分,所有的更新工作都被维修师傅依次检查执行。

Concurrent 模式下:我们都清楚,对于设备的维修,实际有很多种类,比如影响设备运转的,那么这种维修任务迫在眉睫,还有一种就是相比不是那么重要的,比如机器打蜡,清理等,那么在 Concurrent 下的 workloop,就像师傅在用机器检修零件,但是遇到更高优先处理的任务,就会暂定当前零件的检修,而去检修更重要的任务一样。

上面用两个例子描述了调度和调和的流程,那么两者之间的关系是什么呢?

  • 调度:首先调度目的针对多个更新任务的情况,调度让多个任务井然有序的执行,执行任务的同时,也不要影响浏览器的绘制。调度决定着更新任务的执行时期。
  • 调和:一旦更新任务执行,那么就会进入调和流程,说白了就是根据 state 的改变,去切实地更新视图。

接下来将重点介绍一下 Legacy 模式下调度任务是如何衔接的。

二 更新之溯源

在 Legacy 下的 React 应用中,更新本质上有两种:

  • 第一种就是初始化的时候第一次页面的呈现。
  • 第二种就是初始化完毕,state 的更新,比如点击按钮,触发 setState 或者 useState

接下来一一分析上面两个流程。

1 从 ReactDOM.render 看初始化流程

假设现在开始初始化我们的应用,那么 Legacy 模式下是从 ReactDOM.render 开始的,一个传统的应用的开始应该是这个样子。

import ReactDOM from 'react-dom'
/* 通过 ReactDOM.render */
ReactDOM.render(
<App />,
document.getElementById('app')
)

那么 ReactDOM.render 到底做了什么呢? 在 ReactDOM.render 做的事情是形成一个 Fiber Tree 挂载到 app 上。来看一下主要流程。

react-dom/src/client/ReactDOMLegacy.js -> legacyRenderSubtreeIntoContainer

function legacyRenderSubtreeIntoContainer(
parentComponent, // null
children, // <App/> 跟部组件
container, // app dom 元素
forceHydrate,
callback // ReactDOM.render 第三个参数回调函数。
){
let root = container._reactRootContainer
let fiberRoot
if(!root){
/* 创建 fiber Root */
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container,forceHydrate);
fiberRoot = root._internalRoot;
/* 处理 callback 逻辑,这里可以省略 */
/* 注意初始化这里用的是 unbatch */
unbatchedUpdates(() => {
/* 开始更新 */
updateContainer(children, fiberRoot, parentComponent, callback);
});
}
}

调用 ReactDOM.render 本质上就是 legacyRenderSubtreeIntoContainer 方法。这个方法的主要做的事情是:

  • 创建整个应用的 FiberRoot
  • 然后调用 updateContainer 开始初始化更新。
  • 这里注意⚠️的是,用的是 unbatch (非批量的情况),并不是批量更新的 batchUpdate

那么所有更新流程矛头都指向了 updateContainer ,那么接下来看一下 updateContainer 主要做了哪些事。

react-reonciler/src/ReactFiberReconciler.js -> updateContainer

export function updateContainer(element,container,parentComponent,callback){
/* 计算优先级,在v16及以下版本用的是 expirationTime ,在 v17 ,v18 版本,用的是 lane。 */
const lane = requestUpdateLane(current);
/* 创建一个 update */
const update = createUpdate(eventTime, lane);
enqueueUpdate(current, update, lane);
/* 开始调度更新 */
const root = scheduleUpdateOnFiber(current, lane, eventTime);
}

通过上面代码的简化,可以清晰的看出来 updateContainer 做了哪些事。

  • 首先计算更新优先级 lane ,老版本用的是 expirationTime
  • 然后创建一个 update ,通过 enqueueUpdate 把当前的 update 放入到待更新队列 updateQueue 中。
  • 接下来开始调用 scheduleUpdateOnFiber ,开始进入调度更新流程中。

到此为止,可以总结出,初始化更新的时候,最后调用的是 scheduleUpdateOnFiber,开始进入更新流程。具体逻辑一会会讲到。

2 从 useState | setState 看更新流程

上面说到了初始化流程,接下来如果发生一次更新,比如一次点击事件带来的 state 的更新。我们这里分类组件函数组件分别看一下:

类组件之 setState

在 state 章节讲到过,当触发 setState 本质上是调用 enqueueSetState

react-reconciler/src/ReactFiberClassComponent.js -> enqueueSetState

enqueueSetState(inst,payload,callback){
const update = createUpdate(eventTime, lane);
enqueueUpdate(fiber, update, lane);
const root = scheduleUpdateOnFiber(fiber, lane, eventTime);
}

可以看到 setState 流程和初始化的流程一样。那么再看一下 hooks 的 useState

函数组件之 useState

react-reconciler/src/ReactFiberHooks.js -> dispatchAction

function dispatchAction(fiber, queue, action) {
var lane = requestUpdateLane(fiber);
scheduleUpdateOnFiber(fiber, lane, eventTime);
}

上面只保留了 dispatchAction 的核心逻辑,可以清楚的发现,无论是初始化,useState,setState 最后都是调用 scheduleUpdateOnFiber 方法。那么这个就是整个更新的入口。那么这个方法做了些什么事情呢?

3 更新入口 scheduleUpdateOnFiber

react-reconciler/src/ReactFiberWorkLoop.js -> scheduleUpdateOnFiber

export function scheduleUpdateOnFiber(fiber,lane,eventTime){
if (lane === SyncLane) {
if (
(executionContext & LegacyUnbatchedContext) !== NoContext && // unbatch 情况,比如初始化
(executionContext & (RenderContext | CommitContext)) === NoContext) {
/* 开始同步更新,进入到 workloop 流程 */
performSyncWorkOnRoot(root);
}else{
/* 进入调度,把任务放入调度中 */
ensureRootIsScheduled(root, eventTime);
if (executionContext === NoContext) {
/* 当前的执行任务类型为 NoContext ,说明当前任务是非可控的,那么会调用 flushSyncCallbackQueue 方法。 */
flushSyncCallbackQueue();
}
}
}
}

scheduleUpdateOnFiber 的核心逻辑如上,正常情况下,大多数任务都是 SyncLane。即便在异步任务里面触发的更新,比如在 Promise 或者是 setTimeout 里面的更新,也是 SyncLane,两者之间没有太大的联系。所以上述核心代码中,只保留了 SyncLane 的逻辑。

那么在 scheduleUpdateOnFiber 内部主要做的事情是:

  • unbatch 情况下,会直接进入到 performSyncWorkOnRoot ,接下来会进入到 调和流程,比如 rendercommit
  • 那么任务是 useStatesetState,那么会进入到 else 流程,那么会进入到 ensureRootIsScheduled 调度流程。
  • 当前的执行任务类型为 NoContext ,说明当前任务是非可控的,那么会调用 flushSyncCallbackQueue 方法。

通过上面知道了,performSyncWorkOnRoot : 这个方法会直接进入到调和阶段,会从 rootFiber 开始向下遍历。 ensureRootIsScheduled :会进入到调度流程。 flushSyncCallbackQueue :用于立即执行更新队列里面的任务。至于为什么,接下来会讲到,请细心阅读。

在介绍 ReactDOM.render 的时候,初始化的更新会通过 unbatchedUpdates 包裹,那么初始化的更新会直接进入调和阶段同步更新,而不会放入到调度任务中

legacy 模式下的可控任务和非可控任务。

  • 可控任务:在事件系统章节和 state 章节讲到过,对于 React 事件系统中发生的任务,会被标记 EventContext,在 batchUpdate api 里面的更新任务,会被标记成 BatchedContext,那么这些任务是 React 可以检测到的,所以 executionContext !== NoContext,那么不会执行 flushSyncCallbackQueue
  • 非可控任务:如果在延时器(timer)队列或者是微任务队列(microtask),那么这种更新任务,React 是无法控制执行时机的,所以说这种任务就是非可控的任务。比如 setTimeoutpromise 里面的更新任务,那么 executionContext === NoContext ,接下来会执行一次 flushSyncCallbackQueue

那么用流程图描述一下过程:

1.jpg

三 进入调度更新

1 控制进入调度

上面非初始化类型的更新任务,那么最终会走到 ensureRootIsScheduled 流程中,所以来分析一下这个方法。

react-reconciler/src/ReactFiberWorkLoop.js -> ensureRootIsScheduled

function ensureRootIsScheduled(root,currentTime){
/* 计算一下执行更新的优先级 */
var newCallbackPriority = returnNextLanesPriority();
/* 当前 root 上存在的更新优先级 */
const existingCallbackPriority = root.callbackPriority;
/* 如果两者相等,那么说明是在一次更新中,那么将退出 */
if(existingCallbackPriority === newCallbackPriority){
return
}
if (newCallbackPriority === SyncLanePriority) {
/* 在正常情况下,会直接进入到调度任务中。 */
newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
}else{
/* 这里先忽略 */
}
/* 给当前 root 的更新优先级,绑定到最新的优先级 */
root.callbackPriority = newCallbackPriority;
}

ensureRootIsScheduled 主要做的事情有:

  • 首先会计算最新的调度更新优先级 newCallbackPriority,接下来获取当前 root 上的 callbackPriority 判断两者是否相等。如果两者相等,那么将直接退出不会进入到调度中。
  • 如果不想等那么会真正的进入调度任务 scheduleSyncCallback 中。注意的是放入调度中的函数就是调和流程的入口函数 performSyncWorkOnRoot
  • 函数最后会将 newCallbackPriority 赋值给 callbackPriority。

什么情况下会存在 existingCallbackPriority === newCallbackPriority,退出调度的情况?

我们注意到在一次更新中最后 callbackPriority 会被赋值成 newCallbackPriority 。那么如果在正常模式下(非异步)一次更新中触发了多次 setState 或者 useState ,那么第一个 setState 进入到 ensureRootIsScheduled 就会有 root.callbackPriority = newCallbackPriority,那么接下来如果还有 setState | useState,那么就会退出,将不进入调度任务中,原来这才是批量更新的原理,多次触发更新只有第一次会进入到调度中。

对于整个批量更新和批量更新打破原理,在第四部分会讲到。

2 进入调度任务

那么当进入到 scheduleSyncCallback 中会发生什么呢?顺着线索往下看:

react-reconciler/src/ReactFiberSyncTaskQueue.js -> scheduleSyncCallback

function scheduleSyncCallback(callback) {
if (syncQueue === null) {
/* 如果队列为空 */
syncQueue = [callback];
/* 放入调度任务 */
immediateQueueCallbackNode = Scheduler_scheduleCallback(Scheduler_ImmediatePriority, flushSyncCallbackQueueImpl);
}else{
/* 如果任务队列不为空,那么将任务放入队列中。 */
syncQueue.push(callback);
}
}

flushSyncCallbackQueueImpl 会真正的执行 callback ,本质上就是调和函数 performSyncWorkOnRoot

Scheduler_scheduleCallback 就是在调度章节讲的调度的执行方法,本质上就是通过 MessageChannel 向浏览器请求下一空闲帧,在空闲帧中执行更新任务。

scheduleSyncCallback 做的事情如下:

  • 如果执行队列为空,那么把当前任务放入队列中。然后执行调度任务。
  • 如果队列不为空,此时已经在调度中,那么不需要执行调度任务,只需要把当前更新放入队列中就可以,调度中心会一个个按照顺序执行更新任务。

到现在,已经知道了调和更新任务如何进入调度的。也知道了在初始化和改变 state 带来的更新原理。

接下来有一个问题就是,比如在浏览器空闲状态下发生一次 state 更新,那么最后一定会进入调度,等到下一次空闲帧执行吗?

答案是否定的,如果这样,那么就是一种性能的浪费,因为正常情况下,发生更新希望的是在一次事件循环中执行完更新到视图渲染,如果在下一次事件循环中执行,那么更新肯定会延时。但是 React 是如何处理这个情况的呢?

3 空闲期的同步任务

在没有更新任务空闲期的条件下,为了让更新变成同步的,也就是本次更新不在调度中执行,那么 React 对于更新,会用 flushSyncCallbackQueue 立即执行更新队列,发起更新任务,目的就是让任务不延时到下一帧。但是此时调度会正常执行,不过调度中的任务已经被清空,

那么有的同学可以会产生疑问,既然不让任务进入调度,而选择同步执行任务,那么调度意义是什么呢?

调度的目的是处理存在多个更新任务的情况,比如发生了短时间内的连续的点击事件,每次点击事件都会更新 state ,那么对于这种更新并发的情况,第一个任务以同步任务执行,那么接下来的任务将放入调度,等到调度完成后,在下一空闲帧时候执行。

可控更新任务

那么知道了,发生一次同步任务之后,React 会让调度执行,但是会立即执行同步任务。原理就是通过 flushSyncCallbackQueue 方法。对于可控的更新任务,比如事件系统里的同步的 setState 或者 useState,再比如 batchUpdate,如果此时处理空闲状态,在内部都会触发一个 flushSyncCallbackQueue来立即更新。我们看一下:

事件系统中的

react-reconciler/src/ReactFiberWorkLoop.js -> batchedEventUpdates

function batchedEventUpdates(fn, a){
/* 批量更新流程,没有更新状态下,那么直接执行任务 */
var prevExecutionContext = executionContext;
executionContext |= EventContext;
try {
return fn(a) /* 执行事件本身,React 事件在这里执行,useState 和 setState 也会在这里执行 */
} finally {
/* 重置状态 */
executionContext = prevExecutionContext;
if (executionContext === NoContext) {
/* 批量更新流程,没有更新状态下,那么直接执行任务 */
flushSyncCallbackQueue();
}
}
}

ReactDOM暴露的api batchedUpdates

react-reconciler/src/ReactFiberWorkLoop.js -> batchedUpdates

function batchedUpdates(fn, a) {
/* 和上述流程一样 */
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
}

如上可以看到,如果浏览器没有调度更新任务,那么如果发生一次可控更新任务,最后会默认执行一次 flushSyncCallbackQueue 来让任务同步执行。

非可控更新任务

如果是非可控的更新任务,比如在 setTimeout 或者 Promise 里面的更新,那么在 scheduleUpdateOnFiber 中已经讲过。

if (executionContext === NoContext) {
/* 执行 flushSyncCallbackQueue ,立即执行更新 */
flushSyncCallbackQueue();
}

综上这也就说明了,为什么在异步内部的 setState | useState 会打破批量更新的原则,本质上是因为,执行一次 setState | useState 就会触发一次 flushSyncCallbackQueue 立即触发更新,所以就会进入到调和阶段,去真正的更新 fiber 树。

四 同步异步模式下的更新流程实践

为了强化本章节的学习,接下来我们来一起研究一下 legacy 模式下的更新流程。

初始化情况

首先看一下初始化流程。

2.jpg

  • ReactDOM.render -> unbatchContext 开关打开 -> updateContainer。
  • updateContainer:scheduleUpdateOnFiber -> performSyncWorkOnRoot -> renderRoot -> commitRoot -> 浏览器绘制。
  • unbatchContext 开关关闭。

同步情况

接下来一起看一下在同步(可控任务)和异步(非可控任务)下更新流程。

首先看一下同步的情况

function Test(){
const [ number , setNumber ] = React.useState(0)
const handleClick = ()=>{ /* 同步条件下 */
setNumber(1)
setNumber(2)
}
return <div>
{number}
<button onClick={handleClick} >点击</button>
</div>
}

如上当点击按钮的时候,会触发两次 setNumber ,那么这两次 setNumber 都做了些什么呢?

两次更新流程图如下:

3.jpg

整个流程过程:

  • 事件上下文:开启事件开关 -> 进入第一次 setNumber
  • 第一次 setNumber 上下文scheduleUpdateOnFiber -> ensureRootIsScheduled -> scheduleSyncCallback (放入回调函数 performSyncWorkOnRoot )。
  • 第二次 setNumber 上下文scheduleUpdateOnFiber -> ensureRootIsScheduled -> 退出。
  • 事件上下文:关闭事件开关 -> flushSyncCallbackQueue
  • flushSyncCallbackQueue -> 执行回调函数 performSyncWorkOnRoot -> 进入调和阶段 -> renderRoot -> commitRoot -> 浏览器绘制。

异步情况

const handleClick = ()=>{
setTimeout(() => { /* 异步条件下 */
setNumber(1)
setNumber(2)
},0)
}

两次更新流程图如下:

4.jpg

整个流程过程:

  • 事件上下文:开启事件开关 -> 关闭事件开关 -> flushSyncCallbackQueue (此时更新队列为空)。
  • setTimeout上下文:执行第一次 setNumber
  • 第一次 setNumber 上下文scheduleUpdateOnFiber -> ensureRootIsScheduled -> scheduleSyncCallback (放入回调函数 performSyncWorkOnRoot ) -> flushSyncCallbackQueue -> 执行回调函数 performSyncWorkOnRoot -> 进入调和阶段 -> renderRoot -> commitRoot
  • 回到 setTimeout 上下文:执行第二次 setNumber
  • 第二次 setNumber 上下文scheduleUpdateOnFiber -> ensureRootIsScheduled -> scheduleSyncCallback (放入回调函数 performSyncWorkOnRoot ) -> flushSyncCallbackQueue -> 执行回调函数 performSyncWorkOnRoot -> 进入调和阶段 -> renderRoot -> commitRoot
  • js执行完毕,浏览器绘制。

所以这种情况下 render 了两遍。到此为止 legacy 模式下更新流程真相大白。

五 总结

通过本章节的学习,收获的知识点如下:

  • 初始化和 state 改变的更新流程。
  • 可控任务和非可控任务的更新原理。
  • 如何进入调度任务。
  • 强化 state | 调度 | 调和 章节的学习。