react map循环生成的button_【第1945期】彻底搞懂React源码调度原理(Concurrent模式)...

2023-11-12

前言

估计会懵逼。今日早读文章由成都@苏溪云投稿分享。

正文从这开始~~

83b2ee0f344d24d0e95be0f861caf85d.png

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

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

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

所以本文主要是通过自己对源码的阅读,推理和验证,加上大量时间作为催化剂,将React源码中的调度原理展现给各位读者。

React使用当前最新版本:16.13.1

同步调度模式

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

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

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

我们用类组件实现案例。

渲染内容:

ref={this.buttonRef} onClick={this.handleButtonClick}>增加2

{Array.from(new Array(8000)).map( (v,index) =>

key={index}>{this.state.count}

)}

添加按钮点击事件:

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性能分析图:

3463d580d54b20590301ea9a0da4b56b.png

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

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

Concurrent(同时)调度模式

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

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

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

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

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

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

但有了版本管理软件,我们每个人都可以拉一个分支,修改同一个文件,然后将自己修改的内容合并到主分支上,做到多人“同时”修改一个文件。

所以,如果React也能做到“同时”执行多个更新任务,做到每一个更新任务的执行不会阻塞其他更新任务的加入,岂不是很方便。

这可以看作是“同时”模式名字的由来。

同时调度模式的应用场景

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

81f0ad5bf8b18cab8d49fb8e51ce80b5.gif

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

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

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

同时调度模式如何实现

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

时间切片

b522fffe5dd4a153e4c724555e08ffe8.png

什么是时间切片

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

时间切片的作用

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

  • 判断是否有用户界面交互事件和其他需要执行的代码,比如点击事件,有的话则执行该事件

  • 判断是否有优先级更高的任务需要执行,如果有,则中断当前任务,执行更高的优先级任务。也就是利用时间前片来实现高优先级任务插队。

即时间切片有两个作用:

  • 在执行任务过程中,不阻塞用户与页面交互,立即响应交互事件和需要执行的代码

  • 实现高优先级插队

React源码如何实现时间切片
首先在这里引入当前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腻子脚本相关代码。

12a910a41e0791c1ad4e7f2de8a6dda9.png

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

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

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(提交步骤)。

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

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

在执行调度工作循环和计算工作循环时,执行每一个工作中Fiber。但是,有一个条件是每隔5毫秒,会跳出工作循环,运行一次异步的MessageChannel的port.postMessage(...)方法,检查是否存在事件响应、更高优先级任务或其他代码需要执行,如果有则执行,如果没有则重新创建工作循环,执行剩下的工作中Fiber。

b522fffe5dd4a153e4c724555e08ffe8.png

但是,为什么性能图上显示的切片不是精确的5毫秒?

因为一个时间切片中有多个工作中fiber执行,每执行完一个工作中Fiber,都会检查开始计时时间至当前时间的间隔是否已超过或等于5毫秒,如果是则跳出工作循环,但算上检查的最后一个工作中fiber本身执行也有一段时间,所以最终一个时间切片时间一定大于或等于5毫秒。

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

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

如何调度一个任务

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

30e2c0b7e74f8433de6638bfe6a97097.png

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如何调度一个任务。

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

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

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

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

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

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

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

完成任务
  • 在执行完所有工作中fiber后,React进入提交步骤,更新DOM。

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

如何实现优先级

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

a5f8d9a9312eb10052b25ca1c2809f05.png

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

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

不同优先级的作用就是让高优先级任务优先于低优先级任务执行,并且由于时间切片的特性(每5毫秒执行一次异步的port.postMessage(...),在执行相应回调函数前会执行检测到的需要执行的代码)高优先级任务的加入可以中断正在运行的低优先级任务,先执行完高优先级任务,再重新执行被中断的低优先级让任务。

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

高优先级插队

接下来,使用类似同步模式代码的插队案例。

渲染内容:

ref={this.buttonRef} onClick={this.handleButtonClick}>增加2

{Array.from(new Array(8000)).map( (v,index) =>

key={index}>{this.state.count}

)}

添加按钮点击事件:

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对应的普通更新正在执行中,还没有完成,这个时候最能体现插队效果。

732cb4dd7895b3c9b175e8eec2cf6f11.gif

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

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

接下来,观察性能图。

总览:

58df46b6dd5166b22befc3af045b3fb4.png

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

6a970f79b01bf79ae6c1054a419ba77b.png

