完全搞懂React源码调度原理(Concurrent模式)

自上一篇写关于diff的文章到如今已通过了二十天多,利用业余时间和10天婚假的闲暇,终于搞懂了React源码中的调度原理。当费劲一番周折终于调试到将更新与调度任务链接在一块儿的核心逻辑那一刻,忧愁的嘴角终于露出欣慰的微笑。html

最先以前,React尚未用fiber重写,那个时候对React调度模块就有好奇。而如今的调度模块对于以前没研究过它的我来讲更是带有一层神秘的色彩,色彩中朦胧浮现出两个字:“困难”。react

截至目前react的Concurrent(同时)调度模式依然处在实验阶段(期待中),还未正式发布,但官网已有相关简单介绍的文档,相信不久以后就会发布(参考hooks)。git

在研究的时候也查阅了网上的相关资料,但可参考的很少。缘由一个是调度模块源码变更较大,以前的一些文章和如今的源码实现对不上(不过不少文章对时间切片和优先级安排的概念讲解很到位),另外一个是如今可参考的列出调度流程相应源码的文章几乎没有。github

因此本文主要是经过本身对源码的阅读,推理和验证,加上大量时间做为催化剂,将React源码中的调度原理展示给各位读者。web

React使用当前最新版本:16.13.1算法

今年会写一个“搞懂React源码系列”,把React最核心的内容用最易懂的方式讲清楚。2020年搞懂React源码系列:浏览器

  • React Diff原理
  • (当前)React 调度原理
  • 搭建阅读React源码环境-支持React全部版本断点调试细分文件
  • React Hooks原理

欢迎Star和订阅个人博客微信

同步调度模式

React目前只有一种调度模式:同步模式。只有等Concurrent调度模式正式发布,才能使用第两种模式。多线程

没有案例的讲解是没有灵魂的。咱们先来看一个此处和后续讲优先级都将用到的案例:app

假设有一个按钮和有8000个包含一样数字的文本标签,点击按钮后数字会加2。(使用8000个文本标签是为了加长react单次更新任务的计算时间,以便直观观察react如何执行多任务)

咱们用类组件实现案例。

渲染内容:

<div>
  <button ref={this.buttonRef} onClick={this.handleButtonClick}>增长2</button>
  <div>
    {Array.from(new Array(8000)).map( (v,index) =>
      <span key={index}>{this.state.count}</span>
    )}
  </div>
</div>

添加按钮点击事件:

handleButtonClick = () => {
  this.setState( prevState => ({ count: prevState.count + 2 }) )
}

并在componentDidMount中添加以下代码:

const button = this.buttonRef.current
setTimeout( () => this.setState( { count: 1 } ), 500 )
setTimeout( () => button.click(), 500 )

ReactDOM初始化组件:

ReactDOM.render(<SyncSchedulingExample />, document.getElementById("container"));

添加2个setTimeout是为了展现同步模式的精髓: 500毫秒后有两个异步的setState的任务,因为react要计算和渲染8000个文本标签,那么任何一个任务光计算的时间都要几百毫秒,那么react会如何处理这两个任务?

运行案例后,查看Chrome性能分析图:

从结果可知,尽管两个任务理应“同时”运行,但react会先把第一个任务执行完后再执行第二个任务,这就是react同步模式:

多个任务时,react都会按照任务顺序一个一个执行,它没法保证后面的任务能在本应执行的时间执行。(其实就是JS自己特性EventLoop的展示。好比只要一个while循环足够久,理应在某个时刻执行的方法就会被延迟到while循环结束后才运行。)

Concurrent(同时)调度模式

Concurrent调度模式是一种支持同时执行多个更新任务的调度模式。

它的特色是任何一个更新任务均可以被更高优先级中断插队,在高优先级任务执行以后再执行。

很重要的一点,"同时执行多个更新任务"指的是同时将多个更新任务添加到React调度的任务队列中,而后React会一个个执行,而不是相似多线程同时工做那种方式。

如何理解模式名字:Concurrent(同时)?

React官网用了一个很形象的版本管理案例来形容“同时”模式。

当咱们没有版本管理软件的时候,若一我的要修改某个文件,须要通知其余人不要修改这个文件,只有等他修改完以后才能去修改。没法作到多我的同时修改一个文件。

但有了版本管理软件,咱们每一个人均可以拉一个分支,修改同一个文件,而后将本身修改的内容合并到主分支上,作到多人“同时”修改一个文件。

因此,若是React也能作到“同时”执行多个更新任务,作到每个更新任务的执行不会阻塞其余更新任务的加入,岂不是很方便。

