Fiber背景
同步更新过程的局限
在现有React中,更新过程是同步的,这可能会导致性能问题。
当React决定要加载或者更新组件树时,会做很多事,比如调用各个组件的生命周期函数,计算和比对Virtual DOM,最后更新DOM树,这整个过程是同步进行的,也就是说只要一个加载或者更新过程开始,那React就以不破楼兰终不还的气概,一鼓作气运行到底,中途绝不停歇。
表面上看,这样的设计也是挺合理的,因为更新过程不会有任何I/O操作嘛,完全是CPU计算,所以无需异步操作,的确只要一路狂奔就行了,但是,当组件树比较庞大的时候,问题就来了。 假如更新一个组件需要1毫秒,如果有200个组件要更新,那就需要200毫秒,在这200毫秒的更新过程中,浏览器那个唯一的主线程都在专心运行更新操作,无暇去做任何其他的事情。想象一下,在这200毫秒内,用户往一个input元素中输入点什么,敲击键盘也不会获得响应,因为渲染输入按键结果也是浏览器主线程的工作,但是浏览器主线程被React占着呢,抽不出空,最后的结果就是用户敲了按键看不到反应,等React更新过程结束之后,咔咔咔那些按键一下子出现在input元素里了。 这就是所谓的界面卡顿,很不好的用户体验。
简单一点来说就是,react更新过程,如果更新几百个组件,这种遍历是递归调用,执行栈会越来越深。而且 不能中断,因为中断后再想恢复 就非常难了,遍历很深的话,因为JavaScript单线程的特点,每个同步任务不能耗时太长,不然就会让程序不会对其他输入作出相应,React的更新过程就是犯了这个禁忌,而React Fiber就是要改变现状。
而fiber实现原理是:把整个任务分解成很多小任务,每次执行一个任务后看一下有没有剩余时间,如果有,继续下一个任务,如果没有时间,则放弃执行,交给浏览器进行调度
为什么需要Fiber
因此,为了解决以上的痛点问题,React希望能够彻底解决主线程长时间占用问题,于是引入了 Fiber 来改变这种不可控的现状,把渲染/更新过程拆分为一个个小块的任务,通过合理的调度机制来调控时间,指定任务执行的时机,从而降低页面卡顿的概率,提升页面交互体验。通过Fiber架构,让reconcilation过程变得可被中断。适时地让出CPU执行权,可以让浏览器及时地响应用户的交互。
概念
Fiber 可以理解为是一种数据结构,也可以理解为是一个执行单元。
一种数据结构
Fiber 可以理解为是一种数据结构,节点数据结构如下:
Fiber = {
// 标识 fiber 类型的标签,详情参看下述 WorkTag
tag: WorkTag,
// 指向父节点
return: Fiber | null,
// 指向子节点
child: Fiber | null,
// 指向兄弟节点
sibling: Fiber | null,
// 在开始执行时设置 props 值
pendingProps: any,
// 在结束时设置的 props 值
memoizedProps: any,
// 当前 state
memoizedState: any,
// Effect 类型,详情查看以下 effectTag
effectTag: SideEffectTag,
// effect 节点指针,指向下一个 effect
nextEffect: Fiber | null,
// effect list 是单向链表,第一个 effect
firstEffect: Fiber | null,
// effect list 是单向链表,最后一个 effect
lastEffect: Fiber | null,
// work 的过期时间,可用于标识一个 work 优先级顺序
expirationTime: ExpirationTime,
};
通俗易懂的说,所有的element都是一个独立的fiber,element的同级元素用sibling链接,子元素用child链接,这样就由上至下形成了一个fiber tree。
React Fiber 就是采用链表实现的,每个Fiber保存了节点处理的上下文信息,因为是手动实现的,所以更为可控,我们可以保存在内存中,随时中断和恢复。每个 Virtual DOM 都可以表示为一个 fiber,如下图所示,每个节点都是一个 fiber。一个 fiber包括了 child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性,React Fiber 机制的实现,就是依赖于以下的数据结构。
Fiber链表结构设计
Fiber结构是使用链表实现的,Fiber tree实际上是个单链表树结构,详见ReactFiber.js源码,在这里我们看看Fiber的链表结构是怎样的,了解了这个链表结构后,能更快地理解后续 Fiber 的遍历过程。
以上每一个单元包含了payload(数据)和nextUpdate(指向下一个单元的指针),定义结构如下:
class Update {
constructor(payload, nextUpdate) {
this.payload = payload // payload 数据
this.nextUpdate = nextUpdate // 指向下一个节点的指针
}
}
复制代码
接下来定义一个队列,把每个单元串联起来,其中定义了两个指针:头指针firstUpdate和尾指针lastUpdate,作用是指向第一个单元和最后一个单元,并加入了baseState属性存储React中的state状态。如下所示:
class UpdateQueue {
constructor() {
this.baseState = null // state
this.firstUpdate = null // 第一个更新
this.lastUpdate = null // 最后一个更新
}
}
复制代码
接下来定义两个方法:插入节点单元(enqueueUpdate)、更新队列(forceUpdate)。插入节点单元时需要考虑是否已经存在节点,如果不存在直接将firstUpdate、lastUpdate指向此节点即可。更新队列是遍历这个链表,根据payload中的内容去更新state的值。
class UpdateQueue {
//.....
enqueueUpdate(update) {
// 当前链表是空链表
if (!this.firstUpdate) {
this.firstUpdate = this.lastUpdate = update
} else {
// 当前链表不为空
this.lastUpdate.nextUpdate = update
this.lastUpdate = update
}
}
// 获取state,然后遍历这个链表,进行更新
forceUpdate() {
let currentState = this.baseState || {}
let currentUpdate = this.firstUpdate
while (currentUpdate) {
// 判断是函数还是对象,是函数则需要执行,是对象则直接返回
let nextState = typeof currentUpdate.payload === 'function' ? currentUpdate.payload(currentState) : currentUpdate.payload
currentState = { ...currentState, ...nextState }
currentUpdate = currentUpdate.nextUpdate
}
// 更新完成后清空链表
this.firstUpdate = this.lastUpdate = null
this.baseState = currentState
return currentState
}
}
复制代码
最后写一个demo,实例化一个队列,向其中加入很多节点,再更新这个队列:
let queue = new UpdateQueue()
queue.enqueueUpdate(new Update({ name: 'www' }))
queue.enqueueUpdate(new Update({ age: 10 }))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.forceUpdate()
console.log(queue.baseState);
复制代码
打印结果如下:
{ name:'www',age:12 }
一个执行单元
Fiber 可以理解为一个执行单元,每次执行完一个执行单元,react 就会检查现在还剩多少时间,如果没有时间则将控制权让出去。Fiber 可以被理解为划分一个个更小的执行单元,它是把一个大任务拆分为了很多个小块任务,一个小块任务的执行必须是一次完成的,不能出现暂停,但是一个小块任务执行完后可以移交控制权给浏览器去响应用户,从而不用像之前一样要等那个大任务一直执行完成再去响应用户。
执行原理
任务调度与任务优先级
任务的拆分、执行、挂起、恢复以及高优先级任务插队是 react 更新任务的核心。
拆分
每一个dom元素就是一个Fiber,而一个 Fiber 可以理解为一个执行单元,所以一次更新任务被拆分成了以Fiber为单位的小任务。
执行、挂起、恢复 假设用户调用 setState 更新组件, 这个待更新的任务会先放入队列中, 然后通过 requestIdleCallback 请求浏览器调度:浏览器有空闲时就会来执行任务,每执行完一个执行单元,就检查一下剩余时间是否充足以及是否有剩余的执行单元,如果没有了任务则退出,如果时间充足且有剩余的任务就执行下一个执行单元,反之则停止执行,记录下一次要执行的执行单元,等下一次有执行权时恢复执行。使用方法如下:window.requestIdleCallback(callback)。callback就是更新函数,会接收到默认参数 deadline ,其中包含了以下两个属性:
- didTimeout 返回 callback 任务是否超时
- timeRamining 返回当前帧还剩多少时间供用户使用
requestIdleCallback调度fiber更新任务的伪代码如下:
let firstFiber
let nextFiber = firstFiber
let shouldYield = false
//firstFiber->firstChild->sibling
function performUnitOfWork(nextFiber){
//...
return nextFiber.next
}
function workLoop(deadline){
while(nextFiber && deadline.timeReaming > 1){
nextFiber = performUnitOfWork(nextFiber)
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
任务优先级
React 16 利用 expirationTimes模型 来实现任务优先级,每一种不同的任务会分配一个过期时间,过期时间 = 每种任务计算出一个常量 + 任务的触发时间,任务的具体优先级计算公式为 优先级 = 一个很大的常量 - 过期时间,得到的值越大,优先级越高。
React 17 改变了优先级模型为 Lane 模型
优先级批次
除此之外,还有个问题需要解决:如何表示批次?
批次是什么?考虑如下例子:
// 定义状态num
const [num, updateNum] = useState(0);
// ...某些修改num的地方
// 修改的方式1
updateNum(3);
// 修改的方式2
updateNum(num => num + 1);
两种 修改状态的方式 都会创建更新,区别在于:
- 第一种方式,不需考虑更新前的状态,直接将状态num修改为3
- 第二种方式,需要基于 更新前的状态 计算新状态
由于第二种方式的存在,更新之间可能有连续性。所以 expirationTime算法 计算出一个优先级后,组件render时实际参与更新当前状态的值的是:
计算出的优先级对应更新 + 与该优先级相关的其他优先级对应更新 这些相互关联,有连续性的更新被称为一个批次(batch)。expirationTime算法计算 批次 的方式也简单粗暴:优先级大于某个值(priorityOfBatch)的更新都会划为同一批次。
const isUpdateIncludedInBatch = priorityOfUpdate >= priorityOfBatch;
expirationTime算法保证了render异步可中断、且永远是最高优先级的更新先被处理。
两个阶段的拆分
除了Fiber 工作单元的拆分,两阶段的拆分也是一个非常重要的改造,在此之前都是一边Diff一边提交的。先来看看这两者的区别:
-
协调阶段 可以认为是 Diff 阶段, 这个阶段可以被中断, 这个阶段会找出所有节点变更,例如节点新增、删除、属性变更等等, 这些变更React 称之为'副作用(Effect)',在协调阶段如果时间片用完,React就会选择让出控制权。因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。
-
提交阶段 将上一个阶段计算出来的需要处理的**副作用(Effects)**一次性执行了。这个阶段必须同步执行,不能被打断
状态更新的时序
在 React 得到控制权后,应该优先处理高优先级的任务。也就是说正在处理的任务可能会被中断,在恢复时会让位给高优先级任务,原本中断的任务可能会被放弃或者重做。但是如果不按顺序执行任务,可能会导致前后的状态不一致。 比如低优先级任务将 a 设置为0,而高优先级任务将 a 递增1, 两个任务的执行顺序会影响最终的渲染结果。因此要让高优先级任务插队, 首先要保证状态更新的时序。
解决办法是: 所有更新任务按照顺序插入一个队列, 状态必须按照插入顺序进行计算,但任务可以按优先级顺序执行,例如:
红色表示高优先级任务。要计算它的状态必须基于前序任务计算出来的状态, 从而保证状态的最终一致性:
最终红色的高优先级任务 C 执行时的状态值是a=5,b=3. 在恢复控制权时,会按照优先级先执行 C, 前面的A、 B暂时跳过,虽然A、 B任务暂时跳过,但是会执行他们的状态。
上面被跳过任务不会被移除,在执行完高优先级任务后它们还是会被执行的。因为不同的更新任务影响的节点树范围可能是不一样的,举个例子 a、b 可能会影响 Foo 组件树,而 c 会影响 Bar 组件树。所以为了保证视图的最终一致性, 所有更新任务都要被执行。
- 首先 C 先被执行,它更新了 Foo 组件
- 接着执行 A 任务,它更新了Foo 和 Bar 组件,由于 C 已经以最终状态a=5, b=3更新了Foo组件,这里可以做一下性能优化,直接复用C的更新结果, 不必触发重新渲染。因此 A 仅需更新 Bar 组件即可。
- 接着执行 B,同理可以复用 Foo 更新结果。
道理讲起来都很简单,React Fiber 实际上非常复杂,不管执行的过程怎样拆分、以什么顺序执行,最重要的是保证状态的一致性和视图的一致性
react Fiber 出现以后让 react 有了可中断更新的能力,在 react 17 以前 react 依然是同步更新的(react 17有个实验版本,通过ReactDOM.createRoot(rootNode).render()创建的应用是并发更新),也就是协调过程不可中断,表现和 react 16之前一样。
整体流程
总结
react的组件架构是由一个个fiber组成的树组成,他的工作流程就是遍历fiber tree去执行每一个工作单元。分为协调阶段(深度遍历并diff产生新树、执行hooks链表并收集effect并链成链表)和提交阶段(处理effect链表,执行完毕切换渲染树) 。
fiber有新旧两棵树,一个是current fiber,是已经渲染在界面上的。一个是work fiber,由当前的更新触发而在内存中构建的。构建完成,work fiber就会替换cur fiber,然后经过提交阶段完成更新,在dom操作完成后渲染到界面上。
一个页面就是一个fiber,这个页面的child就是render函数中的组件或者element,都会有他们自己的sibling,child,return(父级),如果是hook组件会在该fiber中的memoizedState属性保存它自己的hooks链表,在协调阶段通过执行hooks链表得到effect链表。协调阶段时,requestIdleCallback在主线程的空闲期执行低优先级的任务,requestAnimationFrame执行高优先级任务,requestIdleCallback执行完一个fiber的更新后,若下一个任务执行时间超过了deathLine,或者突然插入更高优先级的任务,则执行中断,保存当前结果,修改fiber node 的tag标记,设置为pending状态,恢复任务执行时,检查tag是被中断的任务,会接着继续做任务或者重做。当全部完成时进入提交阶段在提交阶段(不能被打断、同步、遍历)执行effect链表、调度Effect、操作DOM、执行周期函数,完成切换、渲染。