如何实现高优先级插队

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

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

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

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

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

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

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

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

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

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

过期时间ExpirationTime

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

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

事实上,当前Reac正在重写ExpirationTime的功能,如果后续看到这篇文章发现跟源码差别较大.

ExpirationTime的变化过程

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

更新低优先级

4dba7e95f5d44e95a899051c9fb64c9d.png

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

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

表格的第二第三过程转了一圈,最后还是回到第一次计算的过期时间(因为js同步执行少量代码过程中,performance.now()的变化几乎可以忽略)。

中断低优先级更新,更新高优先级

8becb1c4b77fa77a80a5926cd869e09f.png

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

因为低优先级过期时间标记小于高优先级过期时间标记,即低优先级过期时间大于高优先级过期时间(过期时间标记与过期时间成反比,下面会讲到),表明低优先级更新已经被插队,需要重新执行。所以低优先级更新过期时间标记设为工作中类fiber的过期时间标记。

重新更新低优先级

89bf75b275e4cbbd60c22cb6fe4add13.png

过期时间的两种类型

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

expirationTime的2种类型:

  • 时间标记:一个极大值,如1073741121

  • 过期时间:从网页加载开始计时的实际过期时间,单位为毫秒

过期时间标记

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的确是根据一个最大整数值偏移量来计算:

MAGICNUMBEROFFSET - ceiling(MAGICNUMBEROFFSET - currentTime + expirationInMs / UNITSIZE, bucketSizeMs / UNITSIZE)

其中:

  • MAGICNUMBEROFFSET 是一个极大常量: 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),在第一个更新执行更新队列时,其他更新早已被加入更新队列,所以能确保计所有更新参与计算。

  • MAGICNUMBEROFFSET - currentTime的值为performance.now()/10

  • expirationInMs 表示不同优先级对应的过期时长:

    • 普通/低优先级:5秒

    • 高优先级(用户交互优先级):生产环境下为150毫秒,开发环境下为500毫秒

    • 立即优先级、空闲优先级不通过上面的公式计算,它们的过期时间标记值分别为1和2,一个表示立即过期,另一个表示永不过期。

  • 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毫秒的体现

运行一次异步的MessageChannel的port.postMessage(...)方法,检查是否存在事件响应、更高优先级任务或其他代码需要执行,如果有则执行,如果没有则重新创建工作循环,执行剩下的工作中Fiber。

var channel = new MessageChannel();

var port = channel.port2;

channel.port1.onmessage = performWorkUntilDeadline;

requestHostCallback = function(callback) {

...

if (...) {

...

port.postMessage(null);

}

}

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

调度一个任务源码
初始化

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

setState(...) --> enqueueSetState(...)

--> scheduleWork(...) --> ensureRootIsScheduled(...)

当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为调度中心的清空任务方法,即将任务队列中的任务执行后然后移除

运行任务

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(...);

}

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

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

}

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

function performConcurrentWorkOnRoot(...) {

...

do {

try {

workLoopConcurrent();

break;

} catch (...) {

...

}

} while (true);

...

return performConcurrentWorkOnRoot.bind(...);

}

完成任务

在执行完所有工作中fiber后,React进入提交步骤,更新DOM。

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

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

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

}

...

}

高优先级插队

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

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

onClick --> discreteUpdates

--> runWithPriority(UserBlockingPriority, ...)

-->setState

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

enqueueSetState: function (...) {

...

var expirationTime = computeExpirationForFiber(...);

var update = createUpdate(...);

...

enqueueUpdate(fiber, update);

scheduleWork(fiber, expirationTime);

}

在安排工作方法中,运行确保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,

...

};

执行高优先级任务,当执行到开始计算工作中类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

...

}

NoWork为0

在完成优先级任务过程的提交渲染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);

...

}

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

最后写点什么

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

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

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

面向问题看源码。

在研究某个功能时,先提出问题,再研究源码解决问题。不过若有问题尝试很久都无法解决,可以先放下,继续研究其他问题,之后再回来解决。

调试源码。

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

对react原理感兴趣的可以关注苏溪云的博客:https://github.com/Terry-Su/blogs-cn

关于本文 作者:@苏溪云 原文:https://mp.weixin.qq.com/s/pFfpv0-KGmGqtKkm6UZKeg

5ec5268691aec653298923d4e4e2a1d3.png

为你推荐

【第1894期】NutUI CLI源码解析

【第1774期】详细preact hook源码逐行解析