这能够看做是“同时”模式名字的由来。

同时调度模式的应用场景

下方为React团队成员Dan在作同时模式分享时用的DEMO。一样的快速输入几个数字,在同步模式和同时模式可发现明显区别。

Dan-Concurrent Mode Demo:

同步模式下,卡顿现象明显,而且会出现UI阻塞状态:Input中的光标再也不闪烁,而是卡住。

同时模式下,只有输入内容较长才会出现稍微的卡顿状况和UI阻塞。性能获得明显改善。

同时模式很好的解决了连续频繁更新状态场景下的卡顿和UI阻塞问题。固然,同时模式下还有其余实用功能,好比Suspense,由于本文主要讲调度原理和源码实现,因此就不展开讲Suspense了。

同步调度模式如何实现

React是如何实现同步调度模式的?这也是本文的核心。接下来将先讲时间切片模式,以及React如何实现时间切片模式,而后再讲调度中的优先级,以及如何实现优先级插队,最后讲调度的核心参数:expirationTime(过时时间)。

时间切片

什么是时间切片

最先是从Lin Clark分享的经典Fiber演讲中了解到的时间切片。时间切片指的是一种将多个粒度小的任务放入一个个时间切片中执行的一种方法。

时间切片的做用

在刚执行完一个时间切片准备执行下一个时间切片前,React可以:

  • 判断是否有用户界面交互事件和其余须要执行的代码,好比点击事件,有的话则执行该事件
  • 判断是否有优先级更高的任务须要执行,若是有,则中断当前任务,执行更高的优先级任务。也就是利用时间前片来实现高优先级任务插队。

即时间切片有两个做用:

  1. 在执行任务过程当中,不阻塞用户与页面交互,当即响应交互事件和须要执行的代码
  2. 实现高优先级插队

React源码如何实现时间切片

1 . 首先在这里引入当前React版本中的一段注释说明:

// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
let yieldInterval = 5;

注释对象是声明yieldInterval变量的表达式,值为5,即5毫秒。其实这就是React目前的单位时间切片长度。

注释中说一个帧中会有多个时间切片(显而易见,一帧~=16.67ms,包含3个时间切片还多),切片时间不会与帧对齐,若是要与帧对齐,则使用requestAnimationFrame

从2019年2月27号开始,React调度模块移除了以前的requestIdleCallback腻子脚本相关代码

因此在一些以前的调度相关文章中,会提到React如何使用requestAnimationFrame实现requestIdleCallback腻子脚本,以及计算帧的边界时间等。由于当时的调度源码的确使用了这些来实现时间切片。不过如今的调度模块代码已精简许多,而且用新的方式实现了时间切片。

2 . 了解时间切片实现方法前需掌握的知识点:

  • Message Channel:浏览器提供的一种数据通讯接口,可用来实现订阅发布。其特色是其两个端口属性支持双向通讯和异步发布事件(port.postMessage(...))。
const channel = new MessageChannel()
const port1 = channel.port1
const port2 = channel.port2

port1.onmessage = e => { console.log( e.data ) }
port2.postMessage('from port2')
console.log( 'after port2 postMessage' )

port2.onmessage = e => { console.log( e.data ) }
port1.postMessage('from port1')
console.log( 'after port1 postMessage' )

// 控制台输出: 
// after port2 postMessage
// after port1 postMessage
// from port2
// from port1
  • Fiber: Fiber是一个的节点对象,React使用链表的形式将全部Fiber节点链接,造成链表树,即虚拟DOM树。

当有更新出现,React会生成一个工做中的Fiber树,并对工做中Fiber树上每个Fiber节点进行计算和diff,完成计算工做(React称之为渲染步骤)以后,再更新DOM(提交步骤)。

3 . 下面让咱们来看React究竟如何实现时间切片。

首先React会默认有许多微小任务,即全部的工做中fiber节点。

在执行调度工做循环和计算工做循环时,执行每个工做中Fiber。可是,有一个条件是每隔5毫秒,会跳出工做循环,运行一次异步的MessageChannelport.postMessage(...)方法,检查是否存在事件响应、更高优先级任务或其余代码须要执行,若是有则执行,若是没有则从新建立工做循环,执行剩下的工做中Fiber。

可是,为何性能图上显示的切片不是精确的5毫秒?

由于一个时间切片中有多个工做中fiber执行,每执行完一个工做中Fiber,都会检查开始计时时间至当前时间的间隔是否已超过或等于5毫秒,若是是则跳出工做循环,但算上检查的最后一个工做中fiber自己执行也有一段时间,因此最终一个时间切片时间必定大于或等于5毫秒。

