背景
因最近团队内部准备技术分享,就想着把自己片段化的知识进行一个整理和串联,随总结如下,供之后复习查阅。
Filber概念
Fiber 是 React 16 中采用的新调和(reconciliation)引擎,主要目标是支持虚拟 DOM 的渐进式渲染。这是Facebook历时两年的突破性成果。简要说是React内部实现的一套状态更新机制。支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。
为了更好的探究fiber这么做的目的,我们当然还是要从React15出发,看看之前的react都有哪些瓶颈和问题。
React15面临的问题
在正式说React15的问题前,我们先来看下React15架构。
React15架构可以分为两层:
● Reconciler(协调器)—— 负责找出变化的组件
● Renderer(渲染器)—— 负责将变化的组件渲染到页面上
其中 Reconciler 是基于 Stack reconciler(栈调和器),使用同步的递归更新的方式。说到递归更新也就是diffing的过程, 但递归的缺点还是很明显,不能暂停,一旦开始必须从头到尾。如果需要渲染的树结构层级嵌套多,而且特别深,那么组件树更新时经常会出现页面掉帧、卡顿的现象。页面组件频繁更新时页面的动画总是卡顿,或者输入框用键盘输入文字,文字早已输完,但是迟迟不能出现在输入框内。
卡顿真的是前端展示交互无法忍受的问题,接下来我们分析下页面为什么会卡顿,只有知道问题的本质原因,才能找到最优的解决方案。这个要从浏览器执行机制说起。我们都知道浏览器常见的线程有JS引擎线程、GUI渲染线程、HTTP请求线程,定时触发线程,事件处理线程。其中,GUI渲染线程与JS线程是互斥的。当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中,等到JS引擎空闲时,才会被执行。而主流浏览器刷新频率为60Hz,即16.6ms刷新一次,每个16.6ms浏览器都要执行 JS脚本 ---- 样式布局 ---- 样式绘制。所以一旦递归更新时间超过了16ms时间超过16.6ms,浏览器就没有时间执行样式布局绘制了,表现出来的就是页面卡顿,这在页面有动画时尤为明显。
虽然说react 团队已将树操作的复杂度由O(n*3) 改进为 O(n),再去进行优化diff算法貌似有点钻牛角尖了,之所以这么说,因为diff算法都用于组件的新旧children比较,children一般不会出现过长的情况,有点大炮打蚊子。况且当我们的应用变得非常庞大,页面有上万个组件,要diff这么多组件,再卓绝的算法也不能保证浏览器不会累趴。因为他们没想到浏览器也会累趴,也没有想到这是一个长跑的问题。如果是100米短跑,或者1000米竞赛,当然越快越好。如果是马拉松,就需要考虑到保存体力了,需要注意休息了,所以解决问题的关键是给浏览器适当的喘息。
Filber架构解决的问题
以下是React官网描述的Fiber架构的目标:
- 能够把可中断的任务切片处理。
- 能够调整优先级,重置并复用任务。
- 能够在父元素与子元素之间交错处理,以支持 React 中的布局。
- 能够在 render() 中返回多个元素。
- 更好地支持错误边界。
其中关键的前两点都是在解决上述React 15 的问题,再详细说之前,我们先看下React 16之后的架构
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Scheduler
可以看出在React16 多了Scheduler来调整任务优先级,重要任务优先执行,以浏览器是否有剩余时间作为任务中断的标准,当浏览器有剩余时间时,scheduler会通知我们,同时scheduler会进行一系列的任务优先级判断,保证任务时间合理分配。其中scheduler包含两个功能:时间切片和优先级调度。
时间切片
因为浏览器的刷新频率为16.6ms,js执行超过16.6ms页面就会卡顿,而时间切片就是在浏览器每一帧的时间中,预留一些时间给JS线程,React利用这部分时间更新组件,预留的初始时间是5ms。超过5ms,React将中断js,等下一帧时间到来继续执行js,上面我们大致说了浏览器一帧的执行,接下来我们详细再分析下。
我们可以来看下时间切片应该放在哪里,宏任务貌似可行,先看看有没有更好的选择:
微任务
微任务将在页面更新前全部执行完,所以达不到「将主线程还给浏览器」的目的。---no pass
requestAnimationFrame
requestAnimationFrame一直是浏览器js动画的首选。它采用系统时间间隔16.6ms,能保持最佳绘制效率,一直是js动画的首选,它不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间太长,使动画卡顿不流畅,让各种网页动画效果能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果,但是 React 仍然没有使用,是因为当页面处理未激活的状态下,requestAnimationFrame 会停止执行;当页面后面再转为激活时,requestAnimationFrame 又会接着上次的地方继续执行,这种受到用户行为的干扰的Api只能被Scheduler放弃。---no pass
requestIdleCallback
requestIdleCallback其实就是浏览器自己的时间切片的功能,但是由于它存在以下两个缺陷,react也
是只能放弃。---no pass
- requestIdleCallback在各个浏览器兼容性不好,同requestAnimationFrame一样在当页面未激活的状态下停止执行。
requestIdleCallback 每50ms刷新一次,刷新频率远远低于16.6ms,远远低于页面流畅度的要求。
宏任务
1. setTimeout
既然是宏任务,那么是setTimeout可以吗?答案是可以但不是最佳。让我们分析下原因:
当递归执行 setTimeout(fn, 0) 时,最后间隔时间会变成 4 ms,而不是最初的 1ms,因为setTimeout
的执行时机是和js执行有关的,递归是会不准,4ms是因为W3C指定的标准,setTimeout第二个参数不能小于4ms,小于4ms默认为4ms。
var count = 0
var startVal = +new Date()
console.log("start time", 0, 0)
function func() {
setTimeout(() => {
console.log("exec time", ++count, +new Date() - startVal)
if (count === 50) {
return
}
func()
}, 0)
}
func()
2. messageChannel
Scheduler最终使用了 MessageChannel 产生宏任务,但是由于兼容,如果当前宿主环境不支持
MessageChannel,则还是使用setTimeout。
简单介绍下,window.MessageChannel和window.postMessage一样,都可以创建一个通信的通道,这个管道有两个端口,每个端口都可以通过postMessage发送数据,而一个端口只要绑定了onmessage回调方法,就可以接收从另一个端口传过来的数据。以下是MessageChannel的一个简单例子:
那为什么不使用postMessage而使用MessageChannel呢,我们在使用postMessage的时候会触发所有通过addEventlistener绑定的message事件处理函数,同时postMessage意味着是一个全局的管道,而MessageChannel则是只能在一定的作用域下才生效。因此react为了减少串扰,用MessageChannel构建了一个专属管道,减少了外界的串扰(当外界通信频繁数据量过大,引起缓冲队列溢出而导致管道阻塞便会影响到react的调度性能)以及对外界的干扰。
任务调度
任务优先级的定义
这个是任务调度的关键,react根据用户对交互的预期顺序为交互产生的状态更新赋予了不同的优先级:
其中React 16中使用expirationTime来判断优先级,过期时间越小,优先级越高。
// 无优先级任务
export const NoPriority = 0;
// 立即执行任务,像一些生命周期方法需要同步执行的
export const ImmediatePriority = 1;
// 用户阻塞任务,比如输入框内输入文字,需要立即执行
export const UserBlockingPriority = 2;
// 正常任务
export const NormalPriority = 3;
// 低优先级任务,比如数据请求,不需要用户感知
export const LowPriority = 4;
// 空闲执行任务, 比如隐藏界面意外的内容
export const IdlePriority = 5;
// 同时每个优先级对应的任务都对应一个过期时间
const IMMEDIATE_PRIORITY_TIMEOUT = -1;
const USER_BLOCKING_PRIORITY_TIMEOUT = 250;
const NORMAL_PRIORITY_TIMEOUT = 5000;
const LOW_PRIORITY_TIMEOUT = 10000;
const IDLE_PRIORITY_TIMEOUT = 1073741823;
const priorityMap = {
[ImmediatePriority]: IMMEDIATE_PRIORITY_TIMEOUT,
[UserBlockingPriority]: USER_BLOCKING_PRIORITY_TIMEOUT,
[NormalPriority]: NORMAL_PRIORITY_TIMEOUT,
[LowPriority]: LOW_PRIORITY_TIMEOUT,
[IdlePriority]: IDLE_PRIORITY_TIMEOUT
}
同时将任务分成了两种:未过期的和已过期的,分别用两个队列存储:
taskQueue:依据任务的过期时间(expirationTime)排序,过期时间越小,说明越紧急,过期时间小的排在前面。过期时间根据任务优先级计算得出,优先级越高,过期时间越小。
timerQueue:依据任务的开始时间(startTime)排序,开始时间越小,说明会越早开始,开始时间小的排在前面。任务进来的时候,开始时间默认是当前时间,如果进入调度的时候传了延迟时间,开始时间则是当前时间与延迟时间的和。
当前时间: 这里的当前时间并不是Date.now(), 而是window.performance.now(),它返回的是一个以毫秒为单位的数值,表示从打开当前页面到该命令执行的时候经历的毫秒数,比Date.now()更加精准
流程
Scheduler会定期将timerQueue中的过期任务放到taskQueue中,然后让调度者通知执行者循环taskQueue执行掉每一个任务。执行者控制着每个任务的执行,一旦某个任务的执行时间超出时间片的限制。就会被中断,然后当前的执行者退场,退场之前会通知调度者再去调度一个新的执行者继续完成这个任务,新的执行者在下一帧执行任务时依旧会根据时间片中断任务,然后退场,重复这一过程,直到当前这个任务彻底完成后,将任务从taskQueue踢出。taskQueue中每一个任务都被这样处理,最终完成所有任务,这就是Scheduler的完整工作流程。
算法缺陷
React 16中 expirationTimes 模型比较的是任务的相对优先级。
const isTaskIncludedInBatch = priorityOfTask >= priorityOfBatch ;
除非执行完更高优先级的任务,否则不允许执行较低优先级的任务。例如:给定优先级 A > B > C,如果不执行 A,就无法执行 B;如果不执行 B 和 A,也就不能执行 C。
这种限制是在 Suspense 出现之前设计的,在当时足够满足需求。当所有执行都受 CPU-bound(计算密集型) 限制时,除了按优先级之外,根本不需要按任何顺序去处理任务。但是当引入了 IO-bound(即 Suspense)任务时,就可能会遇到较高优先级的 IO-bound 任务阻塞较低优先级的 CPU-bound 任务完成的情况。所以会出现某些低优先级的任务一直无法执行。
为了解决这一问题,React 17 采用了新的 lane 模型, lane车道模型是一种更细粒度的启发式优先级更新算法。
React17更新算法
在新的算法中,指定一个连续的优先级区间,每次更新都会以区间内包含的优先级生成对应页面快照。
具体做法是:使用一个31位的二进制代表31种可能性。其中每个bit被称为一个lane(车道),代表优先级,某几个lane组成的二进制数被称为一个lanes,代表一批优先级。
Lanes 模型与 Expiration Times 模型相比有两个主要优点:
- Lanes 模型将任务优先级的概念(例如:“任务 A 的优先级是否高于任务 B?”)与批量任务(例如:“任务 A 是否属于这组任务?”)划分开来。
- 通道可以用单一的 32 位数据类型表达许多不同的任务线程。
说了那么多scheduler的优秀之处,可能大家在使用新版本react时仍有这样的疑问:为什么我升级了react16,甚至react17,更新渲染组件仍然是同步的,页面性能依然不好呢?
React16-17需要开启Concurrent模式才能真正体验Scheduler,在react18中将会
作为默认模式。
Fiber Reconciler
为了能更好的配合scheduler将任务可中断,可复用,可按优先级执行,就要将更新任务拆分成一个个小任务,Fiber 的拆分单位是 fiber(fiber tree上的一个节点),实际上是按照虚拟DOM的节点拆分的。
Fiber 节点
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Instance
// 静态数据存储的属性
// 定义fiber的类型。在reconciliation算法中使用它来确定需要完成的工作。
this.tag = tag;
this.key = key;
this.elementType = null;
// 描述了他对一个的组件,对于复合组件,type是函数或者类组件本身,对于标准组件(例如div,span),type是string
this.type = null;
// 保存对组件,DOM节点或与fiber节点关联的其他React元素类型的类实例的引用。
this.stateNode = null;
// fiber关系相关属性,用于生成Fiber Tree结构
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
// 动态数据&状态相关属性
// new props,新的变动带来的新的props,即nextProps
this.pendingProps = pendingProps;
// prev props,用于在上一次渲染期间创建输出的fiber的props
// 当传入的pendingProps和memoizedProps相同的时候,表示fiber可以重新使用之前的fiber,以避免重复的工作
this.memoizedProps = null;
// 状态更新,回调和DOM更新的队列,fiber对应的组件,所产生的update,都会放在该队列中
this.updateQueue = null;
// 当前屏幕UI对应状态,上一次输入更新的fiber state
this.memoizedState = null;
// 一个列表,存储该fiber依赖的contexts,events
this.dependencies = null;
// conCurrentMode和strictMode
// 共存的模式表示这个子树是否默认是 异步渲染的
// fiber刚被创建时,会继承父fiber
this.mode = mode;
// Effects
// 当前fiber阶段需要进行任务,包括:占位、更新、删除等
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
this.deletions = null;
// 优先级调度相关属性
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 双缓存
// current tree和working in prgoress tree关联属性
// 在fiber树更新的过程中,每个fiber都有与其对应的fiber
// 我们称之为 current <==> workInProgress
// 在渲染完成后,会指向对方
this.alternate = null;
}
上面的JSX会生成下面的Fiber树,这里的父指针叫做return而不是parent或者father,是因为作为一个工作单元,return指节点执行完成后会返回的下一个节点。子Fiber节点及其兄弟节点完成工作后会返回其父级节点,所以用return指代父级节点。
双缓冲技术
上面我们看到fiber节点中有个属性是alternate,这里就涉及到了双缓冲技术。
- 简要概述下什么是双缓冲技术?
我们看电视时,看到的屏幕称为OSD层,我们看到画面需要不停的重绘,这就很容易导致画面闪烁,双缓冲使用内存缓冲区来解决这一问题,绘制操作首先呈现到内存缓冲区,绘制完成后某一时机会绘制在OSD层。 - Reconciler对于双缓冲技术的应用
在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。
current Fiber树中的Fiber节点被称为current fiber,workInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。React应用的根节点通过使current指针在不同Fiber树的rootFiber间切换来完成current Fiber树指向的切换。
即当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。
每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新
reconciliation过程
reconciler过程分为2个阶段:
1.(可中断)render/reconciliation:通过构造workInProgress tree得出change
2.(不可中断)commit:应用这些DOM change
流程
- fiber 节点遍历流程
- 从current(Root)开始通过child向下找
- 如果有child先深度遍历子节点,直到null为止
- 然后看是否有兄弟节点
- 有兄弟节点则遍历兄弟节点
- 然后再看兄弟节点是否有子节点
- 如无其他兄弟节点,然后return看父节点是否有兄弟节点
- 如果无,则return回root
- reconciliation流程
小结
本文仅针对Scheduler和Reconciler原理做了浅析,这就是整个React Fiber架构中最核心的重构部分。name最后我们通过一个例子再整体看下这两部分的执行过程:
上面的例子在React Fiber架构中的整个更新流程为:
其中红框中的步骤随时可能由于以下原因被中断:
- 有其他更高优任务需要先更新
- 当前帧没有剩余时间
由于红框中的工作都在内存中进行,不会更新页面上的DOM,所以即使反复中断,用户也不会看见更新不完全的DOM。