2021-「React Fiber」 详细解析
「React Fiber」 详细解析
距离
使用
更新方法就是通过
在
- 调度阶段(Reconciler
) :这个阶段React 用新数据生成新的Virtual DOM ,遍历Virtual DOM ,然后通过Diff 算法,快速找出需要更新的元素,放到更新队列中去。 - 渲染阶段(Renderer
) :这个阶段React 根据所在的渲染环境,遍历更新队列,将对应元素更新。在浏览器中,就是更新对应的DOM 元素。
表面上看,这种设计也是挺合理的,因为更新过程不会有任何
这个策略像函数调用栈一样,会深度优先遍历所有的
举个例子:
假设更新一个组件需要
1ms ,如果有200 个组件要更新,那就需要200ms ,在这200ms 的更新过程中,浏览器唯一的主线程都在专心运行更新操作,无暇去做任何其他的事情。想象一下,在这200ms 内,用户往一个input 元素中输入点什么,敲击键盘也不会获得响应,因为渲染输入按键结果也是浏览器主线程的工作,但是浏览器主线程被React 占用,抽不出空,最后的结果就是用户敲了按键看不到反应,等React 更新过程结束之后,那些按键会一下出现在input 元素里,这就是所谓的界面卡顿。
React Fiber,就是为了解决渲染复杂组件时严重影响用户和浏览器交互的问题。
Fiber 产生的原因?
为了解决这个问题,
至于上面提到的为什么会影响到用户体验,这里需要简单介绍一下浏览器的工作模式:
因为浏览器的页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到
一帧中执行的工作主要以下图所示的任务执行顺序单线程依次执行。
如果其中一项任务执行的过久,导致总时长超过了
上面提到的调和阶段,就属于下图的
js 的执行阶段。如果调和时间过长导致了这一阶段执行时间过长,那么就有可能在用户有交互的时候,本来应该是渲染下一帧了,但是在当前一帧里还在执行JS ,就导致用户交互不能马上得到反馈,从而产生卡顿感。

Fiber 的设计思路
它的实现的调用栈示意图如下所示,一次更新任务是分时间片执行的,直至完成某次更新。
这样

Fiber 具体都做了什么?
Virtual DOM Tree 虚拟DOM 树 虚拟DOM 树的存在就是为了解决js 直接操作真实DOM 而引起的计算机计算能力的浪费。 因为通过js 直接修改DOM ,会引起整颗DOM 树计算和改变,而虚拟DOM 树的存在可以让真实DOM 只改变必要改变的部分。
1、Fiber 的调度单元: Fiber Node
Fiber Node,是
{
...
// 跟当前Fiber相关本地状态(比如浏览器环境就是DOM节点)
stateNode: any,
// 单链表树结构
return: Fiber | null,// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
child: Fiber | null,// 指向自己的第一个子节点
sibling: Fiber | null, // 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
// 更新相关
pendingProps: any, // 新的变动带来的新的props
memoizedProps: any, // 上一次渲染完成之后的props
updateQueue: UpdateQueue<any> | null, // 该Fiber对应的组件产生的Update会存放在这个队列里面
memoizedState: any, // 上一次渲染的时候的state
// Scheduler 相关
expirationTime: ExpirationTime, // 代表任务在未来的哪个时间点应该被完成,不包括他的子树产生的任务
// 快速确定子树中是否有不在等待的变化
childExpirationTime: ExpirationTime,
// 在Fiber树更新的过程中,每个Fiber都会有一个跟其对应的Fiber
// 我们称他为`current <==> workInProgress`
// 在渲染完成之后他们会交换位置
alternate: Fiber | null,
// Effect 相关的
effectTag: SideEffectTag, // 用来记录Side Effect
nextEffect: Fiber | null, // 单链表用来快速查找下一个side effect
firstEffect: Fiber | null, // 子树中第一个side effect
lastEffect: Fiber | null, // 子树中最后一个side effect
....
};
其中有几个属性需要重点关注:
- return:向上链接整颗树
- child:向下链接整棵树
- sibling:横向链接整颗树
- stateNode:与
DOM 树相连 - expirationTime:计算节点更新的优先级
- Effect**:
** 记录节点的变更
通过节点上的