时间切片和其余模块的实现原理对应源码位于本文倒数第二章节“源码实探”。

将描述和实际源码分开,是为了方便阅读。先用大白话把原理实现流程讲出来,不放难懂的源码,最后再贴出对应源码。

如何调度一个任务

讲完时间切片,就能够了解React如何真正的调度一个任务了。

requestIdleCallback(callback, { timeout: number })是浏览器提供的一种可让回调函数执行在每帧(上图2个vsync之间即为1帧)末尾的空闲阶段的方法,配置timeout后,若多帧持续没有空闲时间,超过timeout时长后,该回调函数将当即被执行。

如今的React调度模块虽没有使用requestIdleCallback,但充分吸取了requestIdleCallback的理念。其unstable_scheduleCallback(priorityLevel, callback, { timeout: number })就是相似的实现,不过是针对不一样优先级封装的一种调度任务的方法。

在讲调度流程前先简单介绍调度中用到的相关参数:

  • 当前Fiber树的root:拥有属性“回调函数”
  • React中的调度模块的任务: 拥有属性 “优先级,回调函数,过时时间”
  • 过时时间标记:源码中expirationTime有两种类型,一种是标记类型:一个极大值,大小与时长成反比,能够用来做优先级标记,值越大,优先级越高,好比:1073741551;另外一种是从网页加载开始计时的具体过时时间:好比8000毫秒)。具体内容详见后面的expirationTime章节
  • DOM调度配置: 由于react同时支持web端dom和移动端native两种,核心算法一致,但有些内容是两端独有的,因此有的模块有专门的DOM配置和Native配置。咱们这里将用到调度模块的DOM配置
  • requestHostCallback:DOM调度配置中使用Message Channel异步执行回调函数的方法

接下来看React如何调度一个任务。

初始化

1 . 当出现新的更新,React会运行一个确保root被安排任务的函数。

2 . 当root的回调函数为空值且新的更新对应的过时时间标记是异步类型,根据当前时间和过时时间标记推断出优先级和计算出timeout,而后根据优先级、timeout, 结合执行工做的回调函数,新建一个任务(这里就是scheduleCallback),将该任务放入任务队列中,调用DOM调度配置文件中的requestHostCallback,回调函数为调度中心的清空任务方法。

运行任务

1 . requestHostCallback调用MessageChannel中的异步函数:port.postMessage(...),从而异步执行以前另外一个端口port1订阅的方法,在该方法中,执行requestHostCallback的回调函数,即调度中心的清空任务方法。

2 . 清空任务方法中,会执行调度中心的工做循环,循环执行任务队列中的任务。

有趣的是,工做循环并非执行完一次任务中的回调函数就继续执行下一个任务的回调函数,而是执行完一个任务中的回调函数后,检测其是否返回函数。若返回,则将其做为任务新的回调函数,继续进行工做循环;若未返回,则执行下一个任务的回调函数。

而且工做循环中也在检查5毫秒时间切片是否到期,到期则从新调port.postMessage(...)

3 . 任务的回调函数是一个执行同时模式下root工做的方法。执行该方法时将循环执行工做中fiber,一样使用5毫秒左右的时间切片进行计算和diff,5毫秒时间切片过时后就会返回其自身。

完成任务

1 . 在执行完全部工做中fiber后,React进入提交步骤,更新DOM。

2 . 任务的回调函数返回空值,调度工做循环所以(运行任务步骤中第二点:若任务的回调函数执行后返回为空,则执行下一个任务)完成此任务,并将此任务从任务队列中删除。

如何实现优先级

目前有6种优先级(从高到低排序):

优先级类型 使用场景
当即执行ImmediatePriority React内部使用:过时任务当即同步执行;用户自定义使用
用户与页面交互UserBlockingPriority React内部使用:用户交互事件生成此优先级任务;用户自定义使用
普通NormalPriority React内部使用:默认优先级;用户自定义使用
低LowPriority 用户自定义使用
空闲IdlePriority 用户自定义使用
无NoPriority React内部使用:初始化和重置root;用户自定义使用

表格中列出了优先级类型和使用场景。React内部用到了除低优先级和空闲优先级之外的优先级。理论上,用户能够自定义使用全部优先级,使用方法:

React.unstable_scheduleCallback(priorityLevel, callback, { timeout: <number> })

不一样优先级的做用就是让高优先级任务优先于低优先级任务执行,而且因为时间切片的特性(每5毫秒执行一次异步的port.postMessage(...),在执行相应回调函数前会执行检测到的须要执行的代码)高优先级任务的加入能够中断正在运行的低优先级任务,先执行完高优先级任务,再从新执行被中断的低优先级让任务。

