Fiber架构的由来
# Fiber架构的由来
# React理念
React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。
可见,关键是实现快速响应
。那么制约快速响应
的因素是什么呢?
我们日常使用App,浏览网页时,有两类场景会制约快速响应
:
- 当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。
- 发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。
这两类场景可以概括为:
- CPU的瓶颈
- IO的瓶颈
React
是如何解决这两个瓶颈的呢?
# CPU的瓶颈
当项目变得庞大、组件数量繁多时,就容易遇到CPU的瓶颈。
考虑如下Demo,我们向视图中渲染3000个li
:
function App() {
const len = 3000;
return (
<ul>
{Array(len).fill(0).map((_, i) => <li>{i}</li>)}
</ul>
);
}
const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl);
主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。
我们知道,JS可以操作DOM,GUI渲染线程
与JS线程
是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。
在每16.6ms时间内,需要完成如下工作:
JS脚本执行 ----- 样式布局 ----- 样式绘制
当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。
在Demo中,由于组件数量繁多(3000个),JS脚本执行时间过长,页面掉帧,造成卡顿。
# React15架构的缺点
在Reconciler中,mount
的组件会调用mountComponent (opens new window) (opens new window),update
的组件会调用updateComponent (opens new window) (opens new window)。这两个方法都会递归更新子组件。
# 递归更新的缺点
由于递归执行,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。
在上一节中,我们已经提出了解决办法——用可中断的异步更新代替同步的更新。那么React15的架构支持异步更新么?
# React16架构
React16架构可以分为三层:
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
可以看到,相较于React15,React16中新增了Scheduler(调度器),让我们来了解下他。
# Scheduler(调度器)
既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
其实部分浏览器已经实现了这个API,这就是requestIdleCallback (opens new window) (opens new window)。但是由于以下因素,React
放弃使用:
- 浏览器兼容性
- 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的
requestIdleCallback
触发的频率会变得很低
基于以上原因,React
实现了功能更完备的requestIdleCallback
polyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。
# Reconciler(协调器)
我们知道,在React15中Reconciler是递归处理虚拟DOM的。让我们看看React16的Reconciler (opens new window) (opens new window)。
我们可以看见,更新工作从递归变成了可以中断的循环过程。每次循环都会调用shouldYield
判断当前是否有剩余时间。
在React16架构中整个更新流程为:
其中红框中的步骤随时可能由于以下原因被中断:
- 有其他更高优任务需要先更新
- 当前帧没有剩余时间
由于红框中的工作都在内存中进行,不会更新页面上的DOM,所以即使反复中断,用户也不会看见更新不完全的DOM(即上一节演示的情况)。
实际上,由于Scheduler和Reconciler都是平台无关的,所以
React
为他们单独发了一个包react-Reconciler (opens new window) (opens new window)。你可以用这个包自己实现一个ReactDOM
,具体见参考资料
# 总结
通过本节我们知道了React16
采用新的Reconciler
。
Reconciler
内部采用了Fiber
的架构。
# 代数效应与Generator
从React15
到React16
,协调器(Reconciler
)重构的一大目的是:将老的同步更新
的架构变为异步可中断更新
。
异步可中断更新
可以理解为:更新
在执行过程中可能会被打断(浏览器时间分片用尽或有更高优任务插队),当可以继续执行时恢复之前执行的中间状态。
这就是代数效应
中try...handle
的作用。
其实,浏览器原生就支持类似的实现,这就是Generator
。
但是Generator
的一些缺陷使React
团队放弃了他:
- 类似
async
,Generator
也是传染性
的,使用了Generator
则上下文的其他函数也需要作出改变。这样心智负担比较重。 Generator
执行的中间状态
是上下文关联的。
考虑如下例子:
function* doWork(A, B, C) {
var x = doExpensiveWorkA(A);
yield;
var y = x + doExpensiveWorkB(B);
yield;
var z = y + doExpensiveWorkC(C);
return z;
}
每当浏览器有空闲时间都会依次执行其中一个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节点
。
在React15
及以前,Reconciler
采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。
为了解决这个问题,React16
将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber
架构应运而生。
# Fiber的含义
Fiber
包含三层含义:
- 作为架构来说,之前
React15
的Reconciler
采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack Reconciler
。React16
的Reconciler
基于Fiber节点
实现,被称为Fiber Reconciler
。 - 作为静态的数据结构来说,每个
Fiber节点
对应一个React element
,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的DOM节点等信息。 - 作为动态的工作单元来说,每个
Fiber节点
保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)。
# Fiber的结构
你可以从这里看到Fiber节点的属性定义 (opens new window) (opens new window)。虽然属性很多,但我们可以按三层含义将他们分类来看
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// 作为静态数据结构的属性
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
// 用于连接其他Fiber节点形成Fiber树
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
// 作为动态的工作单元的属性
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 指向该fiber在另一次更新时对应的fiber
this.alternate = null;
}
# 作为架构来说
每个Fiber节点有个对应的React element
,多个Fiber节点
是如何连接形成树呢?靠如下三个属性:
// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;
# Fiber架构的工作原理
通过上一节的学习,我们了解了Fiber
是什么,知道Fiber节点
可以保存对应的DOM节点
。
相应的,Fiber节点
构成的Fiber树
就对应DOM树
。
那么如何更新DOM
呢?这需要用到被称为“双缓存”的技术。
# 什么是“双缓存”
当我们用canvas
绘制动画,每一帧绘制前都会调用ctx.clearRect
清除上一帧的画面。
如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。
为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。
这种在内存中构建并直接替换的技术叫做双缓存 (opens new window) (opens new window)。
React
使用“双缓存”来完成Fiber树
的构建与替换——对应着DOM树
的创建与更新。
# 双缓存Fiber树
在React
中最多会同时存在两棵Fiber树
。当前屏幕上显示内容对应的Fiber树
称为current Fiber树
,正在内存中构建的Fiber树
称为workInProgress Fiber树
。
current Fiber树
中的Fiber节点
被称为current fiber
,workInProgress Fiber树
中的Fiber节点
被称为workInProgress fiber
,他们通过alternate
属性连接。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
React
应用的根节点通过使current
指针在不同Fiber树
的rootFiber
间切换来完成current Fiber
树指向的切换。
即当workInProgress Fiber树
构建完成交给Renderer
渲染在页面上后,应用根节点的current
指针指向workInProgress Fiber树
,此时workInProgress Fiber树
就变为current Fiber树
。
每次状态更新都会产生新的workInProgress Fiber树
,通过current
与workInProgress
的替换,完成DOM
更新。
接下来我们以具体例子讲解mount时
、update时
的构建/替换流程。
# mount时
考虑如下例子:
function App() {
const [num, add] = useState(0);
return (
<p onClick={() => add(num + 1)}>{num}</p>
)
}
ReactDOM.render(<App/>, document.getElementById('root'));
- 首次执行
ReactDOM.render
会创建fiberRootNode
(源码中叫fiberRoot
)和rootFiber
。其中fiberRootNode
是整个应用的根节点,rootFiber
是<App/>
所在组件树的根节点。
之所以要区分fiberRootNode
与rootFiber
,是因为在应用中我们可以多次调用ReactDOM.render
渲染不同的组件树,他们会拥有不同的rootFiber
。但是整个应用的根节点只有一个,那就是fiberRootNode
。
fiberRootNode
的current
会指向当前页面上已渲染内容对应Fiber树
,即current Fiber树
。
fiberRootNode.current = rootFiber;
由于是首屏渲染,页面中还没有挂载任何DOM
,所以fiberRootNode.current
指向的rootFiber
没有任何子Fiber节点
(即current Fiber树
为空)。
- 接下来进入
render阶段
,根据组件返回的JSX
在内存中依次创建Fiber节点
并连接在一起构建Fiber树
,被称为workInProgress Fiber树
。(下图中右侧为内存中构建的树,左侧为页面显示的树)
在构建workInProgress Fiber树
时会尝试复用current Fiber树
中已有的Fiber节点
内的属性,在首屏渲染
时只有rootFiber
存在对应的current fiber
(即rootFiber.alternate
)。
- 图中右侧已构建完的
workInProgress Fiber树
在commit阶段
渲染到页面。
此时DOM
更新为右侧树对应的样子。fiberRootNode
的current
指针指向workInProgress Fiber树
使其变为current Fiber 树
。
# update时
- 接下来我们点击
p节点
触发状态改变,这会开启一次新的render阶段
并构建一棵新的workInProgress Fiber 树
。
和mount
时一样,workInProgress fiber
的创建可以复用current Fiber树
对应的节点数据。
这个决定是否复用的过程就是Diff算法,后面章节会详细讲解
workInProgress Fiber 树
在render阶段
完成构建后进入commit阶段
渲染到页面上。渲染完毕后,workInProgress Fiber 树
变为current Fiber 树
。
# 总结
本文介绍了Fiber树
的构建与替换过程,这个过程伴随着DOM
的更新。
那么在构建过程中每个Fiber节点
具体是如何创建的呢?我们会在架构篇
的render阶段讲解。
通过本章的学习,我们了解了React
的Scheduler-Reconciler-Renderer
架构体系,在结束本章前,我想介绍几个源码内的术语:
Reconciler
工作的阶段被称为render
阶段。因为在该阶段会调用组件的render
方法。Renderer
工作的阶段被称为commit
阶段。就像你完成一个需求的编码后执行git commit
提交代码。commit
阶段会把render
阶段提交的信息渲染在页面上。render
与commit
阶段统称为work
,即React
在工作中。相对应的,如果任务正在Scheduler
内调度,就不属于work
。
在架构篇
我们会分别讲解Reconciler
和Renderer
的工作流程,所以章节名分别为render阶段
和commit阶段
。