React Fiber 起源
React Fiber 这个概念最早于 2016 提出,2018 年随 React 16 大版本发布了,为了实现该架构,React 团队花了两年多的时间。所以,其实在 React 16 版本以后,就已经带上了 Fiber 架构,这也就是为什么 React 16 以后,有些生命周期就变得 UN_SAFE了。
问题背景
在了解 Fiber 是什么之前,我们先来了解下,为什么会出现 Fiber。
React 是一个用于构建用户界面的 JavaScript 库,在用户体验已然成为影响产品存亡的重要因素的当前,势必要追求极致的用户体验。而一个良好的用户体验的基础就是快速响应用户操作,影响快速响应的因素可以归纳为两类:
- IO 瓶颈
- CPU瓶颈
IO 瓶颈
主要体现在网络延迟,React 给出的方案是将人机交互研究的结果整合到真实的 UI 中。
CPU瓶颈
机器也有它运算的极限,在大量复杂的计算面前,CUP 也是有心无力的。而 React 采用同步递归的方式构建虚拟 DOM。如果组件很复杂,层级很深,那构建虚拟 DOM 的过程就需要花费大量的时间,就会造成页面卡顿无法响应,这就出现了 CPU 瓶颈。
主流浏览器刷新频率为60Hz,16.6ms就会刷新一次,也就是一帧,而在一帧中,需要完成的工作有:
在构建虚拟 DOM 这一步,React 一直在优化改善,比如优化 diff 算法等。然而就算是 O(n) 的算法复杂度,在大量数据面前,耗时也是非常大的。
面对这种情况, React 如何破局?
性能优化是一个系统性的工程,如果只看到局部,引入算法,当然是越快越好; 但从整体来看,在关键点引入缓存,可以秒杀N多算法,或另辟蹊径,探索事件的本质,可能用户要的并不是简单的”快”…
React 给出了答案,用户需要的更多是交互的快速响应而并不是快,对于一些页面元素的呈现速度,慢一点是可以接受的。而对于一些人机交互,应该是及时响应的。
为了改善 CUP 瓶颈,快速响应用户体验。React 引入了一些策略:
- 每一帧执行脚本时间为5ms(可变)。
- 为各种更新事件赋予优先级
React 15 及之前构建虚拟 DOM 的过程是基于栈结构递归实现的,显然是很难实现(需要记录大量的上下文信息)。
面对这种困局,Fiber 应运而生,React 对构建虚拟 DOM 的整个过程进行重新实现,让其支持在构建过程可以中断,去处理优先级更高的用户交互,也就是可中断更新。将实现这部分核心功能的架构称之为 Fiber。
React Fiber 架构就是对 React 核心协调(reconciliation)算法的重新实现,以让 React 支持可中断异步更新。由于该阶段主要工作是构建虚拟 DOM,有人通俗认为它就是 React 16 以后的虚拟DOM。
React Fiber 架构概览
在 React Fiber 之后,之前所谓的虚拟DOM 树 ,就有了新的名字: Fiber 树。
React Fiber 架构可以分为两部分:
- Render(reconciliation) 阶段 —— 处理更新,构建 Fiber 树(可以理解为虚拟DOM)。该步骤是可以异步的。
- Commit 阶段 —— 操作实际DOM,负责把 Fiber 树渲染到页面上,该步骤只能是同步处理。
在 Render 阶段,Fiber 会从 React 根组件(ReactDom.render())开始,递归遍历全部组件,构建对应的 Fiber 树,并标记哪些节点需要更新,通过链表将它们连接起来。在 Commit 阶段,就会执行这些更新操作,不需要在遍历全部 Fiber 树了。
另外,其他一些 Effect,比如生命周期函数等,也都保存在 Fiber 节点中,在合适时机就会执行。
Fiber 树构建过程
对于网页中的所有元素,React 会在内存中创建一个与之对应的元素对象树,之前被称之为虚拟 DOM,在 React Fiber 结构中被称之为 Fiber 树。
Fiber 节点
整个应用的 UI 使用了一课 Fiber 树来表示,每个元素都会被创建一个对应的 Fiber 节点,它们是通过 return、child、sibling 三个属性连接起来的。也就是链表数据结构。
function FiberNode(tag, pendingProps, key, mode) {
this.return = null; // 指向父节点
this.child = null; // 指向第一个子节点
this.sibling = null; // 执行右边第一个兄弟节点
.... // 其他属性
// 更新队列
this.updateQueue = null;
// 处理当前节点上的 Effect 比如生命周期函数等回调
this.nextEffect = null;
this.firstEffect = null;
// 调度优先级相关
this.lanes = NoLanes;
// 指向该fiber在另一次更新时对应的fiber
this.alternate = null;
}
复制代码
看个例子:
class ClickCounter extends React.Component {
render() {
return [
<button>加一</button>,
<span>0</span>
]
}
}
ReactDom.render(<ClickCounter />, document.getElementById('id'));
复制代码
Fiber 结构如下
双 Fiber 树
在 React 中,最多可能会存在两个 Fiber 树。
一个叫 current ,它跟当前屏幕上的UI相对应的。
还有一个叫 workInProgress,在状态发生更新时,基于下次屏幕渲染的状态内存构建的 Fiber 树。 这个树保存了下次更新屏幕UI所对应的状态。其实整个 render(协调) 阶段就是异步地创建 workInProgress 树,可以把它看做是一个草稿,等这个草稿完成了,就会同步地将其绘制到页面中。这时 workInProgress 树就变成了 current 树。Fiber 节点上有个属性叫 alternate,将两棵树关联起来。
通过 alternate 相互引用和角色切换,避免了每次更新都创建。
fiberRootNode 整个应用的根节点,每调用一次 ReactDom.render 都会创建一个 rootFiber 。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
复制代码
workInProgress 树构建过程
这里我们主要讲更新的构建过程,这里会涉及 diff 和 DOM 的复用。第一次渲染的话就是全部构建各个元素对于的 Fiber 节点并将它们连接起来
每次更新都是从 rootFiber 节点开始。
- 首先 workInProgress 指向 current.alternate。如果是第一次渲染,则会基于 current 创建一个新的rootFiber 节点。
function createWorkInProgress(current, pendingProps) {
var workInProgress = current.alternate;
if (workInProgress === null) {
workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
workInProgress.elementType = current.elementType;
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode;
}
// 其他逻辑
}
复制代码
- 从 rootFiber 节点开始遍历全部节点。
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
复制代码
遍历过程中会执行以下四个主要函数:
- performUnitOfWork: 处理当前节点,返回值是下一个需要处理的节点。如果有 child 为处理,则返回值为 child。如果 child 都是已处理(调用 completeUnitOfWork ),那么该节点就是 finshWord,返回值就是父节点,也就是 return 属性所指向的 Fiber 节点。
- beginWork: 真正处理对应的 React 元素,执行 render 方法或者函数组件得到当前的元素的 JSX 信息。然后判断和 current Fiber 树做 diff 算法。
- completeUnitOfWork: child 都是已处理,对该节点做收尾工作,并决定下个需要处理的节点,并返回。
- completeWork:核心收尾逻辑在这里,更加不同类型组件处理方式不同。completeUnitOfWork 里会调用该方法。
如何让浏览器持续保持响应
开启了异步更新,也就是 Concurrent Mode
由于 JS 是单线程的,如果 JS 在执行脚本,那么浏览器事件是无法响应的。所以,为了能够快速响应高优先级事件,不让浏览器假死,就必须在每帧的16.6.ms中预留一部分给浏览器。也就意味连续执行脚本时间不能超过16.6。React Fiber 将这个时间定为 5ms(动态的)。
在遍历组件构建 Fiber 过程中,每处理完一个节点,就会判断一下,是否还有剩余时间,如果没有了,则会交出控制权(setTimeout(0))。
源码实现如下:
var yieldInterval = 5;
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
复制代码
如何处理高优先级更新
Fiber 节点上有个 updateQueue 属性,React 每次触发更新都会生成一个 updater,然后加入该队列。
在 React Fiber 之前,这些 Updater 是没有优先级的,当执行完一个 Update 后,就会拿队列中第一个继续执行。
在 React Fiber 之后,React 对各类更新标记了不同的优先级,具体如下:
- 生命周期方法:同步执行。
- 受控的用户输入:比如输入框内输入文字,同步执行。
- 交互事件:比如动画,高优先级执行。
- 其他:比如数据请求,低优先级执行。
同步执行的是最高优先级的。
为了优先处理高优先级更新,标记了优先级还不够,还需要快。
前面提到,为了让浏览器保持持续响应,React 每次执行构建脚本只花费 5ms,就交出执行权。当 React 再次获取到执行权后,会在通过调度器(Schedule),获取最高优先级的 Updater 优先执行。
这里需要处理的问题有:如何保证状态依赖的连续性。
我们来看个例子:
baseState: 上一次渲染完后 state (当前页面)
baseUpdate: 由于优先级低被跳过的未执行的 Updater
memoizedState: 本次构建的 state(渲染后的state)
当前状态:(A1: 字母代表更新内容,数字代表优先级,越小优先级越高)
baseState: ''
shared.pending: A1 --> B2 --> C1 --> D2 // 待更新队列
复制代码
第一次 render,优先级为 1。
baseState: ''
baseUpdate: null
render阶段使用的Update: [A1, C1]
memoizedState: 'AC'
复制代码
其中 B2 由于优先级为 2,低于当前优先级,所以他及其后面的所有 update 会被保存在 baseUpdate 中作为下次更新的 update(即B2 C1 D2)。
第二次 render,优先级为 2。
baseState: 'A'
baseUpdate: B2 --> C1 --> D2
render阶段使用的Update: [B2, C1, D2]
memoizedState: 'ABCD'
复制代码
这里需要留意,baseState 是 ‘A’,而不是 ‘AC’。因为 B2 被跳过了。
如果存在 B2 被跳过,下次更新的 baseUpdate !== 上次更新的 memoizedState。
这样就实现了高优先级更新优先处理,同时确状态的一致性。
最后一个问题
Fiber 让 React 更快了吗?
Fiber 并没有让 React 变得更快了,反而是更慢了。之前一次更新同步执行可能需要 30ms,现在由于每5ms 就会退出执行,进行一系列判断,增加了额外的调度时间,总的执行时间肯定大于 30ms 了。
但是,Fiber 让 React 更丝滑了,不至于让浏览器假死,能快速响应用户交互。
也许,用户要得并不是快…