高优先级插队也是同时调度模式的核心功能之一。

高优先级插队

接下来,使用相似同步模式代码的插队案例。
渲染内容:

<div>
  <button ref={this.buttonRef} onClick={this.handleButtonClick}>增长2</button>
  <div>
    {Array.from(new Array(8000)).map( (v,index) =>
      <span key={index}>{this.state.count}</span>
    )}
  </div>
</div>

添加按钮点击事件:

handleButtonClick = () => {
  this.setState( prevState => ({ count: prevState.count + 2 }) )
}

并在componentDidMount中添加以下代码(不一样之处,第二次setTimeout的时间由500改成600):

const button = this.buttonRef.current
setTimeout( () => this.setState( { count: 1 } ), 500 )
setTimeout( () => button.click(), 600)

ReactDOM初始化组件(不一样之处,使用React.createRoot开启Concurrent模式):

ReactDOM.createRoot( document.getElementById('container') ).render( <ConcurrentSchedulingExample /> )

为何第二次setTimeout的时间由500改成600?

由于是为了展现高优先级插队。第二次setTimeout使用的用户交互优先级更新,晚100毫秒,可保证第一次setTimeout对应的普通更新正在执行中,尚未完成,这个时候最能体现插队效果。

运行案例后,页面默认显示8000个0,而后0变为2(而不是变为1),再变为3。

经过DOM内容的变化已经能够看出:第二次setTimeout执行的按钮点击事件对应的更新插了第一次setTimeout对应更新的队。

接下来,观察性能图。
总览:

被中断细节:只执行了3个时间切片就被中断:

如何实现高优先级插队

1 . 延用上面的高优先级插队案例,从触发高优先级点击事件(准备插队)开始。

触发点击事件后,React会运行内部的合成事件相关代码,而后执行一个执行优先级的方法,优先级参数为“用户交互UserBlockingPriority”,接着进行setState操做。

setState的关联方法新建一个更新,计算当前的过时时间标记,而后开始安排工做。

2 . 在安排工做方法中,运行确保root被安排任务的方法。由于如今的优先级更高且过时时间标记不一样,调度中心取消对以前低优先级任务的安排,并将以前低优先级任务的回调置空,确保它以后不会被执行(调度中心工做循环根据当前的任务的回调函数是否为空决定是否继续执行该任务)。

而后调度中心根据高优先级更新对应的优先级、过时时间标记、timeout等建立新的任务。

3 . 执行高优先级任务,当执行到开始计算工做中类Fiber(class ConcurrentSchedulingExample),执行更新队列方法时,React将循环遍历工做中类fiber的更新环状链表。

当循环到以前低优先级任务对应更新时,由于低优先级过时时间标记小于当前渲染过时时间标记,故将该低优先级过时时间标记设为工做中类fiber的过时时间标记(其余状况会将工做中类fiber的过时时间标记设为0)。此处是以后恢复低优先级的关键所在。

4 . 在完成优先级任务过程的提交渲染DOM步骤中,渲染DOM后,会将root的callbackNode(其名字容易误导其功能,其实就是调度任务,用callbackTask或许更合适)设为空值。

在接下来执行确保root被安排任务的方法中,由于下一次过时时间标记不为空(根本缘由就是上面第二点提到工做中类fiber的过时时间标记被设置为低优先级过时时间标记)且root的callbackNode为空值,因此建立新的任务,即从新建立一个新的低优先级任务。并将任务放入任务列表中。

5 . 从新执行低优先级任务。此处须要注意是从新执行而不是从以前中断的地方继续执行。毕竟React计算过程当中只有当前fiber树和工做中fiber树,执行高优先级时,工做中fiber树已经被更新,因此恢复低优先级任务必定是从新完整执行一遍。

过时时间ExpirationTime

做为贯穿整个调度流程的参数,过时时间ExpirationTime的重要性不言而喻。

但在调试过程当中,发现expirationTime却不止一种类型。它的值有时是1073741121,有时又是6500,两个值显示对应不一样类型。为何会出现这种状况?

事实上,当前Reac正在重写ExpirationTime的功能,若是后续看到这篇文章发现跟源码差异较大,欢迎阅读我以后写的解读新ExpirationTime功能的文章(立个FLAG先,主要后面expirationTime一块变化应该不小,值得研究)。

ExpirationTime的变化过程

以上方优先级插队为例,观察expirationTime值及其相关值的变化。

  • 更新低优先级
