前言
Fiber 是对 React 核心算法的重构,facebook 团队使用两年多的时间去重构 React 的核心算法,在 React16 以上的版本中引入了 Fiber 架构,极大的提高了大型react项目的性能,在研究源码的过程中,发现其中运用了任务调度、双缓冲、等设计思想,非常值得我们学习。
我们为什么需要react fiber
react在进行组件渲染时,从setState开始到渲染完成整个过程是同步的(“一气呵成”)。如果需要渲染的组件比较庞大,js执行会占据主线程时间较长,会导致页面响应度变差,使得动画、手势交互等事件产生卡顿。
为了解决这个问题,React 提供pureComponent,shouldComponentUpdate,useMemo,useCallback让开发者来操心哪些subtree是需要重新渲染的,哪些是不需要重新渲染的。究其本质,是因为 React 采用 jsx 语法过于灵活,不理解开发者写出代码所代表的意义,没有办法做出优化。
因此,为了解决以上的痛点问题,React希望能够彻底解决主线程长时间占用问题,于是引入了 Fiber 来改变这种不可控的现状,把渲染/更新过程拆分为一个个小块的任务,通过合理的调度机制来调控时间,指定任务执行的时机,从而降低页面卡顿的概率,提升页面交互体验。通过Fiber架构,让reconcilation
过程变得可被中断。适时地让出CPU执行权,可以让浏览器及时地响应用户的交互。
由此react fiber的任务就很清晰了
- 把渲染/更新过程拆分为更小的、可中断的工作单元
- 在浏览器空闲时执行工作循环
- 将所有执行结果汇总patch到真实DOM上
工作单元
如何拆分工作,这是最基础也是最重要的工作。
拆什么,什么不能拆?
把渲染/更新过程分为2个阶段(diff + patch):
1.diff ~ render/reconciliation
2.patch ~ commit
diff的实际工作是对比prevInstance
和nextInstance
的状态,找出差异及其对应的DOM change。diff本质上是一些计算(遍历、比较),是可拆分的(算一半待会儿接着算)
patch阶段把本次更新中的所有DOM change应用到DOM树,是一连串的DOM操作。这些DOM操作虽然看起来也可以拆分(按照change list一段一段做),但这样做一方面可能造成DOM实际状态与维护的内部状态不一致,另外还会影响体验。而且,一般场景下,DOM更新的耗时比起diff及生命周期函数耗时不算什么,拆分的意义不很大
所以,render/reconciliation阶段的工作(diff)可以拆分,commit阶段的工作(patch)不可拆分
怎么拆?
先凭空乱来几种diff工作拆分方案:
- 按组件结构拆。不好分,无法预估各组件更新的工作量
- 按实际工序拆。比如分为
getNextState(), shouldUpdate(), updateState(), checkChildren()
再穿插一些生命周期函数
按组件拆太粗,显然对大组件不太公平。按工序拆太细,任务太多,频繁调度不划算。那么有没有合适的拆分单位?
Fiber
有。react的拆分单位是fiber(fiber tree上的一个节点),实际上就是按虚拟DOM节点拆,因为fiber tree是根据vDOM tree构造出来的,树结构一模一样,只是节点携带的信息有差异。
fiber tree上各节点的主要结构如下:
// fiber tree节点结构
{
// The local state associated with this fiber.
stateNode,
// Singly Linked List Tree Structure.
child,
return,
sibling,
// Effect
effectTag,
// Singly linked list fast path to the next fiber with side-effects.
nextEffect,
// The first and last fiber with side-effect within this subtree. This allows
// us to reuse a slice of the linked list when we reuse the work done within
// this fiber.
firstEffect,
lastEffect,
...
}
其中的 child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性,形成了如下的链表树结构:
而effectTag、nextEffect、firstEffect、lastEffect
为effect相关信息,保存当前diff的成果。这些参数共同为后续的工作循环提供了可能,使react可以在执行完每个fiber时停下,根据浏览器的繁忙情况判断是否继续往下执行,因此我们也可以将fiber理解成一个工作单元。
至此,react fiber已经准备好了异步渲染的前置工作,接下来看看浏览器为其提供了哪些助攻。
浏览器能力
介绍浏览器能力之前,我们先了解下浏览器渲染的基础知识。
渲染帧
我们知道,在浏览器中,页面是一帧一帧绘制出来的,渲染的帧率与设备的刷新率保持一致。一般情况下,设备的屏幕刷新率为 1s 60次,当每秒内绘制的帧数(FPS)超过60时,页面渲染是流畅的;而当 FPS 小于60时,会出现一定程度的卡顿现象。下面来看完整的一帧中,具体做了哪些事情
- 首先需要处理输入事件,能够让用户得到最早的反馈
- 接下来是处理定时器,需要检查定时器是否到时间,并执行对应的回调
- 接下来处理 Begin Frame(开始帧),即每一帧的事件,包括 window.resize、scroll、media query change 等
- 接下来执行请求动画帧 requestAnimationFrame(rAF),即在每次绘制之前,会执行 rAF 回调
- 紧接着进行 Layout 操作,包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示
- 接着进行 Paint 操作,得到树中每个节点的尺寸与位置等信息,浏览器针对每个元素进行内容填充
到这时以上的六个阶段都已经完成了,接下来处于空闲阶段(Idle Peroid),可以在这时执行requestIdleCallback
里注册的任务(它就是 React Fiber 实现的基础)
RequestIdleCallback
RequestIdleCallback 是 react Fiber 实现的基础 api 。该方法将在浏览器的空闲时段内调用的函数排队,使开发者在主事件循环上执行后台和低优先级的工作,而不影响延迟关键事件,如动画和输入响应。正常帧任务完成后没超过16ms,说明有多余的空闲时间,此时就会执行requestIdleCallback
里注册的任务。
可以参考下图来理解requestIdleCallback在每帧中的调用
![image-20210413093218261](/Users/newman/Library/Application Support/typora-user-images/image-20210413093218261.png)
- 低优先级任务由
requestIdleCallback
处理; - 高优先级任务,如动画相关的由
requestAnimationFrame
处理; requestIdleCallback
可以在多个空闲期调用空闲期回调,执行任务;
window.requestIdleCallback(callback)
的callback
中会接收到默认参数 deadline ,其中包含了以下两个属性:
- timeRamining 返回当前帧还剩多少时间供用户使用
- didTimeout 返回 callback 任务是否超时
requestIdleCallback
方法非常重要,下面分别讲两个例子来理解这个方法,在每个例子中都需要执行多个任务,但是任务的执行时间是不一样的,下面来看浏览器是如何分配时间执行这些任务的:
一帧执行
直接执行task1、task2、task3,各任务的时间总和小于16ms:
const sleep = (delay) => {
const start = Date.now();
while (Date.now() - start <= delay) {}
};
const taskQueue = [
() => {
console.log("task1 start");
sleep(3);
console.log("task1 end");
},
() => {
console.log("task2 start");
sleep(3);
console.log("task2 end");
},
() => {
console.log("task3 start");
sleep(3);
console.log("task3 end");
},
];
const performUnitWork = () => {
// 取出第一个队列中的第一个任务并执行
taskQueue.shift()();
};
const workloop = (deadline) => {
console.log(`此帧的剩余时间为: ${deadline.timeRemaining()}`);
// 如果此帧剩余时间大于0或者已经到了定义的超时时间(上文定义了timeout时间为1000,到达时间时必须强制执行),且当时存在任务,则直接执行这个任务
// 如果没有剩余时间,则应该放弃执行任务控制权,把执行权交还给浏览器
while (
(deadline.timeRemaining() > 0 || deadline.didTimeout) &&
taskQueue.length > 0
) {
performUnitWork();
}
// 如果还有未完成的任务,继续调用requestIdleCallback申请下一个时间片
if (taskQueue.length > 0) {
window.requestIdleCallback(workloop, { timeout: 1000 });
}
};
requestIdleCallback(workloop, { timeout: 1000 });
上面定义了一个任务队列taskQueue
,并定义了workloop
函数,其中采用 window.requestIdleCallback(workloop, { timeout: 1000 })
去执行taskQueue
中的任务。每个任务中仅仅做了console.log
、sleep(3)
的工作,时间是非常短的(大约3ms多一点),浏览器计算此帧中还剩余15.5ms,足以一次执行完这三个任务,因此在此帧的空闲时间中,taskQueue
中定义的三个任务均执行完毕。打印结果如下:
![image-20210413172443949](/Users/newman/Library/Application Support/typora-user-images/image-20210413172443949.png)
多帧执行
将task1、task2、task3中的睡眠时间提高至10ms:
const sleep = (delay) => {
const start = Date.now();
while (Date.now() - start <= delay) {}
};
const taskQueue = [
() => {
console.log("task1 start");
sleep(10);
console.log("task1 end");
},
() => {
console.log("task2 start");
sleep(10);
console.log("task2 end");
},
() => {
console.log("task3 start");
sleep(10);
console.log("task3 end");
},
];
const performUnitWork = () => {
taskQueue.shift()();
};
const workloop = (deadline) => {
console.log(`此帧的剩余时间为: ${deadline.timeRemaining()}`);
while (
(deadline.timeRemaining() > 0 || deadline.didTimeout) &&
taskQueue.length > 0
) {
performUnitWork();
}
if (taskQueue.length > 0) {
window.requestIdleCallback(workloop, { timeout: 1000 });
}
};
requestIdleCallback(workloop, { timeout: 1000 });
每个任务的时间被提高到10ms之后,在执行第1个任务时还能在第一帧剩余的时间里完成,在准备执行第2个任务时,虽然剩余的时间(还剩5ms左右)不够10ms,但由于浏览器并不知道回调函数会执行多久,所以依然还是会在此帧内执行第2个任务(这也会导致下一帧的渲染延迟),到第3个任务时,当前帧肯定是已经没有空余时间了,那么就再次调用requestIdleCallback
申请下一个时间片。打印结果如下:
![image-20210413172858532](/Users/newman/Library/Application Support/typora-user-images/image-20210413172858532.png)
可以明显的看出任务1、2是在第一个帧内完成的,任务3在第二个。可能有人会好奇为什么第二帧的剩余时间和第一帧差那么多,这里可以理解为浏览渲染每帧的开始时间是不受渲染任务影响的,是固定不变16ms为一周期(60hz刷新频率下),也就是说执行第2个任务超时的那几毫秒不会推迟第二帧的开始时间,这里画了个图,可以帮助大家更好的理解这个问题:
由此看来,应该避免在requestIdleCallback
中执行过长时间的任务,否则可能会阻塞页面渲染,以及页面交互。当然也不建议在 requestIdleCallback
里再操作 DOM,这样会导致页面再次重绘。DOM 操作建议在 rAF 中进行。同时,操作 DOM 所需要的耗时是不确定的,因为会导致重新计算布局和视图的绘制,所以这类操作不具备可预测性。
OK, requestIdleCallback
的基本信息也介绍完了,后面开始重点讲讲react fiber是如何搭配requestIdleCallback
构建出fiber tree的。
React fiber执行原理
Fiber Tree 的构建过程,实际上也是diff的过程,也就是effect的收集过程,此过程会找出所有节点的变更,如节点新增、删除、属性变更等,这些变更 react 统称为副作用(effect),随着所有的节点(工作单元)在帧空闲时间逐个执行完毕,最后产出的结果是effect list
,从中可以知道哪些节点更新、哪些节点增加、哪些节点删除了。
遍历流程
首先我们需要大致了解下Fiber Tree 构建的遍历顺序,它会以旧的fiber tree为蓝本,把每个fiber作为一个工作单元,自顶向下逐节点构造workInProgress tree(构建中的新fiber tree)
具体过程如下:
- 从顶点开始遍历
- 如果有子节点,先遍历子节点;
- 如果没有子节点,则看有没有兄弟节点,有则遍历兄弟节点,并把effect向上归并
- 如果没有兄弟节点,则看有没有父兄弟节点,有则遍历父兄弟节点
- 如果没有都没有了,那么遍历结束
可以看看 performUnitOfWork
的实现,它其实就是一个深度优先的遍历:
/**
* @params fiber 当前需要处理的节点
* @params topWork 本次更新的根节点
*/
function performUnitOfWork(fiber: Fiber, topWork: Fiber) {
// 对该节点进行处理
beginWork(fiber);
// 如果存在子节点,那么下一个待处理的就是子节点
if (fiber.child) {
return fiber.child;
}
// 没有子节点了,上溯查找兄弟节点
let temp = fiber;
while (temp) {
completeWork(temp);
// 到顶层节点了, 退出
if (temp === topWork) {
break;
}
// 找到,下一个要处理的就是兄弟节点
if (temp.sibling) {
return temp.sibling;
}
// 没有, 继续上溯
temp = temp.return;
}
}
任务调度
React fiber的遍历的过程并不是一蹴而就的,它以每个fiber作为一个工作单元,进行工作循环,工作循环中每次处理一个任务(工作单元),处理完毕有一次喘息的机会:
// Flush asynchronous work until the deadline runs out of time.
while (nextUnitOfWork !== null && !shouldYield()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
shouldYield
就是看时间用完了没(idleDeadline.timeRemaining()
),没用完的话继续处理下一个任务,用完了就结束,把时间控制权还给主线程,等下一次requestIdleCallback
回调再接着做:
// If there's work left over, schedule a new callback.
if (nextFlushedExpirationTime !== NoWork) {
scheduleCallbackWithExpiration(nextFlushedExpirationTime);
}
也就是说,(不考虑突发事件的)正常调度是由工作循环来完成的,基本规则是:每个工作单元结束检查是否还有时间做下一个,没时间了就先“挂起”
React Fiber的工作调度与浏览器的核心交互流程如下:
Reconciliation
了解了遍历流程与任务调度方法之后,接下来就是就是我们熟知的Reconcilation
阶段了(为了方便理解,这里不区分Diff和Reconcilation, 两者是同一个东西)。思路和 Fiber 重构之前差别不大, 只不过这里不会再递归去比对、而且不会马上提交变更。
具体过程如下(以组件节点为例):
- 如果当前节点不需要更新,直接把子节点clone过来,跳到5;要更新的话打个tag
- 更新当前节点状态(
props, state, context
等) - 调用
shouldComponentUpdate()
,false
的话,跳到5 - 调用
render()
获得新的子节点,并为子节点创建fiber(创建过程会尽量复用现有fiber,子节点增删也发生在这里) - 如果没有产生child fiber,该工作单元结束,把effect list归并到return,并把当前节点的sibling作为下一个工作单元;否则把child作为下一个工作单元
- 如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元;否则,立即开始做
- 如果没有下一个工作单元了(回到了workInProgress tree的根节点),第1阶段结束,进入pendingCommit状态
实际上是1-6的工作循环,7是出口,工作循环每次只做一件事,做完看要不要喘口气。工作循环结束时,workInProgress tree的根节点身上的effect list就是收集到的所有side effect(因为每做完一个都向上归并)
BeginWork
现在可以具体看看beginWork
是如何对 Fiber 进行比对的:
function beginWork(fiber: Fiber): Fiber | undefined{
if (fiber.tag === WorkTag.HostComponent) {
// 宿主节点diff
diffHostComponent(fiber)
} elseif (fiber.tag === WorkTag.ClassComponent) {
// 类组件节点diff
diffClassComponent(fiber)
} elseif (fiber.tag === WorkTag.FunctionComponent) {
// 函数组件节点diff
diffFunctionalComponent(fiber)
} else {
// ... 其他类型节点,省略
}
}
宿主节点比对:
function diffHostComponent(fiber: Fiber) {
// 新增节点
if (fiber.stateNode == null) {
fiber.stateNode = createHostComponent(fiber);
} else {
updateHostComponent(fiber);
}
const newChildren = fiber.pendingProps.children;
// 比对子节点
diffChildren(fiber, newChildren);
}
类组件节点比对也差不多:
function diffClassComponent(fiber: Fiber) {
// 创建组件实例
if (fiber.stateNode == null) {
fiber.stateNode = createInstance(fiber);
}
if (fiber.hasMounted) {
// 调用更新前生命周期钩子
applybeforeUpdateHooks(fiber);
} else {
// 调用挂载前生命周期钩子
applybeforeMountHooks(fiber);
}
// 渲染新节点
const newChildren = fiber.stateNode.render();
// 比对子节点
diffChildren(fiber, newChildren);
fiber.memoizedState = fiber.stateNode.state;
}
子节点比对:
function diffChildren(fiber: Fiber, newChildren: React.ReactNode) {
let oldFiber = fiber.alternate ? fiber.alternate.child : null;
// 全新节点,直接挂载
if (oldFiber == null) {
mountChildFibers(fiber, newChildren);
return;
}
let index = 0;
let newFiber = null;
// 新子节点
const elements = extraElements(newChildren);
// 比对子元素
while (index < elements.length || oldFiber != null) {
const prevFiber = newFiber;
const element = elements[index];
const sameType = isSameType(element, oldFiber);
if (sameType) {
newFiber = cloneFiber(oldFiber, element);
// 更新关系
newFiber.alternate = oldFiber;
// 打上Tag
newFiber.effectTag = UPDATE;
newFiber.return = fiber;
}
// 新节点
if (element && !sameType) {
newFiber = createFiber(element);
newFiber.effectTag = PLACEMENT;
newFiber.return = fiber;
}
// 删除旧节点
if (oldFiber && !sameType) {
oldFiber.effectTag = DELETION;
oldFiber.nextEffect = fiber.nextEffect;
fiber.nextEffect = oldFiber;
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index == 0) {
fiber.child = newFiber;
} else if (prevFiber && element) {
prevFiber.sibling = newFiber;
}
index++;
}
}
上面的代码很粗糙地还原了 Reconciliation 的过程, 但是对于我们理解React的基本原理已经足够了.
这里引用一下Youtube: Lin Clark presentation in ReactConf 2017 的Slide,来还原 Reconciliation 的过程。
上图是 Reconciliation 完成后的状态,左边是旧树,右边是WIP树。对于需要变更的节点,都打上了'标签'。在提交阶段,React 就会将这些打上标签的节点应用变更。
双缓冲技术
双缓冲技术(double buffering),就像redux里的nextListeners
,以fiber tree为主,workInProgress tree为辅
双缓冲具体指的是workInProgress tree构造完毕,得到的就是新的fiber tree,然后喜新厌旧(把current指针指向workInProgress tree,丢掉旧的fiber tree)就好了
这样做的好处:
- 能够复用内部对象(fiber)
- 节省内存分配、GC的时间开销
每个fiber上都有个alternate
属性,也指向一个fiber,创建workInProgress节点时优先取alternate
,没有的话就创建一个:
let workInProgress = current.alternate;
if (workInProgress === null) {
//...这里很有意思
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// We already have an alternate.
// Reset the effect tag.
workInProgress.effectTag = NoEffect;
// The effect list is no longer valid.
workInProgress.nextEffect = null;
workInProgress.firstEffect = null;
workInProgress.lastEffect = null;
}
如注释指出的,fiber与workInProgress互相持有引用,“喜新厌旧”之后,旧fiber就作为新fiber更新的预留空间,达到复用fiber实例的目的
副作用的收集和提交
接下来就是将所有打了 Effect 标记的节点串联起来,这个可以在completeWork
中做, 例如:
function completeWork(fiber) {
const parent = fiber.return;
// 到达顶端
if (parent == null || fiber === topWork) {
pendingCommit = fiber;
return;
}
if (fiber.effectTag != null) {
if (parent.nextEffect) {
parent.nextEffect.nextEffect = fiber;
} else {
parent.nextEffect = fiber;
}
} else if (fiber.nextEffect) {
parent.nextEffect = fiber.nextEffect;
}
}
将所有副作用提交了
function commitAllWork(fiber) {
let next = fiber;
while (next) {
if (fiber.effectTag) {
// 提交,偷一下懒,这里就不展开了
commitWork(fiber);
}
next = fiber.nextEffect;
}
// 清理现场
pendingCommit = nextUnitOfWork = topWork = null;
}
总结来说,就是通过每个节点更新结束时向上归并effect list来收集任务结果,reconciliation结束后,根节点的effect list里会记录包括DOM change在内的所有side effect,最后把所有副作用应用到真实DOM上。
如何中断/断点恢复
中断:检查当前正在处理的工作单元,保存当前成果(firstEffect, lastEffect
),修改tag标记一下,迅速收尾并再开一个requestIdleCallback
,下次有机会再做
断点恢复:下次再处理到该工作单元时,看tag是被打断的任务,接着做未完成的部分或者重做
总结
其实稍一细想,从Stack reconciler到Fiber reconciler,源码层面就是干了一件递归改循环的事情(当然,实际做的事情远不止递归改循环,但这是第一步)
总之,源码变化很大,如果对Fiber思路没有预先了解的话,看源码会比较艰难,感兴趣的朋友可以结合 react 源码继续研究。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!