我们都知道,React
是一个用于构建用户交互界面的 JavaScript
库,其核心机制就是跟踪组件的状态变化,并将更新的状态映射到到新的界面,在 React
中,我们将此过程称之为『协调』(Reconcilation
),我们调用 setState
方法来改变状态,而框架本身会去检查 state
或 props
是否已经更改来决定是否重新渲染组件,React
的官方文档对 协调机制 进行了良好的抽象描述,即
React
的元素、生命周期、render
方法,以及应用于组件子元素的Diffing
算法综合起到的作用,就是『协调』
我们将从 render
方法返回的不可变的 React
元素通常称为『虚拟 DOM
』,但是除了『虚拟 DOM
』之外,React
框架总是在内部维护一个实例来持有状态(如组件、DOM
节点等),从版本 16
开始,React
推出了新的内部实例树的实现方法,也就是我们经常听闻的 Fiber
算法,但是在本章当中我们并不会太过深入的去介绍源码相关内容,而是主要来探索一下这个所谓的 Fiber
到底是什么以及它出现的缘由和解决的一些问题
存在的问题
之所以会推出新的 Fiber
,那就说明在之前的 React
版本当中是存在一定的问题的,那么下面我们就来先看看之前的版本当中到底是存在哪些问题,这个我们可以从官方提供的 示例 开始看起,运行效果如下
至于根本原因,我们可以通过查看其对应的 源码 来分析具体原因,通过观察源码我们可以发现,代码当中使用了 requestAnimationFrame
这个 API
,关于 requestAnimationFrame
这个接口的使用我们就不介绍更多内容了,直接来看代码的实现,详细可以参考之前我们整理过的 requestAnimationFrame 的使用 以及 并发模型中的 requestAnimationFrame
1 | var start = new Date().getTime() |
另外,为了每秒更新一次圆点中的数字,ExampleApplication
组件维护了一个 seconds
状态,使用 setInterval
每秒更新一次
1 | componentDidMount() { |
以上效果之所以会卡顿,究其原因,对绝大多的浏览器来说,它的页面刷新频率取决于显示器的刷新频率,比如一台刷新频率是 60FPS
的显示器,requestAnimationFrame
会每 16.66ms
(1000ms / 60
)执行一次回调函数,但为了给主线程时间处理其他事务,一般要求产出一帧画面的时间不要超过 10ms
,所以 requestAnimationFrame
的执行频率不会比显示器更高,但有可能更低
默认情况下,JavaSceipr
运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系,如果 JavaSceipr
运算持续占用主线程,页面就没法得到及时的更新,当我们调用 setState
更新页面的时候,React
会遍历应用的所有节点,计算出差异,然后再更新 UI
,整个过程是一气呵成,不能被打断的,如果页面元素很多,整个过程占用的时机就可能超过 16
毫秒,这是就容易出现掉帧的现象,也就是说,其根本原因是因为大量的同步计算任务阻塞了浏览器的 UI
渲染
而 React
的 Reconcilation
是 CPU
密集型的操作,旧版 React
通过『递归』的方式进行渲染,使用的是 JavaSceipr
引擎自身的函数调用栈,它会一直执行到栈空为止,也就是比对 Virtual DOM
树,找出需要变动的节点,然后同步更新它们,这个过程 React
称为 Reconcilation
(协调)
而 React
团队使用 Fiber
实现了自己的组件调用栈以后,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务,基本思路是将运算切割为多个步骤,分批完成,也就是说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染,等浏览器忙完之后,再继续之前未完成的任务,所以使用 Fiber
架构的目的是让 Reconcilation
过程变成『可被中断』,适时地让出 CPU
执行权,让浏览器及时地响应用户的交互
如下就是优化后的效果,我们可以明显感觉到,示例运行起来会比之前看上去流畅许多
协调(Reconciliation)
我们都知道,React
的核心是定义组件,渲染组件的方式则是由环境所决定(比如 React Native
),定义组件,组件状态管理,生命周期方法管理,组件更新等应该跨平台一致处理,不受渲染环境影响,而这部分内容统一由 协调器(Reconciler) 处理,不同渲染器都会使用该模块,协调器主要作用就是在组件状态变更时,调用组件树各组件的 render
方法,渲染,卸载组件
Stack Reconciler
我们知道浏览器渲染引擎是单线程的,在上面章节当中我们也提到过,在 React
之前的版本当中,计算组件树变更时将会阻塞整个线程,整个渲染过程是连续不中断完成的,而这时的其他任务都会被阻塞,如动画等,这可能会使用户感觉到明显卡顿,这个版本的协调器可以称为『栈协调器』(Stack Reconciler
),其协调算法的大致实现过程可以参考官方文档中的 React Stack Reconciler
Stack Reconcilier
的主要缺陷就是不能暂停渲染任务,也不能切分任务,无法有效平衡组件更新渲染与动画相关任务间的执行顺序,即不能划分任务优先级,有可能导致重要任务卡顿,动画掉帧等问题
Fiber Reconciler
而 React 16
版本提出了一个更为先进的协调器,它允许渲染进程分段完成,而不必须一次性完成,中间可以返回至主进程控制执行其他任务,而这是通过计算部分组件树的变更,并暂停渲染更新,询问主进程是否有更高需求的绘制或者更新任务需要执行,这些高需求的任务完成后才开始渲染
这一切的实现是在代码层引入了一个新的数据结构,也就是我们的 Fiber
对象,每一个组件实例对应有一个 Fiber
实例,此 Fiber
实例负责管理组件实例的更新,渲染任务及与其他 Fiber
实例的联系,这个新推出的协调器就叫做『纤维协调器』(Fiber Reconciler
),它提供的新功能主要有下面这些
- 可切分,可中断任务
- 可重用各分阶段任务,且可以设置优先级
- 可以在父子组件任务间前进后退切换任务
render
方法可以返回多元素(即可以返回数组)- 支持异常边界处理异常
下面就让我们来深入的了解一下,到底什么是 Fiber
什么是 Fiber
那么什么是 Fiber
呢?其实 Fiber
是一种流程控制原语,它是一个非常底层的抽象描述,我们可以称其 协程 或者『纤程』,但是需要注意的是,协程和『线程』并不一样,协程本身是没有并发或者并行能力的(需要配合线程),它只是一种控制流程的让出机制,这里我们可以对比普通函数和 Generator
的运行方式,普通函数执行的过程中无法被中断和恢复,如下
1 | const tasks = [] |
而 Generator
却是可以的
1 | const tasks = [] |
所以我们可以发现,其实 React Fiber
的思想和协程的概念是契合的,也就是说 React
渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染,那么这里你可能会有一个疑问,那就是 React
是如何将控制权交回浏览器的呢?其实浏览器并没有抢占的条件,通常来说是 React
主动让出机制,这是因为
- 一来浏览器中没有类似进程的概念,任务之间的界限很模糊,没有上下文,所以不具备中断或是恢复的条件
- 二则是没有抢占的机制,我们无法中断一个正在执行的程序,所以我们只能采用类似协程这样控制权让出机制
观察我们上面的代码,其实上面代码示例中的 hasHighPriorityEvent()
在目前浏览器中是无法实现的,因为我们没办法判断当前是否有更高优先级的任务等待被执行,从而让其让出机制,所以我们只能换一种思路,也就是通过『超时检查的机制』来让出控制权,即确定一个合理的运行时长,然后在合适的检查点检测是否超时(比如每执行一个小任务),如果超时就停止执行,将控制权交换给浏览器,而这个方式的实现主要依赖的是浏览器提供的 requestIdleCallback 这个 API
1 | window.requestIdleCallback( |
IdleDeadline
的接口如下
1 | interface IdleDealine { |
单从名字上理解的话,requestIdleCallback
的意思是让浏览器在有空的时候就执行我们的回调,这个回调会传入一个期限,表示浏览器有多少时间供我们执行,为了不耽误事,我们最好在这个时间范围内执行完毕,现在我们知道了如何让出机制,那么浏览器什么时候有空呢?我们先来看一下浏览器在一帧(Frame
,可以认为事件循环的一次循环)内可能会做什么事情,通常来说可能会做执行下列任务,而且它们的执行顺序基本是固定的
- 处理用户输入事件
JavaScript
执行requestAnimation
调用- 布局
Layout
- 绘制
Paint
通常,客户端线程执行任务时会以帧的形式划分,大部分设备控制在 30-60
帧是不会影响用户体验,在两个执行帧之间,主线程通常会有一小段空闲时间,requestIdleCallback
可以在这个『空闲期』(Idle Period
)调用『空闲期回调』(Idle Callback
)执行一些任务
但是在浏览器繁忙的时候,可能不会有盈余时间,这时候 requestIdleCallback
回调可能就不会被执行,所以在这种情况下可以通过 requestIdleCallback
的第二个参数指定一个超时时间
另外不建议在
requestIdleCallback
中进行DOM
操作,因为这可能导致样式重新计算或重新布局(比如操作DOM
后马上调用getBoundingClientRect()
),这些时间很难预估的,很有可能导致回调执行超时,从而掉帧
但是这个超时时间不是死的,低优先级的可以慢慢等待,高优先级的任务应该率先被执行,目前 React
预定义了五个优先级
Immediate
(-1
),这个优先级的任务会同步执行,或者说要马上执行且不能中断UserBlocking
(250ms
),这些任务一般是用户交互的结果,需要即时得到反馈Normal
(5s
),应对哪些不需要立即感受到的任务,例如网络请求Low
(10s
),这些任务可以放后,但是最终应该得到执行,例如分析通知Idle
(没有超时时间),一些没有必要做的任务(比如隐藏的内容)
但是目前 requestIdleCallback
只有 Chrome
支持,所以为了支持其它浏览器,React
干脆自己 实现 了一个,它是利用 MessageChannel
模拟将回调延迟到绘制操作之后执行,如下图
看到这里你可能会问,我们在上面提到过,使用 Generator
函数也是可以控制函数的执行流程,那么为什么官方不直接采用 Generator
的实现方式呢?关于这一点的原因,可以参考官方在 Fiber Principles: Contributing To Fiber 当中的回答,这里我们就不详细展开了
其实我们上面介绍那么多,如果简单的理解的话,我们可以把 Fiber
认为是一种数据结构或者说执行单元(会在下面进行介绍),我们将它视作一个执行单元,每次执行完一个执行单元,React
就会检查现在还剩多少时间,如果没有时间就将控制权让出去,React
没有使用 Generator
这种让出机制,而是实现了自己的调度让出机制,这个机制就是基于 Fiber
这个执行单元的
简单总结一下就是
React
应用中的基础单元是组件,应用以组件树形式组织,渲染组件Fiber
协调器基础单元则是协调单元(协调器算法组成单元),应用以Fiber
树形式组织,应用Fiber
算法- 组件树和
Fiber
树结构对应,一个组件实例有一个对应的Fiber
实例 Fiber
协调器算法负责整个应用层面的协调,而Fiber
实例则负责对应组件的协调
下面我们就来看看从 React
元素到 Fiber
节点是如何转化的,也就是 React
为 Fiber
架构做了哪些改造
Fiber 节点
我们都知道,React
元素并非真实的 DOM
节点或组件实例,而是一种描述方式,用于描述 DOM
元素的类型、拥有的属性以及包含的子元素,这也正是 React
的核心所在,React
将构建、渲染以及管理真实 DOM
树生命周期这些复杂的逻辑进行了抽象,从而避免了我们直接操作真实 DOM
而引起的巨大性能消耗
在协调期间,从 render
方法返回的每个 React
元素的数据都会被合并到 Fiber
节点树中,每个 React
元素都有一个相应的 Fiber
节点,与 React
元素不同,不会在每次渲染时重新创建这些 Fiber
,这些是持有组件状态和 DOM
的可变数据结构
因此,这意味着当我们调用 ReactDOM.render()
或 setState()
时,React
将执行协调,在 setState
的情况下,它执行遍历并通过将新树与已渲染的树进行区分来找出树中发生了什么变化,然后将这些更改应用于当前树,从而更新与 setState()
调用相关的 state
我们在之前提到过,旧版 React
是通过『递归』的方式进行渲染的,也就是说这是基于函数调用栈的协调算法,只不过这种依赖于调用栈的方式不能随意中断、也很难被恢复,不利于异步处理,这种调用栈,不是程序所能控制的,如果你要恢复递归现场,可能需要从头开始,恢复到之前的调用栈,所以针对于这种情况就需要对 React
现有的数据结构进行调整,模拟函数调用栈,将之前需要递归进行处理的事情分解成增量的执行单元,将递归转换成迭代
React
目前的做法是使用链表,每个 VirtualDOM
节点内部现在使用 Fiber
表示,它的结构大概如下
1 | export type Fiber = { |
用图片来展示这种关系会更直观一些
因为 React
为每个 React
元素创建一个 Fiber
节点,并且因为我们有一个这些元素组成的树,所以我们可以得到一个 Fiber
节点树,所有 Fiber
节点都通过链表连接,具体是使用 Fiber
节点上的 child
、sibling
和 return
属性,至于它为什么以这种方式工作,可以参考 如何以及为什么 React Fiber 使用链表遍历组件树 这篇文章,这里我们就不详细展开了
下面我们来深入的了解一下 Fiber
当中的节点类型,它们有以下这些
1 | export type Fiber = { |
Fiber
包含的属性可以划分为五个部分
- 结构信息,上面已经介绍过,
Fiber
使用链表的形式来表示节点在树中的定位 - 节点类型信息,
tag
表示节点的分类、type
保存具体的类型值,如div
、MyComp
- 节点的状态,节点的组件实例
props
、state
等,它们将影响组件的输出 - 副作用,在协调过程中发现的副作用就保存在节点的
effectTag
中(类似打上标记),这里也使用了链表结构,将本次渲染的所有副作用节点都收集起来,通过nextEffect
连接起来 workInProgress
(WIP
)树,React
在协调过程中会构建一颗新的树,可以认为是一颗表示当前工作进度的树,还有一颗表示已渲染界面的旧树,React
就是一边和旧树比对,一边构建WIP
树的,alternate
指向旧树的同等节点
下面我们就来简单的看看各个属性的作用
type 和 key
Fiber
的 type
和 key
对 React
元素起着同样的作用(实际上,Fiber
从一个元素创建时,这两个属性直接被复制过来),type
描述了它对应的组件,对于合成组件来说 type
是一个函数或者类组件本身,对于原生元素(div
,span
等),它是一个字符串,从概念上来说,type
是在执行时被栈帧追踪的函数(如在 v = f(d)
中)
而与 type
一起的 key
,被用来在协调过程中决定 Fiber
是否可以再利用,也就是在协调阶段用来标识 Fiber
,以检测是否可重用该 Fiber
实例
child 和 sibling
表示当我们在组件上调用 render()
时返回的元素,例如
1 | const Name = (props) => { |
<Name>
的子元素是 <div>
,因为它返回一个 <div>
元素,而 sibling
字段则对应 render
返回多个孩子节点的情况,如下
1 | const Name = (props) => { |
在上述情况下,<Child1>
和 <Child2>
是父元素 <Name>
的子元素,这两个子元素组成一个单链表,head
指针指向第一个孩子节点,所以在上例中 Parent
的孩子节点是 Child1
,Child1
的兄弟节点是 Child2
,如果放到函数当中类比的话,可以认为一个子 Fiber
是一个尾调用函数
return
return
是当前 Fiber
处理完成后需要返回的 Fiber
,从概念上来说它对应栈帧返回的地址,从逻辑上讲,它是返回到父 Fiber
节点,因此可以理解为父 Fiber
,如果一个 Fiber
有多个子 Fiber
,每个子 Fiber
返回的 Fiber
都是它的父 Fiber
,在上面示例中的 Child1
和 Child2
的 return
就是 Parent
pendingProps 和 memoizedProps
分别表示组件当前传入的及之前的 props
,memoizedProps
主要用来存储函数执行结果的值,以便以后可以使用它,从而避免重新计算,pendingProps
表示传递给组件的 props
当传入的 pendingProps
等于 memoizedProps
时,它表示 Fiber
之前的输出可以复用,从而避免不必要的工作
alternate
可以理解为一个 Fiber
版本池,用于交替记录组件更新(切分任务后变成多阶段更新)过程中 Fiber
的更新,因为在组件更新的各阶段,更新前及更新过程中 Fiber
状态并不一致,在需要恢复时(比如冲突),即可使用另一者直接回退至上一版本 Fiber
- 使用
alternate
属性双向连接一个当前Fiber
和其workInProgress
,当前Fiber
实例的alternate
属性指向其workInProgress
,workInProgress
的alternate
属性指向当前稳定Fiber
- 当前
Fiber
的替换版本是其workInProgress
,workInProgress
的交替版本是当前Fiber
- 当
workInProgress
更新一次后,将同步至当前Fiber
,然后继续处理,同步直至任务完成 workInProgress
指向处理过程中的Fiber
,而当前Fiber
总是维护处理完成的最新版本的Fiber
tag
我们先来看看如何创建 Fiber
实例,如下
1 | var createFiber = function ( |
可以发现这里有一个 tag
属性,它主要用来标记 Fiber
类型,而 Fiber
实例是和组件对应的,所以其类型基本上对应于组件类型,主要有以下这些
1 | export type TypeOfWork = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
stateNode 和 FiberRoot
FiberRoot
对象,主要用来管理组件树组件的更新进程,同时记录组件树挂载的 DOM
容器相关信息
1 | export type FiberRoot = { |
Fiber 架构
本小节当中我们尝试来简单的梳理一下 Fiber
架构,但是不会过多的涉及源码,对源码感兴趣的话可以参考 官方仓库 来了解更多
优先级(ExpirationTime VS PriorityLevel)
我们已经知道 Fiber
可以切分任务并设置不同优先级,那么是如何实现划分优先级的呢,其表现形式什么呢?主要有以下两种方式
ExpirationTime
Fiber
切分任务并调用 requestIdleCallback
和 requestAnimationFrame
,保证渲染任务和其他任务,在不影响应用交互,不掉帧的前提下,稳定执行,而实现调度的方式正是给每一个 Fiber
实例设置到期执行时间,不同时间即代表不同优先级,到期时间越短,则代表优先级越高,需要尽早执行
所谓的到期时间(
ExpirationTime
),是相对于调度器初始调用的起始时间而言的一个时间段,调度器初始调用后的某一段时间内,需要调度完成这项更新,这个时间段长度值就是到期时间值
PriorityLevel
在 React 15.x
版本中就已经出现了对于任务的优先层级划分
1 | export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5 |
相对于 PriorityLevel
的简单层级划分,在 React 16.x
版本中使用的则是 ExpirationTime
的到期时间方式表示任务的优先级,可以更好的对任务进行切分,调度
调度器(Scheduler)
我们在之前介绍的协调器的主要作用就是在组件状态变更时,调用组件树各组件的 render
方法,渲染,卸载组件,而 Fiber
使得应用可以更好的协调不同任务的执行,协调器内关于高效协调的实现,我们可以称它为调度器(Scheduler
)
顾名思义,调度器即调度资源以执行指定任务,React
应用中应用组件的更新与渲染,需要占用系统 CPU
资源,如果不能很好的进行资源平衡,合理调度,优化任务执行策略,那很容易造成 CPU
这一紧缺资源的消耗和浪费,容易造成页面卡顿,动画掉帧,组件更新异常等诸多问题
在 React 15.x
版本中,组件的状态变更将直接导致其子组件树的重新渲染,新版本 Fiber
算法将在调度器方面进行全面改进,主要的关注点是
- 合并多次更新,没有必要在组件的每一个状态变更时都立即触发更新任务,有些中间状态变更其实是对更新任务所耗费资源的浪费
- 任务优先级,不同类型的更新有不同优先级,例如用户操作引起的交互动画可能需要有更好的体验,其优先级应该比完成数据更新高
- 推拉式调度,基于推送的调度方式更多的需要开发者编码间接决定如何调度任务,而拉取式调度更方便
React
框架层直接进行全局自主调度
下面我们就来简单的了解一下调度器与优先级与任务调度相关内容
- 调度器与优先级
在 React
的协调算法中,任务由 Fiber
实例描述,所以要划分任务优先级,等效于设置 Fiber
的到期时间(expirationTime
),调度器内提供了 computeExpirationForFiber
方法以计算某一个 Fiber
的到期时间
1 | function computeExpirationForFiber(fiber) { |
- 任务调度
React
应用更新时,Fiber
从当前处理节点,层层遍历至组件树根组件,然后开始处理更新,调用前面的 requestIdleCallback
等 API
执行更新处理,主要调度逻辑是通过 scheduleWork
来实现的
- 通过
fiber.return
属性,从当前Fiber
实例层层遍历至组件树根组件 - 依次对每一个
Fiber
实例进行到期时间判断,若大于传入的期望任务到期时间参数,则将其更新为传入的任务到期时间 - 调用
requestWork
方法开始处理任务,并传入获取的组件树根组件FiberRoot
对象和任务到期时间
1 | // expirationTime 为期望的任务到期时间 |
处理任务的 requestWork
方法实现如下
- 首先比较任务剩余到期时间和期望的任务到期时间,若大于,则更新值
- 判断任务期望到期时间(
expirationTime
),区分同步或异步执行任务
1 | // 当根节点发生更新时,调度器将调用 requestWork 方法开始任务处理过程 |
更新队列(UpdateQueue)
我们知道如果需要实现组件的异步更新,肯定需要在更新前将更新任务进行存储,然后异步任务开始的时候读取更新并实现组件更新,存储更新任务就需要一个数据结构,最常见的就是栈和队列,Fiber
的实现方式就是队列
Fiber
切分任务为多个任务单元(Work Unit
)后,需要划分优先级然后存储在更新队列,随后按优先级进行调度执行,我们知道每一个组件都对应有一个 Fiber
实例,Fiber
实例即负责管理调度组件的任务单元,所以需要为每一个组件 Fiber
实例维护一个更新队列,Fiber
更新队列由 ReactFiberUpdateQueue
模块实现,主要涉及
- 创建更新队列
- 添加更新至更新队列
- 添加更新至
Fiber
(即Fiber
实例对应的更新队列) - 处理更新队列中的更新并返回新状态对象
这里我们就简单的了解一下它的数据结构,具体的更新过程就不详细展开了
1 | // 一个更新对应的数据结构 |
更新器(Updater)
调度器协调,调度的任务主要就是执行组件或组件树更新,而这些任务则具体由更新器(Updater
)完成,可以说调度器是在整个应用组件树层面掌控全局,而更新器则深入到个更具体的每一个组件内部执行,每一个组件实例化时都会被注入一个更新器,负责协调组件与 React
核心进程的通信,其职责主要可以概括为以下几点
- 找到组件实例对应的
Fiber
实例 - 询问调度器当前组件
Fiber
实例的优先级 - 将更新推入
Fiber
的更新队列 - 根据优先级调度更新任务
其主要实现以下几个功能
- 初始化组件实例并为其设置
fibre
实例和更新器 - 初始化或更新组件实例,根据更新队列计算得到新状态等
- 调用组件实例生命周期方法,并且调用更新器
API
更新Fiber
实例等
主要流程有下面几个
- 获取
Fiber
实例 - 获取优先级,
Fiber
实例的优先级是由调度器控制,所以需要询问调度器关于当前Fiber
实例的优先级 - 将更新任务添加至更新队列,组件状态变更时,将对应的组件更新任务划分优先级并根据优先级从高到低依次推入
Fiber
实例的更新队列 - 调度更新任务
渲染阶段与提交阶段
React
在两个主要阶段执行工作,它们是 render
和 commit
在第一个 render
阶段,React
通过 setUpdate
或 React.render
计划性的更新组件,并确定需要在 UI
中更新的内容,如果是初始渲染,React
会为 render
方法返回的每个元素创建一个新的 Fiber
节点,在后续更新中,现有 React
元素的 Fiber
节点将被重复使用和更新,这一阶段是为了得到标记了副作用的 Fiber
节点树,副作用描述了在下一个 commit
阶段需要完成的工作,在当前阶段,React
持有标记了副作用的 Fiber
树并将其应用于实例,它遍历副作用列表、执行 DOM
更新和用户可见的其他更改
这里需要我们注意的是,『第一个 render
阶段的工作是可以异步执行的』,即
React
可以根据可用时间片来处理一个或多个Fiber
节点,然后停下来暂存已完成的工作,并转而去处理某些事件,接着它再从它停止的地方继续执行,但有时候,它可能需要丢弃完成的工作并再次从顶部开始,由于在此阶段执行的工作不会导致任何用户可见的更改(如DOM
更新),因此暂停行为才有了意义,与之相反的是,后续commit
阶段始终是同步的,这是因为在此阶段执行的工作会导致用户可见的变化,例如DOM
更新,这就是为什么React
需要在一次单一过程中完成这些更新
其实简单来说,渲染阶段可以认为是协调阶段,这个阶段可以被中断,处于这个阶段的时候 React
会找出所有节点变更,目的是得到标记了副作用的 Fiber
节点树,在这个过程当中以下生命周期钩子会在渲染阶段被调用
constructor
UNSAFE_componentWillMount
(弃用)UNSAFE_componentWillReceiveProps
(弃用)getDerivedStateFromProps
shouldComponentUpdate
UNSAFE_componentWillUpdate
(弃用)render
因为 render
阶段不会产生像 DOM
更新这样的副作用,所以 React
可以异步处理组件的异步更新(甚至可能在多个线程中执行),也就是说在渲染阶段如果时间片用完,React
就会选择让出控制权,因为渲染阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题,但是因为渲染阶段可能被中断、恢复,甚至重做,React
渲染阶段的生命周期钩子可能会被调用多次,例如 componentWillMount
可能会被调用两次,所以建议渲染阶段的生命周期钩子不要包含副作用,索性 React
就废弃了这部分可能包含副作用的生命周期方法,例如 componentWillMount
、componentWillUpdate
等(这也是新版 React
的生命周期有所调整的原因)
下面我们再来看看提交阶段涉及到的一些生命周期方法,它会将上一个阶段计算出来的需要处理的副作用(Effect
)一次性执行了,这个阶段必须同步执行,不能被打断
getSnapshotBeforeUpdate()
(严格来说这个是在进入commit
阶段前调用)componentDidMount
componentDidUpdate
componentWillUnmount
现在我们应该知道为什么说在提交阶段必须同步执行,不能中断的吧?因为我们要正确地处理各种副作用,包括 DOM
变更、还有在 componentDidMount
中发起的异步请求、useEffect
中定义的副作用等,因为有副作用,所以必须保证按照次序只调用一次,况且会有用户可以察觉到的变更,不容差池
至于为什么要拆分两个阶段,可以参考 What is meant within the README of create-subscription by async limitations? Can it be clarified? 这篇文章
因为关于 Fiber
架构的相关内容我们在上面已经简单介绍过了,所以下面我们就站在整体的角度上简单的回顾一下整个过程到底是什么样子的
渲染阶段
协调算法始终使用 renderRoot
函数从最顶层的 HostRoot
节点开始,不过 React
会略过已经处理过的 Fiber
节点,直到找到未完成工作的节点,例如如果在组件树中的深层组件中调用 setState
方法,则 React
将从顶部开始,但会快速跳过各个父项,直到它到达调用了 setState
方法的组件
而且在第一次渲染之后,React
会得到一个 Fiber
树,这棵树是在 Virtual DOM
树的基础上增加额外的信息来生成的,它本质来说是一个链表,它反映了用于渲染 UI
的应用程序的状态,这棵树通常被称为 current
树(当前树)
当 React
开始处理更新时,它会构建一个所谓的 workInProgress
树(工作过程树),它反映了要刷新到屏幕的未来状态,这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行,如果没有,则继续构建树的过程
workInProgress
树构建这种技术类似于图形化领域的双缓存(Double Buffering
)技术,图形绘制引擎一般会使用双缓冲技术,先将图片绘制到一个缓冲区,再一次性传递给屏幕进行显示,这样可以防止屏幕抖动,优化渲染性能,放到React
中,workInProgress
树就是一个缓冲,它在协调完毕后一次性提交给浏览器进行渲染,它可以减少内存分配和垃圾回收,workInProgress
的节点不完全是新的,比如某颗子树不需要变动,React
会克隆复用旧树中的子树,另外一个重要的场景就是异常的处理,比如当一个节点抛出异常,仍然可以继续沿用旧树的节点,避免整棵树挂掉
其中所有的 Fiber
节点都会在工作循环中进行处理,其中 workLoopSync()
是 React
开始构建树的地方,源码可见 workLoopSync
1 | function workLoopSync() { |
当 React
遍历 current
树时,对于每个现有 Fiber
节点,React
会创建一个构成 workInProgress
树的备用节点,这一节点会使用 render
方法返回的 React
元素中的数据来创建,处理完更新并完成所有相关工作后,React
将准备好一个备用树以刷新到屏幕,一旦这个 workInProgress
树在屏幕上呈现,它就会变成 current
树,在处理完当前 Fiber
后,变量将持有树中下一个 Fiber
节点的引用或 null
,在这种情况下,React
退出工作循环并准备好提交更改
其中遍历树、初始化或完成工作主要用到下面四个函数
performUnitOfWork()
beginWork()
completeUnitOfWork()
completeWork()
我们首先来看看 performUnitOfWork
这个函数
1 | function performUnitOfWork(unitOfWork: Fiber): void { |
函数 performUnitOfWork
接收一个 Fiber
节点,并通过调用 beginWork
函数启动工作,这个函数将启动所有 Fiber
执行工作所需要的活动,函数 beginWork
的作用主要是从来节点比对,它始终返回指向要在循环中处理的下一个子节点的指针或 null
,如果没有子节点,React
知道它到达了分支的末尾,因此它可以完成当前节点,『一旦节点完成,它将需要为同层的其他节点执行工作,并在完成后回溯到父节点』
下面来看看 completeUnitOfWork
函数执行的代码
1 | function completeUnitOfWork(unitOfWork: Fiber): void { |
我们可以看到函数的核心就是一个大的 do-while
的循环,当 workInProgress
节点没有子节点时,React
会进入此函数,完成当前 Fiber
节点的工作后,它就会检查是否有同层节点,如果找的到,React
退出该函数并返回指向该同层节点的指针
这里我们需要注意的是,在当前节点上,React
只完成了前面的同层节点的工作,它尚未完成父节点的工作,只有在完成以子节点开始的所有分支后,才能完成父节点和回溯的工作
从实现中也可以看出 performUnitOfWork
和 completeUnitOfWork
主要用于迭代目的,而主要活动则在 beginWork 和 completeWork 函数中进行,也如我们所见,这四个函数一起执行工作单元的工作,并且还控制当前正在完成的工作,也就是下图当中所示这般
提交阶段
渲染阶段完成后,React
进入提交阶段,这一阶段从函数 completeRoot
开始,在这个阶段 React
更新 DOM
并调用变更生命周期之前及之后方法的地方,当 React
进入这个阶段时,它有两棵树和副作用列表,第一个树表示当前在屏幕上渲染的状态,然后在 render
阶段会构建一个备用树,它在源代码中称为 finishedWork
或 workInProgress
,表示需要映射到屏幕上的状态,此备用树会用类似的方法通过 child
和 sibling
指针链接到 current
树
然后在构造 Fiber
树的过程中,Fiber Reconciler
会将需要更新的节点信息都保存在 Effect List
当中,也就是所谓的副作用列表,它是 finishedWork
树的节点子集,通过 nextEffect
指针进行链接,需要注意的是,副作用列表是运行 render
阶段的『结果』,渲染的重点就是确定需要插入、更新或删除的节点,以及哪些组件需要调用其生命周期方法,这就是副作用列表告诉我们的内容,它页正是在 commit
阶段迭代的节点集合
在 commit
阶段运行的主要函数是 commitRoot
,它执行如下下操作
- 在标记为
Snapshot
副作用的节点上调用getSnapshotBeforeUpdate
生命周期 - 在标记为
Deletion
副作用的节点上调用componentWillUnmount
生命周期 - 执行所有
DOM
插入、更新、删除操作 - 将
finishedWork
树设置为current
- 在标记为
Placement
副作用的节点上调用componentDidMount
生命周期 - 在标记为
Update
副作用的节点上调用componentDidUpdate
生命周期
在调用变更前方法 getSnapshotBeforeUpdate
之后,React
会在树中提交所有副作用,这会通过两波操作来完成
- 第一波执行所有
DOM
(宿主)插入、更新、删除和ref
卸载,然后React
将finishedWork
树赋值给FiberRoot
,将workInProgress
树标记为current
树,这是在提交阶段的第一波之后、第二波之前完成的,因此在componentWillUnmount
中前一个树仍然是current
,在componentDidMount/Update
期间已完成工作是current
- 第二波,
React
调用所有其他生命周期方法和引用回调,这些方法单独传递执行,从而保证整个树中的所有放置、更新和删除能够被触发执行
这里借用 Lin Clark
演讲当中的一张图(见 React Fiber),我们可以清晰的发现这种变化
上图是协调完成后的状态,左边是旧树,右边是 WIP
树,对于需要变更的节点,都打上了标签,在提交阶段 React
就会将这些打上标签的节点应用变更
以上关于 React Fiber
的相关内容我们就介绍到这里,在接下来的 Fiber 架构的简单实现 的章节当中我们会继续深入理解其原理,并且尝试手动的实现一个简易版本的 Fiber