过程 时间相关参数
setState(...) -->
var expirationTime = computeExpirationForFiber(currentTime, ...)
currentTime 1073741 641
expirationTime=computeExpirationForFiber(currentTime, ...) 1073741 121
ensureRootIsScheduled(...) -->
timeout = expirationTimeToMs(expirationTime) - now()
expirationTimeToMs(expirationTime) 7000
now() 1808
timeout = expirationTimeToMs(expirationTime) - now() 5192
startTime 1808
timeout 5192
expirationTime = startTime + timeout 7000

在设置更新时,会根据当前优先级和当前时间标记生成对应过时时间标记。

而此后,在确保和安排任务时,会将过时时间标记转换为实际过时时间。

表格的第二第三过程转了一圈,最后仍是回到第一次计算的过时时间(由于js同步执行少许代码过程当中,performance.now()的变化几乎能够忽略)。

  • 中断低优先级更新,更新高优先级
过程 时间相关参数
setState(...) -->
var expirationTime = computeExpirationForFiber(currentTime, ...)
currentTime 1073741 630
expirationTime=computeExpirationForFiber(currentTime, ...) 1073741 571
ensureRootIsScheduled(...) --> timeout = expirationTimeToMs(expirationTime) - now() expirationTimeToMs(expirationTime) 2500
now() 1916
timeout = expirationTimeToMs(expirationTime) - now() 584
unstable_scheduleCallback() -->
var expirationTime = startTime + timeout
startTime 1916
timeout 584
expirationTime = startTime + timeout 2500
processUpdateQueue(...)-->
if ( updateExpirationTime < renderExpirationTime ){
newExpirationTime = updateExpirationTime
}
updateExpirationTime 1073741 121
renderExpirationTime 1073741 571
newExpirationTime = updateExpirationTime 1073741 121

执行高优先级时,低优先级被中断。而可以让低优先级被恢复的核心逻辑就是最后一个过程(执行更新队列)中对updateExpirationTime(低优先级更新的过时时间标记)和renderExpirationTime(高优先级更新的过时时间标记)的判断。

由于低优先级过时时间标记小于高优先级过时时间标记,即低优先级过时时间大于高优先级过时时间(过时时间标记与过时时间成反比,下面会讲到),代表低优先级更新已经被插队,须要从新执行。因此低优先级更新过时时间标记设为工做中类fiber的过时时间标记。

  • 从新更新低优先级
过程 时间相关参数
ensureRootIsScheduled(...) -->
timeout = expirationTimeToMs(expirationTime) - now()
expirationTimeToMs(expirationTime) 7000
now() 2066
timeout = expirationTimeToMs(expirationTime) - now() 4934
unstable_scheduleCallback(...) -->
var expirationTime = startTime + timeout
startTime 2066
timeout 4934
expirationTime = startTime + timeout 7000

过时时间的两种类型

经过观察expirationTime值的变化过程,可知在设置更新时,计算的expiraionTime为一种标记形式,而到安排任务的时候,任务的expirationTime已变为实际过时时间。

expirationTime的2种类型:

  1. 时间标记:一个极大值,如1073741121
  2. 过时时间:从网页加载开始计时的实际过时时间,单位为毫秒

过时时间标记

React成员Andrew Clark在"Make ExpirationTime an opaque type "中提到了expirationTime做为标记的计算方法和做用:

In the old reconciler, **expiration times are computed by applying an
offset to the current system time . This has the effect of increasing
the priority of updates as time progresses**.

他说ExpirationTime是经过给当前系统时间添加一个偏移量来计算,这样的做用是随着时间运行可以提高更新的优先级。

而源码中,expirationTime的确是根据一个最大整数值偏移量来计算:

MAGIC_NUMBER_OFFSET - ceiling(MAGIC_NUMBER_OFFSET - currentTime + expirationInMs / UNIT_SIZE, bucketSizeMs / UNIT_SIZE)

其中:

  • MAGIC_NUMBER_OFFSET 是一个极大常量: 1073741 821
  • UNIT_SIZE也是常量:10,用来将毫秒值除以10,好比1000毫秒转为1000/10=100,便于展现时间标记
  • ceiling(num, unit)的做用是根据单位长度进行特殊向上取整(对基础值也向上取整,好比1.1特殊向上取整后为2,而1特殊向上取整后也为2, 能够理解为 Math.floor( num + 1 ) )
function ceiling(num, unit) {
  return ((num / unit | 0) + 1) * unit;
}

num | 0的做用相似Math.floor(num), 向下取整,而且加1能够放入括号,因此代码可转换为:

function ceiling(num, unit) {
  return Math.floor( num / unit + 1 ) * unit;
}

好比,若单位unit为10,若数值num为:

    • 10,则返回20
    • 11,也返回20

