前言
今天,骑着我心爱的小电驴,心事重重的走在大街上,突然,前面出现一个饿了么外卖小哥,在外卖箱的右下角上赫然写着Carbon Fiber(碳纤维),原来Fiber还可以这么用!!! 感觉就像打通了我的任督二脉一样,瞬间对Fiber这个单词的理解有了飞跃性的提升!一个保温箱都用上了Carbon Fiber,看来确实是下了一番功夫!
React团队重写了React 的核心算法---reconciliation,一般将之前的算法叫stack reconciliation,现在的叫fiber reconciliation。
在React Fiber架构面世之后,引起了不小的轰动。React团队为什么要重写React架构,而Fiber又是什么?下面我们就一起走进Fiber。
一、React V15.x 版本存在的问题
在页面元素很多,且需要频繁刷新的场景下,React V15.x 会出现掉帧的现象。下面是一个非常经典的例子:
参考链接:claudiopro.github.io/react-fiber…
从上图可以看出明显的掉帧现象。原因是大量的同步计算任务阻塞了浏览器的UI渲染。因为在默认情况下,JS运算、页面布局、页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果JS运算持续占用主线程,页面就没法得到及时的更新。当我们调用setState更新页面的时候,React会遍历应用的所有节点,计算出差异,然后再更新UI。整个过程是不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过16ms,就容易出现掉帧的现象。
在Dom-Diff时也是如此递归遍历对比,且存在两个非常影响性能的问题:
- 树节点庞大时,会导致递归调用执行栈越来越深
- 不能中断执行,页面会等待递归执行完成才重新渲染
为了解决这一问题,React团队从框架层面对web页面的运行机制做了优化,并且得到了很好的效果。效果图如下:
二、如何解决
解决主线程长时间被 JS 运算占用这一问题的基本思路,是将运算切割为多个步骤,分批完成。也就是说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。
React将任务分成小片,在一小片段的时间内运行这些分片任务,让主线程做优先级更高的事情,如果有任何待处理的事情,就回来完成工作。一个Fiber就是一个工作单元, React 的一个核心概念是 UI 是数据的投影 ,组件的本质可以看作输入数据,输出UI的描述信息(虚拟DOM树),即:
ui = f(data)
也就是说,渲染一个 React app,其实是在调用一个函数,函数本身会调用其它函数,形成调用栈,递归调用导致的调用栈我们本身无法控制, 只能一次执行完成。而 Fiber 就是为了解决这个痛点,可以去按需要打断调用栈,手动控制 stack frame——就这点来说,Fiber 可以理解为 virtual stack frame。
旧版 React通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback这一 API。官方的解释是这样的:
window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台和低优先级的任务,而且不会对像动画和输入响应等用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。
参考文档: developer.mozilla.org/zh-CN/docs/…
Fiber的拆分
既然要将任务拆分为小任务,得有拆分的方案。
Fiber的拆分方案就是按照虚拟DOM拆分,因为fiber tree是根据Virtual DOM tree 构建出来的,结构上是一样的,只是节点携带的信息不一样。
因此,每个组件实例和每个DOM节点的抽象表示都是一个工作单元,在工作循环中,每次处理一个fiber,处理完一个就会判断是否有高优先级的任务或者剩余时间是否充足,可以继续处理或者挂起或者完成工作循环。
Fiber reconciliation的工作循环
- 找到根节点优先级最高的
workInProgress tree,取其待处理的节点(代表组件或DOM节点) - 检查当前节点是否需要更新,不需要的话,直接到4
- 标记一下(打个
tag),更新自己(组件更新props,context等,DOM节点记下DOM change),进行reconcileChildren并返回workInProgress.child - 不存在
workInProgress.child,证明是叶子节点,向上收集effect - 把
child或者sibling当做nextUnitWork,进入下一个工作循环。如果回到了workInProgress tree的根节点,则工作循环结束 - 进入
commit阶段
Fiber工作阶段phase
diff render和reconciliation主要是构建workInProgress tree,其实是diff过程complete diffProperties,标记tag,收集effectcommit提交阶段,应用更新
workInProgress 双缓冲池技术
Fiber调度算法采取了双缓冲池算法,FiberRoot底下的所有节点,都会在算法过程中,尝试创建自己的“镜像”。
workInProgress tree是reconcile过程中从fiber tree建立的当前进度快照,所有工作都是在这颗树上进行,用于计算更新,完成reconciliation过程。
workInProgress.alternate = current;
current.alternate = workInProgress;
他和当前的fiber通过alternate进行关联,在构建workInProgress 时,会取current.alternate,存在则复用,不存在则创建。这样做能够复用内部对象(fiber),节省内存分配、GC的时间开销。
workInProgress tree 构造完毕,得到的就是新的fiber tree,当进入commit阶段就把current指向了workInProgress。
root.current = finishedWork;
核心思想是双缓池技术(double buffering pooling technique),因为需要做 diff 的话,起码是要有两棵树进行对比。通过这种方式,可以把树的总体数量限制在2,节点、节点属性都是延迟创建的,最大限度地避免内存使用量因算法过程而不断增长。
双缓冲的两棵树 FiberNode Tree 角色互换,原来的 workInProgress 转正。
双缓冲更新策略
- 将每次渲染完后的
fiber树赋值给currentRoot - 第一次更新时将
rooterFiber的alternate指向上一次渲染好的currentRoot - 第二次之后的更新将
workInProgressRoot指向currentRoot.alternate,然后将当前的workInProgressRoot.alternate指向上一次渲染好的currentRoot - ...
- 进而达到复用
fiber对象树
有了解题思路后,我们再来看看 React 具体是怎么做的。
三、解决方法
React 框架内部的运作可以分为 3 层:
Virtual DOM层,描述页面长什么样。Reconciler层,负责调用组件生命周期方法,进行Diff运算等。Renderer层,根据不同的平台,渲染出相应的页面,比较常见的是ReactDOM和ReactNative。
因此Virtual DOM、Dom diff、Fiber、Renderer等之间并不是独立的概念!而是相互关联,相辅相成的!
其中Reconciler层改动最大,React团队也给它新起了个名字:Fiber Reconciler。也就是Fiber。
Fiber 其实指的是一种数据结构,它可以用一个纯 JS 对象来表示:
const fiber = {
stateNode, // 节点实例
child, // 子节点
sibling, // 兄弟节点
return, // 父节点
}
为了加以区分,以前的Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑:
而Fiber Reconciler每执行一段时间,都会将控制权交回给浏览器,可以分段执行:
为了达到这种效果,就需要有一个调度器(
Scheduler)来进行任务分配。任务的优先级有六种:
synchronous,与之前的Stack Reconciler操作一样,同步执行task,在next tick之前执行animation,下一帧之前执行high,在不久的将来立即执行low,稍微延迟执行也没关系offscreen,下一次render时或scroll时才执行
优先级高的任务(如键盘输入)可以打断优先级低的任务(如 diff)的执行从而更快的生效。
Fiber Reconciler在执行过程中,会分为2个阶段:
- 阶段一,生成
Fiber树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。 - 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。
阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。
Fiber的特点:
- 暂停工作,并在之后可以返回再次开始
- 可以为不同类型的工作设置优先级
- 复用之前已经完成的工作
- 终止已经不再需要的工作
四、Fiber 树
Fiber Reconciler在阶段一时进行Diff计算的时候,会生成一棵Fiber树。这棵树是在Virtual DOM树的基础上增加额外的信息来生成的,它本质上来说是一个链表。
Fiber树在首次渲染的时候会一次过生成。在后续需要Diff的时候,会根据已有树和最新Virtual DOM的信息,生成一棵新的树,这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程:
如果过程中有优先级更高的任务需要进行,则Fiber Reconciler会丢弃正在生成的树,在空闲的时候再重新执行一遍。
在构造Fiber树的过程中,Fiber Reconciler会将需要更新的节点信息保存在Effect List当中,在阶段二执行的时候,会批量更新相应的节点。
Fiber执行阶段
每次渲染有两个阶段:Reconciliation(协调render)阶段和Commit(提交)阶段
- 协调
render阶段:可以认为是Diff阶段,这个阶段可以被中断,这个阶段会找出所有节点变更,例如节点增删改等等,这些变更在React中称为Effect(副作用) - 提交阶段:将上一个阶段计算出来的需要处理的副作用一次性执行。这个阶段不能中断,必须同步一次性执行完
Fiber小结
- 我们可以通过某些调度策略合理分配
CPU资源,从而提高用户的响应速度 - 通过
Fiber架构,让自己的Reconciliation过程变得可被中断,适时地让出CPU执行权,可以让浏览器及时地响应用户的交互
五、总结
我们从React V15 存在的问题出发,介绍了React Fiber解决问题的思路,并介绍了Fiber Reconciler的工作流程。从Stack Reconciler到Fiber Reconciler,源码层面将递归改为循环,从而解决了React V15 存在的问题。
六、源码简析
scheduler
在React16.5 之后把scheduler单独发一个包了,就叫scheduler
scheduleCallbackWithExpirationTime
异步进行root任务调度就是通过这个方法来做的,这里最重要的就是调用了scheduler的scheduleDeferredCallback方法(在scheduler包中是schedulework)传入的事回调函数performAsyncWork,以及一个包含timeout超时事件的对象
function scheduleCallbackWithExpirationTime(
root: FiberRoot,
expirationTime: ExpirationTime,
) {
if (callbackExpirationTime !== NoWork) {
// A callback is already scheduled. Check its expiration time (timeout).
if (expirationTime > callbackExpirationTime) {
// Existing callback has sufficient timeout. Exit.
return
} else {
if (callbackID !== null) {
// Existing callback has insufficient timeout. Cancel and schedule a
// new one.
cancelDeferredCallback(callbackID)
}
}
// The request callback timer is already running. Don't start a new one.
} else {
startRequestCallbackTimer()
}
callbackExpirationTime = expirationTime
const currentMs = now() - originalStartTimeMs
const expirationTimeMs = expirationTimeToMs(expirationTime)
const timeout = expirationTimeMs - currentMs
// 最主要的就是调用了scheduler的scheduleDeferredCallback方法
callbackID = scheduleDeferredCallback(performAsyncWork, { timeout })
}
scheduler.scheduleWork
创建一个调度节点newNode,并按照timeoutAt的顺序加入到CallbackNode链表,调用 ensureHostCallbackIsScheduled 这里面的 expirationTime 是调用时传入的timeoutAt加上当前时间形成的过期时间。
function unstable_scheduleCallback(callback, deprecated_options) {
var startTime =
currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime()
var expirationTime
if (
typeof deprecated_options === 'object' &&
deprecated_options !== null &&
typeof deprecated_options.timeout === 'number'
) {
// FIXME: Remove this branch once we lift expiration times out of React.
expirationTime = startTime + deprecated_options.timeout
} else {
// 这里是以后把`expirationTime`从React中抽离出来之后的逻辑
}
var newNode = {
callback,
priorityLevel: currentPriorityLevel,
expirationTime,
next: null,
previous: null,
}
// Insert the new callback into the list, ordered first by expiration, then
// by insertion. So the new callback is inserted any other callback with
// equal expiration.
if (firstCallbackNode === null) {
// This is the first callback in the list.
firstCallbackNode = newNode.next = newNode.previous = newNode
// 调用ensureHostCallbackIsScheduled
ensureHostCallbackIsScheduled()
} else {
var next = null
var node = firstCallbackNode
do {
if (node.expirationTime > expirationTime) {
// The new callback expires before this one.
next = node
break
}
node = node.next
} while (node !== firstCallbackNode)
if (next === null) {
// No callback with a later expiration was found, which means the new
// callback has the latest expiration in the list.
next = firstCallbackNode
} else if (next === firstCallbackNode) {
// The new callback has the earliest expiration in the entire list.
firstCallbackNode = newNode
ensureHostCallbackIsScheduled()
}
var previous = next.previous
previous.next = next.previous = newNode
newNode.next = next
newNode.previous = previous
}
return newNode
}
ensureHostCallbackIsScheduled
如果已经在调用回调了,就return,因为本来就会继续调用下去,isExecutingCallback在flushWork的时候会被修改为true。如果isHostCallbackScheduled为false,也就是还没开始调度,那么设为true,如果已经开始了,就直接取消,因为顺序可能变了。调用requestHostCallback开始调度这里
function ensureHostCallbackIsScheduled() {
if (isExecutingCallback) {
// Don't schedule work yet; wait until the next time we yield.
return
}
// Schedule the host callback using the earliest expiration in the list.
var expirationTime = firstCallbackNode.expirationTime
if (!isHostCallbackScheduled) {
isHostCallbackScheduled = true
} else {
// Cancel the existing host callback.
cancelHostCallback()
}
requestHostCallback(flushWork, expirationTime)
}
cancelHostCallback = function() {
scheduledHostCallback = null
isMessageEventScheduled = false
timeoutTime = -1
}
requestHostCallback
开始进入调度,设置调度的内容,用scheduledHostCallback和timeoutTime这两个全局变量记录回调函数和对应的过期时间
调用requestAnimationFrameWithTimeout,其实就是调用requestAnimationFrame在加上设置了一个100ms的定时器,防止requestAnimationFrame太久不触发。
调用回调animationTick并设置isAnimationFrameScheduled全局变量为true
requestHostCallback = function(callback, absoluteTimeout) {
scheduledHostCallback = callback
timeoutTime = absoluteTimeout
if (isFlushingHostCallback || absoluteTimeout < 0) {
// Don't wait for the next frame. Continue working ASAP, in a new event.
window.postMessage(messageKey, '*')
} else if (!isAnimationFrameScheduled) {
isAnimationFrameScheduled = true
requestAnimationFrameWithTimeout(animationTick)
}
}
模拟 requestIdleCallback
因为requestIdleCallback这个API目前还处于草案阶段,所以浏览器实现率还不高,所以在这里React直接使用了polyfill的方案。
这个方案简单来说就是通过requestAnimationFrame在浏览器渲染一帧之前多做一些处理,然后通过postMessage在macro task(类似 setTimeout)中加入一个回调,因为接下去会进入浏览器渲染阶段,所以主线程是被block住的,等到主线程有空的时候回来调用
animationTick
只要scheduledHostCallback还在就继续调要requestAnimationFrameWithTimeout因为这一帧渲染完了可能队列还没清空,本身也是要进入再次调用的,这边就省去了requestHostCallback在次调用的必要性
接下去一段代码是用来计算相隔的requestAnimationFrame的时差的,这个时差如果连续两次都小于当前的activeFrameTime,说明平台的帧率是很高的,这种情况下会动态的缩小帧时间。
最后更新frameDeadline,然后如果没有触发idleTick则发送消息
var animationTick = function(rafTime) {
if (scheduledHostCallback !== null) {
requestAnimationFrameWithTimeout(animationTick)
} else {
isAnimationFrameScheduled = false
return
}
var nextFrameTime = rafTime - frameDeadline + activeFrameTime
if (nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) {
if (nextFrameTime < 8) {
nextFrameTime = 8
}
activeFrameTime =
nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime
} else {
previousFrameTime = nextFrameTime
}
frameDeadline = rafTime + activeFrameTime
if (!isMessageEventScheduled) {
isMessageEventScheduled = true
window.postMessage(messageKey, '*')
}
}
idleTick
首先判断postMessage是不是自己的,不是直接返回
清空scheduledHostCallback和timeoutTime
获取当前时间,对比frameDeadline,查看是否已经超时了,如果超时了,判断一下任务callback的过期时间有没有到,如果没有到,则重新对这个callback进行一次调度,然后返回。如果到了,则设置didTimeout为true
接下去就是调用callback了,这里设置isFlushingHostCallback全局变量为true代表正在执行。并且调用callback也就是flushWork并传入didTimeout
var idleTick = function(event) {
if (event.source !== window || event.data !== messageKey) {
return
}
isMessageEventScheduled = false
var prevScheduledCallback = scheduledHostCallback
var prevTimeoutTime = timeoutTime
scheduledHostCallback = null
timeoutTime = -1
var currentTime = getCurrentTime()
var didTimeout = false
if (frameDeadline - currentTime <= 0) {
if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) {
didTimeout = true
} else {
if (!isAnimationFrameScheduled) {
isAnimationFrameScheduled = true
requestAnimationFrameWithTimeout(animationTick)
}
scheduledHostCallback = prevScheduledCallback
timeoutTime = prevTimeoutTime
return
}
}
if (prevScheduledCallback !== null) {
isFlushingHostCallback = true
try {
prevScheduledCallback(didTimeout)
} finally {
isFlushingHostCallback = false
}
}
}
flushWork
先设置isExecutingCallback为true,代表正在调用callback
设置deadlineObject.didTimeout,在 React 业务中可以用来判断任务是否超时
如果didTimeout,会一次从firstCallbackNode向后一直执行,知道第一个没过期的任务
如果没有超时,则依此执行第一个callback,直到帧时间结束为止
最后清理变量,如果任务没有执行完,则再次调用ensureHostCallbackIsScheduled进入调度
顺便把Immedia优先级的任务都调用一遍。
function flushWork(didTimeout) {
isExecutingCallback = true
deadlineObject.didTimeout = didTimeout
try {
if (didTimeout) {
while (firstCallbackNode !== null) {
var currentTime = getCurrentTime()
if (firstCallbackNode.expirationTime <= currentTime) {
do {
flushFirstCallback()
} while (
firstCallbackNode !== null &&
firstCallbackNode.expirationTime <= currentTime
)
continue
}
break
}
} else {
if (firstCallbackNode !== null) {
do {
flushFirstCallback()
} while (
firstCallbackNode !== null &&
getFrameDeadline() - getCurrentTime() > 0
)
}
}
} finally {
isExecutingCallback = false
if (firstCallbackNode !== null) {
ensureHostCallbackIsScheduled()
} else {
isHostCallbackScheduled = false
}
flushImmediateWork()
}
}
flushFirstCallback
它做的事情很简单
- 如果当前队列中只有一个回调,清空队列
- 调用回调并传入
deadline对象,里面有timeRemaining方法通过frameDeadline - now()来判断是否帧时间已经到了 - 如果回调有返回内容,把这个返回加入到回调队列
全局变量参考
isHostCallbackScheduled
是否已经开始调度了,在ensureHostCallbackIsScheduled设置为true,在结束执行callback之后设置为false
scheduledHostCallback
在requestHostCallback设置,值一般为flushWork,代表下一个调度要做的事情
isMessageEventScheduled
是否已经发送调用idleTick的消息,在animationTick中设置为true
timeoutTime
表示过期任务的时间,在idleTick中发现第一个任务的时间已经过期的时候设置
isAnimationFrameScheduled
是否已经开始调用requestAnimationFrame
activeFrameTime
给一帧渲染用的时间,默认是 33,也就是 1 秒 30 帧
frameDeadline
记录当前帧的到期时间,他等于currentTime + activeFraeTime,也就是requestAnimationFrame回调传入的时间,加上一帧的时间。
isFlushingHostCallback
是否正在执行callback
参考文档:React Fiber工作原理和相关概念
附录:
1. React15存在哪些痛点?Fiber是什么?React16为什么需要引入Fiber?
- 渲染和
diff阶段一气呵成,节点树庞大时会导致页面卡死 Fiber并不神秘,只是将Virtual-DOM转变为一种链表结构- 链表结构配合
requestIdleCallback可实现可中断可恢复的调度机制
2. React16下的虚拟DOM与React15相同
3. 如何实现Fiber的数据结构和遍历算法?
React中使用链表将Virtual DOM链接起来,每一个节点表示一个Fiber
4. 如何实现Fiber架构下可中断和可恢复的的任务调度?
借助requestIdleCallback交由浏览器在一帧渲染后的给出的空闲时间内实现指定数量跟新,批量更新可以直接跳过这个API,按之前的方式
5. 如何实现Fiber架构下的组件渲染和副作用收集提交?
- 执行的收集顺序类似于二叉树的先序遍历
- 完成的收集顺序类似于二叉树的后序遍历
6. 如何实现Fiber中的调和和双缓冲优化策略?
- 在
Fiber结构中增加一个alternate字段标识上一次渲染好的Fiber树,下次渲染时可复用
以上就是我对Fiber的粗浅理解,如有错误,欢迎指正~~~