前言
Fiber 是对 React 核心算法的重构,facebook 团队使用两年多的时间去重构 React 的核心算法,在 React16 以上的版本中引入了 Fiber 架构,极大的提高了大型react项目的性能,也激发了我对其实现的好奇。在研究源码的过程中,能发现很多比较细的点,有任务单元拆分的细,有任务调度、双缓冲、节点复用等优化的细,都非常值得我们学习,接下来就带大家看看react fiber 到底有多细。
一、我们为什么需要react fiber
react在进行组件渲染时,从setState开始到渲染完成整个过程是同步的(“一气呵成”)。如果需要渲染的组件比较庞大,js执行会占据主线程时间较长,会导致页面响应度变差,使得动画、手势交互等事件产生卡顿。
为了解决这个问题,React 提供pureComponent,shouldComponentUpdate,useMemo,useCallback让开发者来操心哪些subtree是需要重新渲染的,哪些是不需要重新渲染的。究其本质,是因为 React 采用 jsx 语法过于灵活,不理解开发者写出代码所代表的意义,没有办法做出优化。
为什么JS长时间执行会影响交互响应、动画?因为JavaScript在浏览器的主线程上运行,恰好与样式计算、布局以及许多情况下的绘制一起运行。如果JavaScript运行时间过长,就会阻塞这些其他工作,可能导致掉帧。
因此,为了解决以上的痛点问题,React希望能够彻底解决主线程长时间占用问题,于是引入了 Fiber 来改变这种不可控的现状,把渲染/更新过程拆分为一个个小块的任务,通过合理的调度机制来调控时间,指定任务执行的时机,从而降低页面卡顿的概率,提升页面交互体验。通过Fiber架构,让reconcilation过程变得可被中断。适时地让出CPU执行权,可以让浏览器及时地响应用户的交互。
由此react fiber的任务就很清晰了
- 把渲染/更新过程拆分为更小的、可中断的工作单元
- 在浏览器空闲时执行工作循环
- 将所有执行结果汇总patch到真实DOM上
二、工作单元
如何拆分工作,这是最基础也是最重要的工作。
2.1 拆什么,什么不能拆?
把渲染/更新过程分为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)不可拆分
2.2 怎么拆?
先凭空乱来几种diff工作拆分方案:
- 按组件结构拆。不好分,无法预估各组件更新的工作量
- 按实际工序拆。比如分为getNextState(), shouldUpdate(), updateState(), checkChildren()再穿插一些生命周期函数
按组件拆太粗,显然对大组件不太公平。按工序拆太细,任务太多,频繁调度不划算。那么有没有合适的拆分单位?
2.3 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已经准备好了异步渲染的前置工作,接下来看看浏览器为其提供了哪些助攻。
三、浏览器能力
介绍浏览器能力之前,我们先了解下浏览器渲染的基础知识。
3.1 渲染帧
我们知道,在浏览器中,页面是一帧一帧绘制出来的,渲染的帧率与设备的刷新率保持一致。一般情况下,设备的屏幕刷新率为 1s 60次,当每秒内绘制的帧数(FPS)超过60时,页面渲染是流畅的;而当 FPS 小于60时,会出现一定程度的卡顿现象。下面来看完整的一帧中,具体做了哪些事情
- 首先需要处理输入事件,能够让用户得到最早的反馈
- 接下来是处理定时器,需要检查定时器是否到时间,并执行对应的回调
- 接下来处理 Begin Frame(开始帧),即每一帧的事件,包括 window.resize、scroll、media query change 等
- 接下来执行请求动画帧 requestAnimationFrame(rAF),即在每次绘制之前,会执行 rAF 回调
- 紧接着进行 Layout 操作,包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示
- 接着进行 Paint 操作,得到树中每个节点的尺寸与位置等信息,浏览器针对每个元素进行内容填充
到这时以上的六个阶段都已经完成了,接下来处于空闲阶段(Idle Peroid),可以在这时执行requestIdleCallback里注册的任务(它就是 React Fiber 任务调度实现的基础)
3.2 RequestIdleCallback
RequestIdleCallback 是 react Fiber 实现的基础 api 。该方法将在浏览器的空闲时段内调用的函数排队,使开发者在主事件循环上执行后台和低优先级的工作,而不影响延迟关键事件,如动画和输入响应。正常帧任务完成后没超过16ms,说明有多余的空闲时间,此时就会执行requestIdleCallback里注册的任务。
可以参考下图来理解requestIdleCallback在每帧中的调用
- 低优先级任务由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中定义的三个任务均执行完毕。打印结果如下:
多帧执行
将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申请下一个时间片。打印结果如下:
可以明显的看出任务1、2是在第一个帧内完成的,任务3在第二个。可能有人会好奇为什么第二帧的剩余时间和第一帧差那么多,这里可以理解为浏览渲染每帧的开始时间是不受渲染任务影响的,是固定不变16ms为一周期(60hz刷新频率下),也就是说执行第2个任务超时的那几毫秒不会推迟第二帧的开始时间,或者可以理解第2个任务(抢夺)了这第二帧一些时间,这里画了个图,可以帮助大家更好的理解这个问题:
由此看来,应该避免在requestIdleCallback中执行过长时间的任务,否则可能会阻塞页面渲染,以及页面交互。当然也不建议在requestIdleCallback 里再操作 DOM,这样会导致页面再次重绘。DOM 操作建议在 rAF 中进行。同时,操作 DOM 所需要的耗时是不确定的,因为会导致重新计算布局和视图的绘制,所以这类操作不具备可预测性。
Promise 也不建议在这里面进行,因为 Promise 的回调属性 Event loop 中优先级较高的一种微任务,会在 requestIdleCallback 结束时立即执行,不管此时是否还有富余的时间,这样有很大可能会让一帧超过 16 ms。
OK, requestIdleCallback 的基本信息也介绍完了,后面开始重点讲讲react fiber是如何搭配requestIdleCallback构建出fiber tree的。
四、React fiber执行原理
Fiber Tree 的构建过程,实际上也是diff的过程,也就是effect的收集过程,此过程会找出所有节点的变更,如节点新增、删除、属性变更等,这些变更 react 统称为副作用(effect),随着所有的节点(工作单元)在帧空闲时间逐个执行完毕,最后产出的结果是effect list,从中可以知道哪些节点更新、哪些节点增加、哪些节点删除了。
4.1 任务调度
React fiber的构建的过程并不是一蹴而就的,它以每个fiber作为一个工作单元,进行工作循环,工作循环中每次处理一个任务(工作单元),处理完毕有一次喘息的机会:
// Flush asynchronous work until there's a higher priority event
while (nextUnitOfWork !== null && !shouldYieldToRenderer()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
shouldYieldToRenderer就是看时间用完了没,没用完的话继续处理下一个任务,用完了就结束,把时间控制权还给主线程,等下一次requestIdleCallback回调再接着做。但如果当前渲染执行很长一段时间后还未结束,那么就不再会喘息,而是一次性把剩余工作全部做完。
if (!isYieldy) {
// Flush work without yielding
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
}
React Fiber的工作调度与浏览器的核心交互流程如下:
4.2 遍历流程
Fiber Tree 构建的遍历顺序,它会以旧的fiber tree为蓝本,把每个fiber作为一个工作单元,自顶向下逐节点构造workInProgress tree(构建中的新fiber tree)
具体过程如下:
- 从顶点开始遍历
- 如果有子节点,先遍历子节点;
- 如果没有子节点,则看有没有兄弟节点,有则遍历兄弟节点,并把effect向上归并
- 如果没有兄弟节点,则看有没有父兄弟节点,有则遍历父兄弟节点
- 如果没有都没有了,那么遍历结束
其实就是一个深度优先的遍历
可以先看看继续看看任务调度中 performUnitOfWork大致的实现:
function performUnitOfWork(fiber: Fiber, topWork: Fiber) {
next = beginWork(current, workInProgress, nextRenderExpirationTime);
if (next === null) {
// If this doesn't spawn new work, complete the current work.
next = completeUnitOfWork(workInProgress);
}
return next
}
这一块代码可以看出下一个工作单元是的确定,是由当前工作单元beginWork之后得到的,这也合情合理,毕竟没执行过当前工作单元,也就无法得知有无子节点生成。那我们继续看看beginWork中是如何判断下一个工作单元的。
switch (workInProgress.tag) {
case HostComponent: {
return updateHostComponent(current, workInProgress, renderExpirationTime);
}
case ClassComponent: {
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderExpirationTime
);
}
case FunctionComponent: {
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderExpirationTime
);
}
// ...
}
可以看到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;
}
4.3 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{
// 宿主节点diff
case HostComponent: {
return updateHostComponent(current, workInProgress, renderExpirationTime);
}
// 类组件节点diff
case ClassComponent: {
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderExpirationTime
);
}
// 函数组件节点diff
case FunctionComponent: {
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderExpirationTime
);
}
// ... 其他类型节点,省略
}
宿主节点比对:
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),以current tree为主,workInProgress tree为辅。
双缓冲具体指的是workInProgress tree构造完毕,得到的就是新的fiber tree,然后喜新厌旧的把current指针指向workInProgress tree,把旧的fiber tree放在一边。
这样做的好处:
- 最多只需要树的两个版本,节省内存
- 可以方便的重用节点
- 可在需要时,回收额外的内存
每个fiber上都有个alternate属性,也指向一个fiber,创建workInProgress节点时优先取alternate,没有的话就创建一个:
let workInProgress = current.alternate;
if (workInProgress === null) {
// We use a double buffering pooling technique because we know that we'll
// only ever need at most two versions of a tree. We pool the "other" unused
// node that we're free to reuse. This is lazily created to avoid allocating
// extra objects for things that are never updated. It also allow us to
// reclaim the extra memory if needed.
// 没有就创建一个
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode
);
//...这里很有意思
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上。
4.4 如何中断/断点恢复
中断:检查当前正在处理的工作单元,保存当前成果(firstEffect, lastEffect),修改tag标记一下,迅速收尾并再开一个requestIdleCallback,下次有机会再做
断点恢复:下次再处理到该工作单元时,看tag是被打断的任务,接着做未完成的部分或者重做
五、总结
其实稍一细想,从Stack reconciler到Fiber reconciler,源码层面就是干了一件递归改循环的事情,把任务拆分成一个个细小的工作单元,随着任务调度抽空执行,直到整颗树构建完成。所以react fiber所谓的性能提升只是将reconciliation中diff过程的优先级往后调了,只在浏览器空闲时执行,从而提升了用户交互、动画渲染相关的优先级,使浏览器能够更快反馈用户操作,使页面减少卡顿感,但并不会减少react渲染所需的工作量,因此想要真正提升应用性能还是得靠写好代码,减少不必要的渲染才行呀。
尾声
- react fiber直到目前(17版本)正式版并没有开启异步渲染,具体原因可查看Concurrent 模式
- 由于该 API 的兼容性和实际渲染频率的因素,异步渲染大多数情况下用的是polyfill(requestAnimationFrame与MessageChannel)实现,而不是requestIdleCallback,大致实现可参考React的调度算法实现