为何要React要使用特殊向上取整方法?

由于这样能够实现”更新节流“:在单位时间(好比100毫秒)内,保证多个同等优先级更新计算出的expirationTime相同,只执行第一个更新对应的任务(但计算更新时会用到全部更新)。

在确保root被安排好任务的函数中,会判断新的更新expirationTime和正在执行的更新expirationTime是否相同,以及它们的优先级是否相同,若相同,则直接return。从而不会执行第一个更新以后更新对应的任务。

但这并非说以后的更新都不会执行。因为第一个更新对应任务的执行是异步的(post.postMessage),在第一个更新执行更新队列时,其余更新早已被加入更新队列,因此能确保计全部更新参与计算。

  • MAGIC_NUMBER_OFFSET - currentTime的值为performance.now()/10
  • expirationInMs 表示不一样优先级对应的过时时长:
    • 普通/低优先级:5秒
    • 高优先级(用户交互优先级):生产环境下为150毫秒,开发环境下为500毫秒
    • 当即优先级、空闲优先级不经过上面的公式计算,它们的过时时间标记值分别为12,一个表示当即过时,另外一个表示永不过时。
  • bucketSizeMs: 即ceiling(num, unit)中的unit,做为特殊向上取整的单位长度。高优先级为100毫秒,普通/低优先级为250毫秒。

为了便于理解,不考虑更新节流,则:

过时时间标记值 = 极大数值 - ( 当前时间 + 优先级对应过时时长 ) / 10

当前时间 + 优先级对应过时时长就是实际过时时间,因此:

过时时间标记值 = 极大数值 - 过时时间 / 10

过时时间

过时时间就是:

当前时间 + 优先级对应过时时长

过时时间标记转换为过时时间:

function expirationTimeToMs(expirationTime) {
    return (MAGIC_NUMBER_OFFSET - expirationTime) * UNIT_SIZE;
}

源码实探

写到此处,不知不觉已通过了好几天。对于源码展示这一块,也有了不一样的打算。以前计划纯用流程图展示。但由于涉及关键代码量大,流程图不是很适用。因此此次直接用流程叙述+相关源码,直观的实现原理对应源码。

时间切片源码

在执行调度工做循环和计算工做循环时,执行每个工做中Fiber。可是,有一个条件是每隔5毫秒,会跳出工做循环,

function workLoop(...) {
    ...
    while (currentTask !== null && ...) {
        ....
    }
    ...
}
调度工做循环
function workLoopConcurrent() {
    while (workInProgress !== null && !shouldYield()) {
      workInProgress = performUnitOfWork(workInProgress);
    }
  }
计算工做循环中, shouldYield()即为检查5毫秒是否到期的条件
shouldYield(...) --> Scheduler_shouldYield(...) --> unstable_shouldYield(...)
--> shouldYieldToHost(...)
--> getCurrentTime() >= deadline
-->
  var yieldInterval = 5; var deadline = 0;
  var performWorkUntilDeadline = function() {
      ...
      var currentTime = getCurrentTime()
      deadline = currentTime + yieldInterval
      ...
  }
var yieldInterval = 5为每隔5毫秒的体现

运行一次异步的MessageChannelport.postMessage(...)方法,检查是否存在事件响应、更高优先级任务或其余代码须要执行,若是有则执行,若是没有则从新建立工做循环,执行剩下的工做中Fiber。

var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function(callback) {
    ...
    if (...) {
        ...
        port.postMessage(null);
    }
}

在执行调度任务过程当中,会执行requestHostCallback(...) , 从而调用port.postMessage(...)

调度一个任务源码

初始化

1 . 当出现新的更新,React会运行一个确保root被安排任务的函数。

setState(...) --> enqueueSetState(...) 
--> scheduleWork(...) --> ensureRootIsScheduled(...)

2 . 当root的回调函数为空值且新的更新对应的过时时间标记是异步类型,根据当前时间和过时时间标记推断出优先级和计算出timeout,

var currentTime = requestCurrentTimeForUpdate();
var priorityLevel = inferPriorityFromExpirationTime(currentTime, expirationTime);
if (expirationTime === Sync) {
    ...
} else {
    callbackNode = scheduleCallback(priorityLevel, performConcurrentWorkOnRoot.bind(null, root), 
    {
    timeout: expirationTimeToMs(expirationTime) - now()
    });
}

而后根据优先级、timeout, 结合执行工做的回调函数,新建一个任务(这里就是scheduleCallback),