简单总结一下:
组件是
组件树和
2、规定调度顺序:expirationTime 到期时间
每个
它是以任务什么时候该执行完为描述信息的,到期时间越短,则代表优先级越高。
在
React 中,为防止某个update 因为优先级的原因一直被打断而未能执行。React 会设置一个ExpirationTime ,当时间到了ExpirationTime 的时候,如果某个update 还未执行的话,React 将会强制执行该update ,这就是ExpirationTime 的作用。
每一次
到期时间的计算有两种方式
function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) {
let expirationTime;
// ......
if (fiber.mode & ConcurrentMode) {
if (isBatchingInteractiveUpdates) {
// 交互引起的更新
expirationTime = computeInteractiveExpiration(currentTime);
} else {
// 普通异步更新
expirationTime = computeAsyncExpiration(currentTime);
}
}
// ......
}
// ......
return expirationTime;
}
computeInteractiveExpiration
export const HIGH_PRIORITY_EXPIRATION = __DEV__ ? 500 : 150;
export const HIGH_PRIORITY_BATCH_SIZE = 100;
export function computeInteractiveExpiration(currentTime: ExpirationTime) {
return computeExpirationBucket(
currentTime,
HIGH_PRIORITY_EXPIRATION, //150
HIGH_PRIORITY_BATCH_SIZE //100
);
}
computeAsyncExpiration
export const LOW_PRIORITY_EXPIRATION = 5000;
export const LOW_PRIORITY_BATCH_SIZE = 250;
export function computeAsyncExpiration(
currentTime: ExpirationTime
): ExpirationTime {
return computeExpirationBucket(
currentTime,
LOW_PRIORITY_EXPIRATION, //5000
LOW_PRIORITY_BATCH_SIZE //250
);
}
查看上面两种方法,我们发现其实他们调用的是同一个方法:computeExpirationBucket,只是传入的参数不一样,而且传入的是常量。
computeExpirationBucket
const UNIT_SIZE = 10;
const MAGIC_NUMBER_OFFSET = 2;
function ceiling(num: number, precision: number): number {
return (((num / precision) | 0) + 1) * precision;
}
function computeExpirationBucket(
currentTime,
expirationInMs,
bucketSizeMs
): ExpirationTime {
return (
MAGIC_NUMBER_OFFSET +
ceiling(
currentTime - MAGIC_NUMBER_OFFSET + expirationInMs / UNIT_SIZE,
bucketSizeMs / UNIT_SIZE
)
);
}
最终的公式是:((((currentTime - 2 + 5000 / 10) / 25) | 0) + 1) * 25
其中只有只有
简单来说,以低优先级来说
高优先级是
也就是说
随着时间的流逝,一个更新的优先级会越来越高,这样就可以避免
另外,之前存在过一个
PriorityLevel 的优先级评估变量,但在16.x 中使用的是expirationTime 来评估,但为了兼容仍然会考虑PriorityLevel 来计算expirationTime 。
3、workInProgress Tree : 保存更新进度快照
4、Fiber Tree 和WorkInProgress tree 的关系
在首次渲染的过程中,
而
这两棵树构成了双缓冲树
双缓冲具体指的是

一次更新的操作都是在
这样做的好处:
- 能够复用内部对象(fiber)
- 节省内存分配、
GC 的时间开销 - 就算运行中有错误,也不会影响
View 上的数据
5、更新
怎么触发的更新
- this.setState();
props 的改变(因为props 改变也是由父组件的setState 引起的, 其实也是第一种);- this.forceUpdate();
触发更新后
首先
整个调度的过程是计算并重新构建
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;
}
6、effect
每一个
每个
例如,我们的更新导致

可以看到具有副作用的节点是如何链接在一起的。当遍历节点时,

7、获取浏览器的控制权 — requestIdleCallback 和requestAnimationFrame
构建出

不过这个
如果浏览器支持这两个
8、调度器(Scheduler)
-
调和器主要作用就是在组件状态变更时,调用组件树各组件的
render 方法,渲染,卸载组件,而Fiber 使得应用可以更好的协调不同任务的执行,调和器内关于高效协调的实现,我们可以称它为调度器(Scheduler) 。Fiber 中的调度器主要的关注点是: -
- 合并多次更新:没有必要在组件的每一个状态变更时都立即触发更新任务,有些中间状态变更其实是对更新任务所耗费资源的浪费,就比如用户发现错误点击时快速操作导致组件某状态从
A 至B 再至C ,这中间的B 状态变更其实对于用户而言并没有意义,那么我们可以直接合并状态变更,直接从A 至C 只触发一次更新; - 任务优先级:不同类型的更新有不同优先级,例如用户操作引起的交互动画可能需要有更好的体验,其优先级应该比完成数据更新高;
- 推拉式调度:基于推送的调度方式更多的需要开发者编码间接决定如何调度任务,而拉取式调度更方便
React 框架层直接进行全局自主调度;
- 合并多次更新:没有必要在组件的每一个状态变更时都立即触发更新任务,有些中间状态变更其实是对更新任务所耗费资源的浪费,就比如用户发现错误点击时快速操作导致组件某状态从
调度的实现逻辑主要是
-
- 通过
fiber.return 属性,从当前fiber 实例层层遍历至组件树根组件; - 依次对每一个
fiber 实例进行到期时间判断,若大于传入的期望任务到期时间参数,则将其更新为传入的任务到期时间; - 调用
requestWork 方法开始处理任务,并传入获取的组件树根组件FiberRoot 对象和任务到期时间;
- 通过
Fiber 执行流程

