React技术揭秘——理念2
React16的新架构
React16架构可以分为三层:
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
可以看到,相较于React15,React16中新增了Scheduler(调度器),让我们来了解下他。
Scheduler(调度器)💇
既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
其实部分浏览器已经实现了这个API,这就是requestIdleCallback。但是由于以下因素,React
放弃使用:
- 浏览器兼容性
- 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的
requestIdleCallback
触发的频率会变得很低
基于以上原因,React
实现了功能更完备的requestIdleCallback
polyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。
Scheduler是独立于
React
的库
Reconciler(协调器)
我们知道,在React15中Reconciler是递归处理虚拟DOM的。让我们看看React16的Reconciler。
我们可以看见,更新工作从递归变成了可以中断的循环过程。每次循环都会调用shouldYield
判断当前是否有剩余时间。
1 |
|
那么React16是如何解决中断更新时DOM渲染不完全的问题呢?
在React16中,Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记,类似这样:
1 |
|
全部的标记见这里
整个Scheduler与Reconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。
你可以在这里看到
React
官方对React16新Reconciler的解释
Renderer(渲染器)
Renderer根据Reconciler为虚拟DOM打的标记,同步执行对应的DOM操作。
state.count = 1
,每次点击按钮state.count++
列表中3个元素的值分别为1,2,3乘以state.count
的结果
在React16架构中整个更新流程为:
其中红框中的步骤随时可能由于以下原因被中断:
- 有其他更高优任务需要先更新
- 当前帧没有剩余时间
由于红框中的工作都在内存中进行,不会更新页面上的DOM,所以即使反复中断,用户也不会看见更新不完全的DOM
接下来看看Fiber是什么?
他和Reconciler或者说和React之间是什么关系
fiber架构的心智模型
React核心团队成员Sebastian Markbåge(React Hooks的发明者)曾说:我们在React中做的就是践行代数效应(Algebraic Effects)。
那么,代数效应是什么呢?他和React有什么关系呢。
什么是代数效应
代数效应
是函数式编程
中的一个概念,用于将副作用
从函数
调用中分离。
接下来我们用虚构的语法
来解释。
假设我们有一个函数getTotalPicNum
,传入2个用户名称
后,分别查找该用户在平台保存的图片数量,最后将图片数量相加后返回。
1 |
|
在getTotalPicNum
中,先别关注getPicNum
的实现,只在乎“获取到两个数字后将他们相加的结果返回”这一过程。
接下来我们来实现getPicNum
。
"用户在平台保存的图片数量"是保存在服务器中的。所以,为了获取该值,我们需要发起异步请求。
为了尽量保持getTotalPicNum
的调用方式不变,我们首先想到了使用async await
:
1 |
|
但是,async await
是有传染性
的 —— 当一个函数变为async
后,这意味着调用他的函数也需要是async
,这破坏了getTotalPicNum
的同步特性。
有没有什么办法能保持getTotalPicNum
保持现有调用方式不变的情况下实现异步请求呢?
没有。不过我们可以虚构
一个。
我们虚构一个类似try...catch
的语法 —— try...handle
与两个操作符perform
、resume
。
1 |
|
当执行到getTotalPicNum
内部的getPicNum
方法时,会执行perform name
。
此时函数调用栈会从getPicNum
方法内跳出,被最近一个try...handle
捕获。类似throw Error
后被最近一个try...catch
捕获。
类似throw Error
后Error
会作为catch
的参数,perform name
后name
会作为handle
的参数。
与try...catch
最大的不同在于:当Error
被catch
捕获后,之前的调用栈就销毁了。而handle
执行resume
后会回到之前perform
的调用栈。
对于case 'kaSong'
,执行完resume with 230;
后调用栈会回到getPicNum
,此时picNum === 230
注意⚠️
再次申明,
try...handle
的语法是虚构的,看看代数效应
的思想。
总结一下:代数效应
能够将副作用
(例子中为请求图片数量
)从函数逻辑中分离,使函数关注点保持纯粹。
并且,从例子中可以看出,perform resume
不需要区分同步异步。
代数效应在React中的应用
那么代数效应
与React
有什么关系呢?最明显的例子就是Hooks
。
对于类似useState
、useReducer
、useRef
这样的Hook
,我们不需要关注FunctionComponent
的state
在Hook
中是如何保存的,React
会为我们处理。
我们只需要假设useState
返回的是我们想要的state
,并编写业务逻辑就行。
1 |
|
代数效应与Generator
从React15
到React16
,协调器(Reconciler
)重构的一大目的是:将老的同步更新
的架构变为异步可中断更新
。
异步可中断更新
可以理解为:更新
在执行过程中可能会被打断(浏览器时间分片用尽或有更高优任务插队),当可以继续执行时恢复之前执行的中间状态。
这就是代数效应
中try...handle
的作用。
其实,浏览器原生就支持类似的实现,这就是Generator
。
但是Generator
的一些缺陷使React
团队放弃了他:
- 类似
async
,Generator
也是传染性
的,使用了Generator
则上下文的其他函数也需要作出改变。这样心智负担比较重。 Generator
执行的中间状态
是上下文关联的。
看看下面的🌰
1 |
|
每当浏览器有空闲时间都会依次执行其中一个doExpensiveWork
,当时间用尽则会中断,当再次恢复时会从中断位置继续执行。
只考虑“单一优先级任务的中断与继续”情况下Generator
可以很好的实现异步可中断更新
。
但是当我们考虑“高优先级任务插队”的情况,如果此时已经完成doExpensiveWorkA
与doExpensiveWorkB
计算出x
与y
。
此时B
组件接收到一个高优更新
,由于Generator
执行的中间状态
是上下文关联的,所以计算y
时无法复用之前已经计算出的x
,需要重新计算。
如果通过全局变量
保存之前执行的中间状态
,又会引入新的复杂度。
基于这些原因,React
没有采用Generator
实现协调器
。
代数效应与Fiber
Fiber
并不是计算机术语中的新名词,他的中文翻译叫做纤程
,与进程(Process)、线程(Thread)、协程(Coroutine)同为程序执行过程。
在很多文章中将纤程
理解为协程
的一种实现。在JS
中,协程
的实现便是Generator
。
所以,我们可以将纤程
(Fiber)、协程
(Generator)理解为代数效应
思想在JS
中的体现。
React Fiber
可以理解为:
React
内部实现的一套状态更新机制。支持任务不同优先级
,可中断与恢复,并且恢复后可以复用之前的中间状态
。
其中每个任务更新单元为React Element
对应的Fiber节点
。
接下来,康康Fiber架构
的实现
Fiber的起源
最早的
Fiber
官方解释来源于2016年React团队成员Acdlite的一篇介绍。
在React15
及以前,Reconciler
采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。
为了解决这个问题,React16
将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber
架构应运而生。
Fiber的含义
Fiber
包含三层含义:
- 作为架构来说,之前
React15
的Reconciler
采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack Reconciler
。React16
的Reconciler
基于Fiber节点
实现,被称为Fiber Reconciler
。 - 作为静态的数据结构来说,每个
Fiber节点
对应一个React element
,保存了该组件的类型(函数组件/类组件/原生组件…)、对应的DOM节点等信息。 - 作为动态的工作单元来说,每个
Fiber节点
保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新…)。
Fiber的结构
1 |
|
作为架构来说
每个Fiber节点有个对应的React element
,多个Fiber节点
是怎么连接形成树呢?用下面三个属性:
1 |
|
举个例子,如下的组件结构:
1 |
|
这里需要提一下,为什么父级指针叫做
return
而不是parent
或者father
呢?因为作为一个工作单元,return
指节点执行完completeWork
(本章后面会介绍)后会返回的下一个节点。子Fiber节点
及其兄弟节点完成工作后会返回其父级节点,所以用return
指代父级节点。
作为静态的数据结构
作为一种静态的数据结构,保存了组件相关的信息:
1 |
|
作为动态的工作单元
作为动态的工作单元,Fiber
中如下参数保存了本次更新相关的信息,我们会在后续的更新流程中使用到具体属性时再详细介绍
1 |
|
如下两个字段保存调度优先级相关的信息,会在讲解Scheduler
时介绍。
1 |
|
注意
在2020年5月,调度优先级策略经历了比较大的重构。以expirationTime
属性为代表的优先级模型被lane
取代。可以看看这个PR
那么Fiber树
和页面呈现的DOM树
有什么关系,React
又是如何更新DOM
的呢?
且听下回分解! (写不动了)🥱
复活! 补上
我们现在知道了Fiber是什么,知道Fiber节点可以保存对应的DOM节点。
相应的,Fiber节点构成的Fiber树就对应DOM树。
那么如何更新DOM呢?这需要用到被称为“双缓存”的技术。