function unstable_scheduleCallback(priorityLevel, callback, options) {
    ...
    var expirationTime = startTime + timeout;
    var newTask = {
      id: taskIdCounter++,
      callback: callback,
      priorityLevel: priorityLevel,
      startTime: startTime,
      expirationTime: expirationTime,
      sortIndex: -1
    };
    ...
}

将该任务放入任务队列中,调用DOM调度配置文件中的requestHostCallback,回调函数为调度中心的清空任务方法。

push(taskQueue, newTask);
...
if (...) {
    ...
    requestHostCallback(flushWork);
 }

flushWork为调度中心的清空任务方法,即将任务队列中的任务执行后而后移除

运行任务

1 . requestHostCallback调用MessageChannel中的异步函数:port.postMessage(...)

var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function (callback) {
  scheduledHostCallback = callback;

  if (...) {
    ...
    port.postMessage(null);
  }
};

从而异步执行以前另外一个端口port1订阅的方法,在该方法中,执行requestHostCallback的回调函数,即调度中心的清空任务方法。

var performWorkUntilDeadline = function () {
    ...
    var hasMoreWork = scheduledHostCallback(...);
}

2 .清空任务方法中,会执行调度中心的工做循环,循环执行任务队列中的任务。

function flushWork(...) {
    ...
    return workLoop(...);
    ...
}

有趣的是,工做循环并非执行完一次任务中的回调函数就继续执行下一个任务的回调函数,而是执行完一个任务中的回调函数后,检测其是否返回函数。若返回,则将其做为任务新的回调函数,继续进行工做循环;若未返回,则执行下一个任务的回调函数。

function workLoop(...) {
    ...
    while (currentTask !== null && ...) {
        var callback = currentTask.callback;
        if (callback !== null) {
            currentTask.callback = null;
            ...
            var continuationCallback = callback(didUserCallbackTimeout)
            if (typeof continuationCallback === 'function') {
                currentTask.callback = continuationCallback;
                ...
            }    
        } else {
            pop(taskQueue)
        }
        currentTask = peek(taskQueue);
    }
    ...
}

而且工做循环中也在检查5毫秒时间切片是否到期,到期则从新调port.postMessage(...)

while(currentTask !== null && ...) {
    ...
    if (... && (... || shouldYieldToHost())) {
        break;
    }
    ...
}
if (currentTask !== null) {
    return true;
}
var hasMoreWork = scheduledHostCallback(...);

if (!hasMoreWork) {
    ...
} else {
  port.postMessage(null);
}

3 . 任务的回调函数是一个执行同时模式下root工做的方法。执行该方法时将循环执行工做中fiber,一样使用5毫秒左右的时间切片进行计算和diff,5毫秒时间切片过时后就会返回其自身。

function performConcurrentWorkOnRoot(...) {
    ...
    do {
    try {
      workLoopConcurrent();
      break;
    } catch (...) {
      ...
    }
    } while (true);
    ...
    return performConcurrentWorkOnRoot.bind(...);
}

完成任务

1 . 在执行完全部工做中fiber后,React进入提交步骤,更新DOM。

finishConcurrentRender(...)-->commitRoot(...)-->commitRootImpl(...)

2 . 任务的回调函数返回空值,调度工做循环所以(运行任务步骤中第二点:若任务的回调函数执行后返回为空,则执行下一个任务)完成此任务,并将此任务从任务队列中删除。

function performConcurrentWorkOnRoot() {
    ...
    if (workInProgress !== null) { ... }
    else {
        ...
        finishConcurrentRender(root, finishedWork, workInProgressRootExitStatus, expirationTime);
    } 
    ...
    return null;
}
function workLoop(...) {
    ...
    while (currentTask !== null && ...) {
        var callback = currentTask.callback;
        if (callback !== null) {
            currentTask.callback = null;
            ...
            var continuationCallback = callback(didUserCallbackTimeout)
            if (typeof continuationCallback === 'function') {
                currentTask.callback = continuationCallback;
                ...
            }    
        } else {
            pop(taskQueue)
        }
        currentTask = peek(taskQueue);
    }
    ...
}

高优先级插队

1 . 延用上面的高优先级插队案例,从触发高优先级点击事件(准备插队)开始。

触发点击事件后,React会运行内部的合成事件相关代码,而后执行一个执行优先级的方法,优先级参数为“用户交互UserBlockingPriority”,接着进行setState操做。

onClick --> discreteUpdates 
--> runWithPriority(UserBlockingPriority, ...)
-->setState

setState的关联方法新建一个更新,计算当前的过时时间标记,而后开始安排工做。