在调和过程中以
具体过程如下:

通过每个节点更新结束时向上归并
所以,构建
而提交过程阶段是一口气直接做完(同步执行
- 处理
effect list (包括3 种处理:更新DOM 树、调用组件生命周期函数以及更新ref 等内部状态) - 该阶段结束时,所有更新都
commit 到DOM 树上了。
-
- 未使用
Fiber 的例子: https://claudiopro.github.io/react-fiber-vs-stack-demo/stack.html - 使用
Fiber 的例子:[https://claudiopro.github.io/re
- 未使用
Fiber 为什么是React 性能的一个飞跃?
什么是Fiber
在
React Fiber 中的时间分片
把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。
Stack Reconciler
基于栈的
如果一个页面足够复杂,形成的函数调用栈就会很深。每一次更新,执行栈需要一次性执行完成,中途不能干其他的事儿,只能
Fiber Reconciler
链表结构
在
- 使用多向链表的形式替代了原来的树结构;
<div id="A">
A1
<div id="B1">
B1
<div id="C1"></div>
</div>
<div id="B2">B2</div>
</div>

- 副作用单链表;

- 状态更新单链表;

- …
链表是一种简单高效的数据结构,它在当前节点中保存着指向下一个节点的指针;遍历的时候,通过操作指针找到下一个元素。

链表相比顺序结构数据格式的好处就是:
- 操作更高效,比如顺序调整、删除,只需要改变节点的指针指向就好了。
- 不仅可以根据当前节点找到下一个节点,在多向链表中,还可以找到他的父节点或者兄弟节点。
但链表也不是完美的,缺点就是:
- 比顺序结构数据更占用空间,因为每个节点对象还保存有指向下一个对象的指针。
- 不能自由读取,必须找到他的上一个节点。
斐波那契数列的Fiber
递归形式的斐波那契数列写法:
function fib(n) {
if (n <= 2) {
return 1;
} else {
return fib(n - 1) + fib(n - 2);
}
}
采用
function fib(n) {
let fiber = { arg: n, returnAddr: null, a: 0 },
consoled = false;
// 标记循环
rec: while (true) {
// 当展开完全后,开始计算
if (fiber.arg <= 2) {
let sum = 1;
// 寻找父级
while (fiber.returnAddr) {
if (!consoled) {
// 在这里打印查看形成的链表形式的 fiber 对象
consoled = true;
console.log(fiber);
}
fiber = fiber.returnAddr;
if (fiber.a === 0) {
fiber.a = sum;
fiber = { arg: fiber.arg - 2, returnAddr: fiber, a: 0 };
continue rec;
}
sum += fiber.a;
}
return sum;
} else {
// 先展开
fiber = { arg: fiber.arg - 1, returnAddr: fiber, a: 0 };
}
}
}
React Fiber 是如何实现更新过程可控?
更新过程的可控主要体现在下面几个方面:
- 任务拆分
- 任务挂起、恢复、终止
- 任务具备优先级
任务拆分
在
任务挂起、恢复、终止
workInProgress tree
currentFiber tree
在新
挂起
当第一个小任务完成后,先判断这一帧是否还有空闲时间,没有就挂起下一个任务的执行,记住当前挂起的节点,让出控制权给浏览器执行更高优先级的任务。
恢复
在浏览器渲染完一帧后,判断当前帧是否有剩余时间,如果有就恢复执行之前挂起的任务。如果没有任务需要处理,代表调和阶段完成,可以开始进入渲染阶段。
如何判断一帧是否有空闲时间的呢?
使用前面提到的
恢复执行的时候又是如何知道下一个任务是什么呢?
是在前面提到的链表。在
终止
其实并不是每次更新都会走到提交阶段。当在调和过程中触发了新的更新,在执行下一个任务的时候,判断是否有优先级更高的执行任务,如果有就终止原来将要执行的任务,开始新的

任务具备优先级
任务在执行过程中顺便收集了每个A1(TEXT)-B1(TEXT)-C1(TEXT)-C1-C2(TEXT)-C2-B1-B2(TEXT)-B2-A
。
其实最终都是为了收集到这条副作用链表,有了它,在接下来的渲染阶段就通过遍历副作用链完成
<div id="A1">
A1
<div id="B1">
B1
<div id="C1">C1</div>
<div id="C2">C2</div>
</div>
<div id="B2">B2</div>
</div>

直观展示
正是基于以上这些过程,使用


清晰展示及交互、源码可通过下面两个链接进入,查看网页源代码。
Fiber 结构长什么样?
基于时间分片的增量更新需要更多的上下文信息,之前的
class FiberNode
:
class FiberNode {
constructor(tag, pendingProps, key, mode) {
// 实例属性
this.tag = tag; // 标记不同组件类型,如函数组件、类组件、文本、原生组件...
this.key = key; // react 元素上的 key 就是 jsx 上写的那个 key ,也就是最终 ReactElement 上的
this.elementType = null; // createElement的第一个参数,ReactElement 上的 type
this.type = null; // 表示fiber的真实类型 ,elementType 基本一样,在使用了懒加载之类的功能时可能会不一样
this.stateNode = null; // 实例对象,比如 class 组件 new 完后就挂载在这个属性上面,如果是RootFiber,那么它上面挂的是 FiberRoot,如果是原生节点就是 dom 对象
// fiber
this.return = null; // 父节点,指向上一个 fiber
this.child = null; // 子节点,指向自身下面的第一个 fiber
this.sibling = null; // 兄弟组件, 指向一个兄弟节点
this.index = 0; // 一般如果没有兄弟节点的话是0 当某个父节点下的子节点是数组类型的时候会给每个子节点一个 index,index 和 key 要一起做 diff
this.ref = null; // reactElement 上的 ref 属性
this.pendingProps = pendingProps; // 新的 props
this.memoizedProps = null; // 旧的 props
this.updateQueue = null; // fiber 上的更新队列执行一次 setState 就会往这个属性上挂一个新的更新, 每条更新最终会形成一个链表结构,最后做批量更新
this.memoizedState = null; // 对应 memoizedProps,上次渲染的 state,相当于当前的 state,理解成 prev 和 next 的关系
this.mode = mode; // 表示当前组件下的子组件的渲染方式
// effects
this.effectTag = NoEffect; // 表示当前 fiber 要进行何种更新(更新、删除等)
this.nextEffect = null; // 指向下个需要更新的fiber
this.firstEffect = null; // 指向所有子节点里,需要更新的 fiber 里的第一个
this.lastEffect = null; // 指向所有子节点中需要更新的 fiber 的最后一个
this.expirationTime = NoWork; // 过期时间,代表任务在未来的哪个时间点应该被完成
this.childExpirationTime = NoWork; // child 过期时间
this.alternate = null; // current 树和 workInprogress 树之间的相互引用
}
}

图片来源:完全理解
React Fiber
function performUnitWork(currentFiber) {
//beginWork(currentFiber) //找到儿子,并通过链表的方式挂到currentFiber上,没有儿子就找后面那个兄弟
//有儿子就返回儿子
if (currentFiber.child) {
return currentFiber.child;
}
//如果没有儿子,则找弟弟
while (currentFiber) {
//一直往上找
//completeUnitWork(currentFiber);//将自己的副作用挂到父节点去
if (currentFiber.sibling) {
return currentFiber.sibling;
}
currentFiber = currentFiber.return;
}
}
Concurrent Mode (并发模式)
目前
Legacy Mode: 就相当于目前稳定版的模式Blocking Mode: 应该是以后会代替Legacy Mode 而长期存在的模式Concurrent Mode: 以后会变成default 的模式
- Suspense:
Suspense 是React 提供的一种异步处理的机制, 它不是一个具体的数据请求库。它是React 提供的原生的组件异步调用原语。 - useTrasition:让页面实现
Pending -> Skeleton -> Complete
的更新路径, 用户在切换页面时可以停留在当前页面,让页面保持响应。 相比展示一个无用的空白页面或者加载状态,这种用户体验更加友好。
其中