【第1944期】HTTP/3原理与实践

欢迎自荐投稿,前端早读课等你来

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

react map循环生成的button_【第1945期】彻底搞懂React源码调度原理(Concurrent模式)... 的相关文章

  • eclipse中package,source folder和folder

    在eclipse的Package explorer中 如下图所示 Source folder 存放Java的源代码 eclipse会自动编译里面的文件 以 来进行文件夹的分级 默认为src文件夹 Package 一般位于source fol
  • 【深度学习】 Python 和 NumPy 系列教程(六):Python容器:4、字典Dictionary详解(初始化、访问元素、常用操作、常用函数、遍历、解析)

    目录 一 前言 二 实验环境 三 Python容器 Containers 0 容器介绍 4 字典 Dictionary 0 基本概念 1 初始化 a 使用 创建字典 b 使用dict 函数创建字典 2 访问字典元素 a 使用方括号 b 使用
  • nexus下载安装

    进入官网http www sonatype org 点击Jion Now 展开downloads 选择Nexus Repository Manager OSS 目前已经更新到3 X了 这里暂且还是选2 X的吧 下载完 解压 cmd打开命令提
  • 【软件工程】第五章 结构化设计

    5 1 结构化设计的概念 5 1 1 设计的定义 何谓设计 一种软件开发活动 定义实现需求规约所需的软件结构 目标 依据需求规约在一个抽象层上建立系统软件模型 包括软件体系结构 数据和程序结构 以及详细的处理算法 给出软件解决方案 产生设计
  • 欧拉回路【总结】【题解】

    题目 欧拉回路 UOJ 欧拉回路 Liuser s OJ 题目描述 有一天一位灵魂画师画了一张图 现在要你找出欧拉回路 即在图中找一个环使得每条边都在环上出现恰好一次 一共两个子任务 无向图 有向图 输入格式 第一行一个整数 t 表示子任务
  • vue项目启动后,js-base64依赖报错Cannot read properties of null (reading ‘replace’)

    cannot read properties of null reading replace 关于这种乱七八糟的问题 咱也不敢说 在哪也不敢问 项目运行之后 有一些警告 都是一些依赖版本的问题 平时也能直接给运行起来 这次就是项目可以运行起
  • rabbitmq+springboot实现幂等性操作

    文章目录 1 场景描述 1 1 场景1 1 2 场景2 2 原理 3 实战开发 3 1 建表 3 2 集成mybatis plus 3 3 集成RabbitMq 3 3 1 安装mq 3 3 2 springBoot集成mq 3 4 具体实
  • 阿里云服务器使用xshell连接

    阿里云服务器使用xshell连接 当购买了第一次阿里云服务器时 如何使用xshell连接 其实是非常简单的 1 登录阿里云控制台 1 是你的阿里云服务器所在地址 2 是公网IP 将来远程连接时需要使用 3 是设置远程连接的密码 用户名默认r
  • 在x86和arm编译libmodbus

    编译libmodbus 下载路径 1 编译准备 sudo apt get install libtool autogen sh 2 arm编译 autogen sh mkdir install configure ac cv func ma
  • 电压电流的驱动能力分析以及计算方法

    文章为笔者学习过程中看到的 感觉帮助较大 分享出来希望能帮助到大家 在电子电路中为什么有的地方电压会被拉低2 驱动能力是什么意思 如何提高驱动能力 在很多资料上看到说驱动能力不够是因为提供的电流太小 为什么不说电压呢 在很多限制的条件都是电
  • BIOS开启虚拟化技术

    什么是BIOS BIOS 是一个内置于个人计算机的程序 当您打开计算机时该程序启动操作系统 也称为系统固件 BIOS 是计算机硬件的一部分 不同于 Windows 怎么进入BIOS 电脑进入BIOS的方法各有不同 通常会在开机时 显示电脑l
  • atoi函数源代码

    atoi函数源代码 isspace int x if x x t x n x f x b x r return 1 else return 0 isdigit int x if x lt 9 x gt 0 return 1 else ret
  • CPU是如何读写内存的?

    如果你不知道CPU是如何读写内存的 那你应该好好看看这篇文章 如果你觉得这是一个非常简单的问题 那你更应该好好读读本文 这个问题绝没有你想象那么简单 一定要读完 闲话少说 让我们来看看CPU在读写内存时底层究竟发生了什么 1 谁来告诉CPU
  • Mybatis二级缓存应用场景和局限性

    二级缓存应用场景 对查询频率高 变化频率低的数据建议使用二级缓存 对于访问多的查询请求且用户对查询结果实时性要求不高 此时可采用mybatis二级缓存技术降低数据库访问量 提高访问速度 业务场景比如 耗时较高的统计分析sql 电话账单查询s
  • ChatGPT是否具有记忆能力?

    ChatGPT在某种程度上具有记忆能力 但它的记忆能力有限且不像人类的记忆那样全面和持久 以下是对ChatGPT的记忆能力的详细分析 1 上下文记忆 ChatGPT可以在对话过程中记住先前的对话历史 以便更好地理解和回应后续的问题 通过将上
  • 带你了解并实践monorepo和pnpm,绝对干货!熬夜总结!

    大厂技术 高级前端 Node进阶 点击上方 程序员成长指北 关注公众号 回复1 加入高级Node交流群 为什么使用monorepo 什么是monorepo 简单来说就是 将多个项目或包文件放到一个git仓库来管理 目前比较广泛应用的是yar
  • java面试笔试基本知识点总结

    1 正则表达式 正则表达式定义了字符串的模式 正则表达式可以用来搜索 编辑或处理文本 正则表达式并不仅限于某一种语言 但是在每种语言中有细微的差别 在编写处理字符串的程序时 经常会有查找符合某些复杂规则的字符串的需要 正则表达式就是用于描述
  • hikaricp druid比较_Spring Boot整合MybatisPlus和Druid

    在Java中 我比较ORM熟悉的只有Hibernate和Mybatis 其他的并未实践使用过 在这二者之间我更喜欢Mybatis 因为它精简 灵活 毕竟我是上年纪的程序员 喜欢自己写SQL 刚才有提到Mybatis 但是这里的重点是介绍My
  • 应用APK文件有效瘦身

    先说下前言 为什么要这样处理 随着项目的越来越多丰富功能 打包出来的apk体积日益变大 不说打包耗时 编译耗时 发布到应用市场 用户下载流量多 而且手机空间那么有限 用户不满意 咱们就要进行改变呗 没有体验 就没有用户 我先贴一张图 演示项