enqueueSetState: function (...) {
    ...
    var expirationTime = computeExpirationForFiber(...);
    var update = createUpdate(...);
    ...
    enqueueUpdate(fiber, update);
    scheduleWork(fiber, expirationTime);
}

2 . 在安排工做方法中,运行确保root被安排任务的方法。由于如今的优先级更高且过时时间标记不一样,调度中心取消对以前低优先级任务的安排,并将以前低优先级任务的回调置空,确保它以后不会被执行(调度中心工做循环根据当前的任务的回调函数是否为空决定是否继续执行该任务)。

function ensureRootIsScheduled(...) {
     if (existingCallbackNode !== null) {
         ...
         cancelCallback(existingCallbackNode);
     }
     ...
}
function unstable_cancelCallback(task) {
    ...
    task.callback = null;
}

而后调度中心根据高优先级更新对应的优先级、过时时间标记、timeout等建立新的任务。

var expirationTime = startTime + timeout;
var newTask = {
  ...
  callback: callback,
  priorityLevel: priorityLevel,
  startTime: startTime,
  expirationTime: expirationTime,
  ...
};

3 . 执行高优先级任务,当执行到开始计算工做中类Fiber(class ConcurrentSchedulingExample),执行更新队列方法时,React将循环遍历工做中类fiber的更新环状链表。当循环到以前低优先级任务对应更新时,由于低优先级过时时间标记小于当前渲染过时时间标记,故将该低优先级过时时间标记设为工做中类fiber的过时时间标记(其余状况会将工做中类fiber的过时时间标记设为0)。此处是以后恢复低优先级的关键所在。

function processUpdateQueue(...) {
    ...
    var newExpirationTime = NoWork;
    ...
    if (updateExpirationTime < renderExpirationTime) {
        if (updateExpirationTime > newExpirationTime) {
            newExpirationTime = updateExpirationTime;
        }
    } else { ... }
    ...
    workInProgress.expirationTime = newExpirationTime
    ...
}
NoWork0

4 . 在完成优先级任务过程的提交渲染DOM步骤中,渲染DOM后,会将root的callbackNode(其名字容易误导其功能,其实就是调度任务,用callbackTask或许更合适)设为空值。

function commitRootImpl(...) {
    ...
    root.callbackNode = null;
    ...
}

在接下来执行确保root被安排任务的方法中,由于下一次过时时间标记不为空(根本缘由就是上面第二点提到工做中类fiber的过时时间标记被设置为低优先级过时时间标记)且root的callbackNode为空值,因此建立新的任务,即从新建立一个新的低优先级任务。并将任务放入任务列表中。

function ensureRootIsScheduled(...) {
    var expirationTime = getNextRootExpirationTimeToWorkOn(...);
    if (expirationTime === NoWork) { ... return }
    if (expirationTime === Sync) { ... }
    else {
        callbackNode = scheduleCallback(priorityLevel, performConcurrentWorkOnRoot.bind(null, root), 
      {
        timeout: expirationTimeToMs(expirationTime) - now()
      });
    }
}
function unstable_scheduleCallback(priorityLevel, callback, options) {
    ...
    var expirationTime = startTime + timeout;
    var newTask = {
      ...
      callback: callback,
      priorityLevel: priorityLevel,
      startTime: startTime,
      expirationTime: expirationTime,
     ...
    };
    ...
    push(taskQueue, newTask);
    ...
}

5 . 从新执行低优先级任务。此处须要注意是从新执行而不是从以前中断的地方继续执行。毕竟React计算过程当中只有当前fiber树和工做中fiber树,执行高优先级时,工做中fiber树已经被更新,因此恢复低优先级任务必定是从新完整执行一遍。

最后写点什么

这次阅读源码的一些心得:

1 . 先自上而下,再自下而上。

自上而下是先了解源码的总体结构,总的执行流程是怎样,再一层一层往下研究。而自下而上是着重研究某个功能的细节,弄懂细节以后再研究其上层。

2 . 面向问题看源码。

在研究某个功能时,先提出问题,再研究源码解决问题。不过如有问题尝试好久都没法解决,能够先放下,继续研究其余问题,以后再回来解决。

3 . 调试源码。

对于很是简单的功能,通常只看源码就能弄懂。但其余功能,每每只有通过调试才能能验证和推理,从而真正弄懂。下一篇会写如何搭建支持全部React版本断点调试细分文件的React源码调试环境。

感谢你花时间阅读这篇文章。若是你喜欢这篇文章,欢迎点赞、收藏和分享,让更多的人看到这篇文章,这也是对我最大的鼓励和支持!

欢迎经过微信(扫描下方二维码)或Github订阅个人博客。

微信公众号:苏溪云的博客