2018-Depth in React,详谈Fiber 架构
Depth in React,详谈Fiber 架构
前言
所以我今年
我的思路是自上而下的介绍,先理解整体的
介绍
在详细介绍
React 的核心思想
内存中维护一颗虚拟
React 16 之前的不足
首先我们了解一下render()
和 setState()
进行组件渲染和更新的时候,

调和阶段
渲染阶段
在协调阶段阶段,由于是采用的递归的遍历方式,这种也被成为
如何解决之前的不足
之前的问题主要的问题是任务一旦执行,就无法中断,
js 线程一直占用主线程,导致卡顿。
可能有些接触前端不久的不是特别理解上面为什么
浏览器每一帧都需要完成哪些工作?
页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到

浏览器一帧内的工作
通过上图可看到,一帧内需要完成如下六个步骤的任务:
- 处理用户的交互
JS 解析执行- 帧开始。窗口尺寸变更,页面滚去等的处理
- rAF(requestAnimationFrame)
- 布局
- 绘制
如果这六个步骤中,任意一个步骤所占用的时间过长,总时间超过
而在上一小节提到的调和阶段花的时间过长,也就是
解决方案
补充知识,操作系统常用任务调度策略:先来先服务(FCFS)调度算法、短作业(进程)优先调度算法(SJ/PF
) 、最高优先权优先调度算法(FPF) 、高响应比优先调度算法(HRN) 、时间片轮转法(RR) 、多级队列反馈法。
合作式调度主要就是用来分配任务的,当有更新任务来的时候,不会马上去做requestIdelCallback
在上面我们已经知道浏览器是一帧一帧执行的,在两个执行帧之间,主线程通常会有一小段空闲时间,requestIdleCallback
可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback

-
低优先级任务由
requestIdleCallback
处理; -
高优先级任务,如动画相关的由
requestAnimationFrame
处理; -
requestIdleCallback
可以在多个空闲期调用空闲期回调,执行任务; -
requestIdleCallback
方法提供deadline ,即任务执行限制时间,以切分任务,避免长时间执行,阻塞UI 渲染而导致掉帧;
这个方案看似确实不错,但是怎么实现可能会遇到几个问题:
- 如何拆分成子任务?
- 一个子任务多大合适?
- 怎么判断是否还有剩余时间?
- 有剩余时间怎么去调度应该执行哪一个任务?
- 没有剩余时间之前的任务怎么办?
接下里整个
什么是Fiber
为了解决之前提到解决方案遇到的问题,提出了以下几个目标:
- 暂停工作,稍后再回来。
- 为不同类型的工作分配优先权。
- 重用以前完成的工作。
- 如果不再需要,则中止工作。
为了做到这些,我们首先需要一种方法将任务分解为单元。从某种意义上说,这就是
但是仅仅是分解为单元也无法做到中断任务,因为函数调用栈就是这样,每个函数为一个工作,每个工作被称为堆栈帧,它会一直工作,直到堆栈为空,无法中断。
所以我们需要一种增量渲染的调度,那么就需要重新实现一个堆栈帧的调度,这个堆栈帧可以按照自己的调度算法执行他们。另外由于这些堆栈是可以自己控制的,所以可以加入并发或者错误边界等功能。
因此
所以我们可以说
如果了解协程的可能会觉得
Fiber 的这种解决方案,跟协程有点像( 区别还是很大的) ,是可以中断的,可以控制执行顺序。在JS 里的generator 其实就是一种协程的使用方式,不过颗粒度更小,可以控制函数里面的代码调用的顺序,也可以中断。
Fiber 是如何工作的
ReactDOM.render()
和setState
的时候开始创建更新。- 将创建的更新加入任务队列,等待调度。
- 在
requestIdleCallback 空闲时执行任务。 - 从根节点开始遍历
Fiber Node ,并且构建WokeInProgress Tree 。 - 生成
effectList 。 - 根据
EffectList 更新DOM 。
下面是一个详细的执行过程图:

- 第一部分从
ReactDOM.render()
方法开始,把接收的React Element 转换为Fiber 节点,并为其设置优先级,创建Update ,加入到更新队列,这部分主要是做一些初始数据的准备。 - 第二部分主要是三个函数:
scheduleWork
、requestWork
、performWork
,即安排工作、申请工作、正式工作三部曲,React 16 新增的异步调用的功能则在这部分实现,这部分就是Schedule 阶段,前面介绍的Cooperative Scheduling 就是在这个阶段,只有在这个解决获取到可执行的时间片,第三部分才会继续执行。具体是如何调度的,后面文章再介绍,这是React 调度的关键过程。 - 第三部分是一个大循环,遍历所有的
Fiber 节点,通过Diff 算法计算所有更新工作,产出EffectList 给到commit 阶段使用,这部分的核心是beginWork 函数,这部分基本就是Fiber Reconciler ,包括reconciliation 和commit 阶段。
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
....
};
Fiber Reconciler
在第二部分,进行
- (可中断)
render/reconciliation 通过构造WorkInProgress Tree 得出Change 。 - (不可中断)
commit 应用这些DOM change 。
reconciliation 阶段
在
- [UNSAFE_]componentWillMount(弃用)
- [UNSAFE_]componentWillReceiveProps(弃用)
- getDerivedStateFromProps
- shouldComponentUpdate
- [UNSAFE_]componentWillUpdate(弃用)
- render
由于componentWillMount
和 componentWillReceiveProps
方法而改为静态方法 getDerivedStateFromProps
的原因吧。
commit 阶段
在effect
的 effectTag
,具体tag
不同,调用不同的更新方法。
- getSnapshotBeforeUpdate
- componentDidMount
- componentDidUpdate
- componentWillUnmount
P.S:注意区别
reconciler 、reconcile 和reconciliation ,reconciler 是调和器,是一个名词,可以说是React 工作的一个模块,协调模块;reconcile 是调和器调和的动作,是一个动词;而reconciliation 只是reconcile 过程的第一个阶段。
Fiber Tree 和WorkInProgress Tree
在后续的更新过程中(setState
这个链接的结构是怎么构成的呢,这就要主要到之前
// 单链表树结构
{
return: Fiber | null, // 指向父节点
child: Fiber | null,// 指向自己的第一个子节点
sibling: Fiber | null,// 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
}
每一个

对照图来看,是不是可以知道
当setState
的时候又是如何
采用的是一种叫双缓冲技术(double buffering
这样做的好处:
- 能够复用内部对象(fiber)
- 节省内存分配、
GC 的时间开销 - 就算运行中有错误,也不会影响
View 上的数据
每个alternate
属性,也指向一个alternate
,没有的话就创建一个。
创建
后记
本开始想一篇文章把
说实话,自己不是特别满意这篇,感觉头重脚轻,在讲协调之前写得还挺好的,但是在讲协调这块文字反而变少了,因为我是专门想写一篇文章讲协调的,所以这篇仅仅用来梳理整个流程。
但是梳理整个流程又发现
可以关注我的
一些问题
接下来留一些思考题。
- 如何去划分任务优先级?
- 在
reconcile 过程的render 阶段是如何去遍历链表,如何去构建workInProgress 的? - 当任务被打断,如何恢复?
- 如何去收集
EffectList ? - 针对不同的组件类型如何进行更新?
参考
详解Diff 过程
前言
我相信在看这篇文章的读者一般都已经了解过
如果你对标题不满意,请把文章看完,至少也得把文章最后的结论好好看下
在上一篇将
老的
Web UI 中DOM 节点跨层级的移动操作特别少,可以忽略不计。- 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
- 对于同一层级的一组子节点,它们可以通过唯一
id 进行区分。
基于以上三个前提策略,
具体老的算法可以见这篇文章:
说实话,老的
接下来就开始正式的讲解
Diff 简介
做
链表的每一个节点是
我这里说的虚拟
DOM 节点是指React.createElement 方法所产生的节点。虚拟DOM tree 只维护了组件状态以及组件与DOM 树的关系,Fiber Node 承载的东西比 虚拟DOM 节点多很多。
对于
TextNode( 包含字符串和数字) - 单个
React Element( 通过该节点是否有$$typeof 区分) - 数组
- 可迭代的
children ,跟数组的处理方式差不多
那么我们就来一步一步的看这四种类型是如何进行
前置知识介绍
这篇文章主要是从reconcileChildren
开始的
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderExpirationTime: ExpirationTime
) {
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderExpirationTime
);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderExpirationTime
);
}
}
reconcileChildren
只是一个入口函数,如果首次渲染,mountChildFibers
创建子节点的reconcileChildFibers
去做
接下来再看看
export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);
他们都是通过 ChildReconciler
函数来的,只是传递的参数不同而已。这个参数叫shouldTrackSideEffects
,他的作用是判断是否要增加一些effectTag
,主要是用来优化初次渲染的,因为初次渲染没有更新操作。
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
expirationTime: ExpirationTime
): Fiber | null {
// 主要的 Diff 逻辑
}
reconcileChildFibers
就是
参数介绍
returnFiber
是即将Diff 的这层的父节点。currentFirstChild
是当前层的第一个Fiber 节点。newChild
是即将更新的vdom 节点( 可能是TextNode 、可能是ReactElement ,可能是数组) ,不是Fiber 节点expirationTime
是过期时间,这个参数是跟调度有关系的,本系列还没讲解,当然跟Diff 也没有关系。
再次提醒,
reconcileChildFibers 是reconcile(diff) 的一层。
前置知识介绍完毕,就开始详细介绍每一种节点是如何进行
Diff TextNode
首先看
看下面两个小
// demo1:当前 ui 对应的节点的 jsx
return (
<div>
// ...
<div>
<xxx></xxx>
<xxx></xxx>
</div>
//...
</div>
);
// demo2:更新成功后的节点对应的 jsx
return (
<div>
// ...
<div>前端桃园</div>
//...
</div>
);
对应的单链表结构图:

对于
currentFirstNode 是TextNode currentFirstNode 不是TextNode
currentFirstNode 是当前该层的第一个节点,reconcileChildFibers 传进来的参数。
第一种情况。
源码如下:
if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
// We already have an existing node so let's just update it and delete
// the rest.
deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
const existing = useFiber(currentFirstChild, textContent, expirationTime);
existing.return = returnFiber;
return existing;
}
在源码里 useFiber
就是复用节点的方法,deleteRemainingChildren
就是删除剩余节点的方法,这里是从 currentFirstChild.sibling
开始删除的。
currentFirstChild
开始删掉剩余的节点,对应到上面的图中就是删除掉
对于源码如下:
deleteRemainingChildren(returnFiber, currentFirstChild);
const created = createFiberFromText(
textContent,
returnFiber.mode,
expirationTime
);
created.return = returnFiber;
其中 createFiberFromText
就是根据 textContent
来创建节点的方法。
注意:删除节点不会真的从链表里面把节点删除,只是打一个
delete 的tag ,当commit 的时候才会真正的去删除。
Diff React Element
有了上面
那么就有一个问题,如何判断这个节点是否可以复用呢?
有两个点:
如果以上两点相同,就代表这个节点只是变化了内容,不需要创建新的节点,可以复用的。
对应的源码如下:
if (child.key === key) {
if (
child.tag === Fragment
? element.type === REACT_FRAGMENT_TYPE
: child.elementType === element.type
) {
// 为什么要删除老的节点的兄弟节点?
// 因为当前节点是只有一个节点,而老的如果是有兄弟节点是要删除的,是多于的。删掉了之后就可以复用老的节点了
deleteRemainingChildren(returnFiber, child.sibling);
// 复用当前节点
const existing = useFiber(
child,
element.type === REACT_FRAGMENT_TYPE
? element.props.children
: element.props,
expirationTime,
);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
相信这些代码都很好理解了,除了判断条件跟前面
同样,如果节点的类型不相同,就将节点从当前节点开始把剩余的都删除。
deleteRemainingChildren(returnFiber, child);
到这里,可能你们就会觉得接下来应该就是讲解当没有可以复用的节点的时候是如果创建节点的。
不过可惜你们猜错了。因为
我们现在来看这种情况:

这种情况就是有可能更新的时候删除了一个节点,但是另外的节点还留着。
那么在对比
但是你看,明明
这种策略就是从
你有木有这样的问题:为什么
TextNode 不采用这样的循环策略来找可以复用的节点呢?这个问题留给你思考,欢迎在评论区留下你的答案。
对应的源码逻辑如下:
// 找到 key 相同的节点,就会复用当前节点
while (child !== null) {
if (child.key === key) {
if (
child.tag === Fragment
? element.type === REACT_FRAGMENT_TYPE
: child.elementType === element.type
) {
// 复用节点逻辑,省略该部分代码,和上面复用节点的代码相同
// code ...
return existing;
} else {
deleteRemainingChildren(returnFiber, child);
break;
}
} else {
// 如果没有可以复用的节点,就把这个节点删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
在上面这段代码我们需要注意的是,当
接下来才是我们前面说的,如果没有找到可以复用的节点,然后就重新创建节点,源码如下:
// 前面的循环已经把该删除的已经删除了,接下来就开始创建新的节点了
if (element.type === REACT_FRAGMENT_TYPE) {
const created = createFiberFromFragment(
element.props.children,
returnFiber.mode,
expirationTime,
element.key
);
created.return = returnFiber;
return created;
} else {
const created = createFiberFromElement(
element,
returnFiber.mode,
expirationTime
);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
对于createFiberFromFragment
传递的不是 element
,而是 element.props.children
。
Diff Array
因为
前面已经说了,
1. 相同位置(index) 进行比较
相同位置进行对比,这个是比较容易想到的一种方式,还是举个例子加深一下印象。

这已经是一个非常简单的例子了,
那如果判断节点是否可以复用呢?有了前面的
不过对于
const key = oldFiber !== null ? oldFiber.key : null;
前面的经验可得,判断是否可以复用,常常会根据
接下来再看
当
if (typeof newChild === "string" || typeof newChild === "number") {
// 对于新的节点如果是 string 或者 number,那么都是没有 key 的,
// 所有如果老的节点有 key 的话,就不能复用,直接返回 null。
// 老的节点 key 为 null 的话,代表老的节点是文本节点,就可以复用
if (key !== null) {
return null;
}
return updateTextNode(returnFiber, oldFiber, "" + newChild, expirationTime);
}
如果updateTextNode
方法。
注意,
updateTextNode 里面包含了首次渲染的时候的逻辑,首次渲染的时候回插入一个TextNode ,而不是复用。
当
if (typeof newChild === "object" && newChild !== null) {
// 有 $$typeof 代表就是 ReactElement
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
// ReactElement 的逻辑
}
case REACT_PORTAL_TYPE: {
// 调用 updatePortal
}
}
if (isArray(newChild) || getIteratorFn(newChild)) {
if (key !== null) {
return null;
}
return updateFragment(
returnFiber,
oldFiber,
newChild,
expirationTime,
null
);
}
}
首先判断是否是对象,用的是 typeof newChild === 'object' && newChild !== null
,注意要加 !== null
,因为 typeof null
也是
然后通过
我相信到这里应该对于应该对于如何相同位置的节点如何对比有清晰的认识了。另外还有问题,那就是如何循环一个一个对比呢?
这里要注意,新的节点的
那么循环一个一个对比,就是遍历数组的过程。
let newIdx = 0; // 新数组的索引
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 遍历老的节点
nextOldFiber = oldFiber.sibling;
// 返回复用节点的函数,newFiber 就是复用的节点。
// 如果为空,就代表同位置对比已经不能复用了,循环结束。
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
expirationTime
);
if (newFiber === null) {
break;
}
// 其他 code,比如删除复用的节点
}
这并不是源码的全部源码,我只是把思路给贴出来了。
这是第一次遍历新数组,通过调用 updateSlot
来对比新老元素,前面介绍的如何对比新老节点的代码都是在这个函数里。这个循环会把所以的从前面开始能复用的节点,都复用到。比如上面我们画的图,如果两个链表里面的
跳出来了,就会有两种情况。
- 新节点已经遍历完毕
- 老节点已经遍历完毕
2. 新节点已经遍历完毕
如果新节点已经遍历完毕的话,也就是没有要更新的了,这种情况一般就是从原来的数组里面删除了元素,那么直接把剩下的老节点删除了就行了。还是拿上面的图的例子举例,老的链表里
if (newIdx === newChildren.length) {
// 新的 children 长度已经够了,所以把剩下的删除掉
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
注意这里是直接 return
了哦,没有继续往下执行了。
3. 老节点已经遍历完毕
如果老的节点在第一次循环的时候就被复用完了,新的节点还有,很有可能就是新增了节点的情况。那么这个时候只需要根据把剩余新的节点直接创建
if (oldFiber === null) {
// 如果老的节点已经被复用完了,对剩下的新节点进行操作
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(
returnFiber,
newChildren[newIdx],
expirationTime
);
}
return resultingFirstChild;
}
oldFiber === null
就是用来判断老的
到这里,目前简单的对数组进行增、删节点的对比还是比较简单,接下来就是移动的情况是如何进行复用的呢?
4. 移动的情况如何进行节点复用
对于移动的情况,首先要思考,怎么能判断数组是否发生过移动操作呢?
如果给你两个数组,你是否能判断出来数组是否发生过移动。
答案是:老的数组和新的数组里面都有这个元素,而且位置不相同。
从两个数组中找到相同元素
把所有老数组元素按
function mapRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber
): Map<string | number, Fiber> {
const existingChildren: Map<string | number, Fiber> = new Map();
let existingChild = currentFirstChild; // currentFirstChild 是老数组链表的第一个元素
while (existingChild !== null) {
// 看到这里可能会疑惑怎么在 Map 里面的key 是 fiber 的key 还是 fiber 的 index 呢?
// 我觉得是根据数据类型,fiber 的key 是字符串,而 index 是数字,这样就能区分了
// 所以这里是用的 map,而不是对象,如果是对象的key 就不能区分 字符串类型和数字类型了。
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
这个 mapRemainingChildren
就是将老数组存放到
现在有了这个updateSlot
的复用逻辑很像,一个是从老数组链表中获取节点对比,一个是从
// 如果前面的算法有复用,那么 newIdx 就不从 0 开始
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
expirationTime
);
// 省略删除 existingChildren 中的元素和添加 Placement 副作用的情况
}
到这里新数组遍历完毕,也就是同一层的
效果演示
以下效果动态演示来自于文章:
这里渲染一个可输入的数组。

当第一种情况,新数组遍历完了,老数组剩余直接删除(12345→
新数组没完,老数组完了(1234→

移动的情况,即之前就存在这个元素,后续只是顺序改变(123 →

最后删除没有涉及的元素。
总结
对于数组的
我们可以把整个过程分为三个阶段:
- 第一遍历新数组,新老数组相同
index 进行对比,通过updateSlot
方法找到可以复用的节点,直到找到不可以复用的节点就退出循环。 - 第一遍历完之后,删除剩余的老节点,追加剩余的新节点的过程。如果是新节点已遍历完成,就将剩余的老节点批量删除;如果是老节点遍历完成仍有新节点剩余,则将新节点直接插入。
- 把所有老数组元素按
key 或index 放Map 里,然后遍历新数组,插入老数组的元素,这是移动的情况。
后记
刚开始阅读源码的过程是非常的痛苦的,但是当你一遍一遍的把作者想要表达的理解了,为什么要这么写 理解了,会感到作者的设计是如此的精妙绝伦,每一个变量,每一行代码感觉都是精心设计过的,然后感受到自己与大牛的差距,激发自己的动力。
更多的对于
我是桃翁,一个爱思考的前端