随机推荐

  • Python常用命令整理

    Anaconda常用命令 1 管理Conda 1 检查conda版本 conda version 2 升级当前版本conda conda update conda 2 管理 虚拟 环境 1 创建环境 创建一个名为python3的环境 指定P
  • Visual Studio 安装检测内存工具-Visual Leak Detetctor。(适用于VS2013、VS2015、VS2017、VS2019、VS2022版本)

    目录 前言 Visual Leak Detetctor 外部安装VLD 安装包 配置VLD 查看相关文件 将VLD配置到C 项目中 创建一个C 的空工程 配置头文件 配置lib库 测试Visual Leak Detetctor 前言 如果你
  • Got permission denied while trying to connect to the Docker daemon socket

    docker权限问题 需要将当前用户添加到docker组 sudo groupadd docker 添加docker用户组 sudo gpasswd a XXX docker 检测当前用户是否已经在docker用户组中 其中XXX为用户名
  • Mac 下 Arduino 开发环境搭建

    文章目录 Mac 下 Arduino 开发环境搭建 驱动 Arduino 安装 Arduino IDE 使用 Arduino IDE 准备跑路 VS Code 安装 VS Code 安装 Arduino 扩展 安装 C C 扩展 开始开发
  • 剑指Offer第二十七题:字符串的排列

    题目描述 输入一个字符串 按字典序打印出该字符串中字符的所有排列 例如输入字符串abc 则打印出由字符a b c所能排列出来的所有字符串abc acb bac bca cab和cba 思路 感觉是动态规划题 假设选第一个元素时 对于abc有
  • Redis+Lua限制发送量及遇到的坑

    业务中需要限制每个账号每天发送短信数量 如果没有超过设置的发送量 则正常发送 否则返回失败 解决思路 将账号ID yyyyMMdd组成redis的key value为当天的发送量 在发送前获取账号ID yyyyMMdd的值 如果没有超过发送
  • MachineLearningWu_13/P60-P64_Tensorflow

    P60 P64的学习目录如下 x 1 TF网络模型实现 以一个简单的TF的分类网络为例 将模型翻译成框架下的语义 即如右侧所表达的 当然上面对于分类网络的解释是一个简洁的解释 我们来进行更加具象的了解一下 左边是机器学习的三步骤 1 给定输
  • next_permutation 函数的使用 poj1256

    next permutation全排列函数是一个十分好用而且强大的函数 要想更好的了解这个函数可以看https blog csdn net howardemily article details 68064377 个人感觉写的特别好 里面有
  • <<视觉问答>>2021:Separating Skills and Concepts for Novel Visual Question Answering

    目录 摘要 一 介绍 二 相关工作 三 Skill Concept Composition in VQA 四 方法 4 1 Concept Grounding 4 2 Skill Matching 4 3 Training Procedur
  • 父子组件传值,子组件数据不更新

    项目场景 提示 这里简述项目相关背景 例如 查看列表中的某一条 显示这条的详情信息 这里的详情是一个弹框子组件 后台管理系统 问题描述 提示 这里描述项目中遇到的问题 在父子组件传参时 父组件将值传到子组件后 子组件进行数据展示 在第一次传
  • python3 request post请求中文例子

    下面是一个使用Python 3发送POST请求并包含中文数据的示例 import requests 请求URL url https example com api 请求头部设置 headers Content Type applicatio
  • Derby 和 Sqlite 数据库的配置与使用

    Derby 和 Sqlite 数据库的配置与使用 Derby 和 Sqlite 数据库 一种无需安装可直接使用的数据库 使用这两个数据库只需要下载其文件夹并配置其环境变量 然后导入对应的 jar 包即可直接使用 不同于 Mysq 和 Ora
  • 欧拉函数以及欧拉降幂

    大数幂运算指数太大的时候 我们需要进行降幂操作 首先呢 认识欧拉定理之前 先了解一下欧拉函数 欧拉函数性质 若p是一个质数 那么 p p 1 欧拉函数是积性函数 所以 nm n m 若n p k且p为质数 那么 n p k p k 1 证明
  • 耐人思考的“30秒法则”

    时间管理 30秒法则 也被称为 电梯法则 这个法则说得是 凡事要在最短的时间内把结果表达清楚 凡事要直奔主题 直奔结果 这个30秒法则 是由一个真实的故事引申出来的 美国知名咨询公司麦肯锡 有一位重要的大客户来公司洽谈 董事长因急事赶飞机
  • STM32毕业设计题目选题分享

    文章目录 1前言 2 STM32 毕设课题 3 如何选题 3 1 不要给自己挖坑 3 2 难度把控 3 3 如何命名题目 1前言 更新单片机嵌入式选题后 不少学弟学妹催学长更新STM32和C51选题系列 感谢大家的认可 来啦 以下是学长亲手
  • java 以流的形式从服务器下载文件并保存到本地

    1 基本实现流程 当我们想要下载网站上的某个资源时 我们会获取一个url 它是服务器定位资源的一个描述 下载的过程有如下几步 1 客户端发起一个url请求 获取连接对象 2 服务器解析url 并且将指定的资源返回一个输入流给客户 3 建立存
  • JavaScript设计模式读书笔记(五)=>架构型设计模式,MVC,MVP,MVVM

    全系列目录 JavaScript设计模式读书笔记 一 gt 创建型设计模式 JavaScript设计模式读书笔记 二 gt 结构型设计模式 JavaScript设计模式读书笔记 三 gt 行为型设计模式 JavaScript设计模式读书笔记
  • ] 2014找工作总结-机会往往留给有准备的人

    看了这篇文章 感觉到了震撼 如果早看到该有多好 我也是2014年的毕业生 我的找工作历程也基本上告一段落了 与本博文的原作者比起来 自己仿佛到现在也没有真正的为工作而准备 这也是自己没有规划的原因吧 所以在找工作的过程中只收到了一个offe
  • 电脑与云服务器传输文件,电脑与云服务器传输文件

    电脑与云服务器传输文件 内容精选 换一换 当创建文件系统后 您需要使用云服务器来挂载该文件系统 以实现多个云服务器共享使用文件系统的目的 本章节以Windows 2012版本操作系统为例进行CIFS类型的文件系统的挂载 同一SFS容量型文件
  • react map循环生成的button_【第1945期】彻底搞懂React源码调度原理(Concurrent模式)...

    前言 估计会懵逼 今日早读文章由成都 苏溪云投稿分享 正文从这开始 最早之前 React还没有用fiber重写 那个时候对React调度模块就有好奇 而现在的调度模块对于之前没研究过它的我来说更是带有一层神秘的色彩 色彩中朦胧浮现出两个字