前言
今天,骑着我心爱的小电驴,心事重重的走在大街上,突然,前面出现一个饿了么外卖小哥,在外卖箱的右下角上赫然写着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
,收集effect
commit
提交阶段,应用更新
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
的粗浅理解,如有错误,欢迎指正~~~