一、React Fiber 为什么出现
在 React15 以前,React 的组件更新创建虚拟 DOM 和 Diff 的过程是同步并且不可中断的 。
如果需要更新组件树层级非常深的话,在 Diff 的过程会非常占用浏览器的线程,而浏览器执行 JS 的线程和渲染真实 DOM 的线程是互斥的 (具体可以看一下这篇文章),也就是同一时间内,浏览器要么在执行 JS 的代码运算,要么在渲染页面,如果 JS 的代码运行时间过长则会造成页面卡顿。
二、React Fiber 是什么
基于以上原因 React 团队在 React16 之后就改写了整个架构,将原来数组结构的虚拟DOM,改成叫 Fiber 的一种数据结构 ,基于这种 Fiber 的数据结构可以实现由原来同步的不可中断的更新过程变成异步的可中断的更新。
对于 React Fiber 是什么,从架构角度来看,官方的解释是:React Fiber 是对核心算法的一次重新实现 。
从编码角度来看,Fiber 是 React 内部定义的一种数据结构,它是 Fiber 树结构的节点单位,也就是 React 16 新架构下的虚拟 DOM。
React Fiber 架构的核心是"可中断"、"可恢复"、"优先级"。
React Fiber 主要通过 FiberNode 的一些属性去保存组件相关的一些信息:
ts
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
/** 作为静态数据结构的属性 */
this.tag = tag; // 组件类型,如 Function/Class
this.key = key; // 唯一值,通常会在列表中使用
this.elementType = null;
this.type = null; // 元素类型,字符串或类或函数,如"div"/Class/ComponentFn
this.stateNode = null; // 指向真实 DOM 对象
// 靠以下属性连成一个树结构的数据,也就是 Fiber 链表
this.return = null; // 指向父级 Fiber 节点
this.child = null; // 指向子 Fiber 节点的第一个
this.sibling = null; // 指向兄弟 Fiber 节点
this.index = 0;
this.ref = null;
// 作为动态的工作单元的属性
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 指向该fiber在另一次更新时对应的fiber
this.alternate = null;
}
比如:
jsx
function App() {
return (
<div className="app">
<span>Hello</span>, World!
</div>
)
}
形成的 Fiber 树为:
三、React Fiber 做了什么
之前,递归渲染 vdom,然后 diff 下来做 patch(补丁) 的渲染,整个渲染和 diff 是递归进行的:
现在 ,是先把 vdom 转为 fiber(reconcile 调和的过程),因为 fiber 是链表结构,可以打断,空闲时调度(requestIdleCallback
)就行,最后,全部转换完之后,再一次性 render,这个过程叫 commit 阶段:
React16 的架构分为三层:
- Scheduler(调度器):调度任务的优先级,高优先级的任务优先进入 Reconciler。
- Reconciler(协调器):负责找出变化的组件。
- Renderer(渲染器):负责将变化的组件渲染到页面上。
在 React16 版本中,主要做了以下的操作:
- 做了时间分片 ,拆分了多个任务,并且为每个任务增加了优先级 ,优先级高的任务可以中断低优先级的任务。然后再重新执行优先级低的任务。
- 增加了异步任务 ,调用
requestIdleCallback
api,在浏览器空闲的时候执行 - 使用了双缓存 Fiber 树,DOM diff树变成了链表,一个 DOM 对应两个 fiber,对应两个队列,这都是为找到被中断的任务,重新执行。
任务优先级
- NoPriority:无优先级
- ImmediatePriority:立即执行
- UserBlockingPriority:用户阻塞优先级,不执行可能会导致用户交互阻塞
- NormalPriority:普通优先级
- LowPriority:低优先级
- IdlePriority:空闲优先级
requestIdleCallback
requestIdleCallback
是一个高级的调度方法,用于在浏览器空闲时执行任务。
它会在浏览器的主事件循环空闲时执行指定的回调函数,以避免阻塞用户交互和其他高优先级任务。
requestIdleCallback
的回调函数将提供一个 IdleDeadline
参数,可以用于判断剩余的空闲时间,并根据需要,执行任务的片段。
js
function performTask(deadline) {
while (deadline.timeRemaining() > 0) {
// 在空闲时间内执行你的操作
}
// 如果任务没有完成,继续请求下一次空闲回调
requestIdleCallback(performTask);
}
// 启动空闲回调
requestIdleCallback(performTask);
// 停止空闲回调
var idleCallbackId = requestIdleCallback(performTask);
cancelIdleCallback(idleCallbackId);
双缓存 Fiber 树
在 React 中,最多会同时存在两棵 Fiber 树。当前屏幕上显示内容对应的 Fiber 树称为 current Fiber 树
,正在内存中构建的 Fiber 树称为 workInProgress Fiber 树
。
current Fiber 树
中的 Fiber 节点被称为 current fiber
,workInProgress Fiber 树
中的 Fiber 节点被称为 workInProgress fiber
,它们之间通过 alternate
属性连接。
当 workInProgress Fiber 树
构建完成交给 Renderer
渲染在页面上后,应用根节点的 current
指针指向 workInProgress Fiber 树
,此时 workInProgress Fiber 树
就变成 current Fiber 树
。
每次状态更新都会产生新的 workInProgress Fiber 树
,通过 current
和 workInProgress
的替换,完成 DOM 更新。
双缓存 Fiber 树在 mount
阶段的构建流程
jsx
function App() {
const [num, setNum] = useState(0);
return <p onClick={() => setNum(num + 1)}>{num}</p>;
}
ReactDOM.render(<App />, document.getElementById("root"));
- 首次执行
ReactDOM.render
会创建fiberRootNode
(源码中叫fiberRoot
)和rootFiber
。fiberRootNode
是整个应用的根节点(只有一个),rootFiber
是<App />
所在组件树的根节点。
由于是首屏渲染,页面中还没有挂载任何 DOM,所以fiberRootNode.current
指向的rootFiber
没有任何子 Fiber
节点(即current Fiber 树
为空)。
- 接下来进入
render
阶段,根据组件返回的 JSX,在内存中依次创建Fiber
节点并连接在一起构建Fiber
树,被称为workInProgress Fiber 树
。
workInProgress Fiber 树
的创建可以复用current Fiber 树
对应的节点数据。 在下面的 diff 算法中会说明如何判断是否可复用。
- 将已经构建完的
workInProgress Fiber 树
在 commit 阶段渲染到页面。使得workInProgress Fiber 树
变为current Fiber 树
。
双缓存 Fiber 树在 update
阶段的更新流程
- 点击 p 标签触发状态更新,会开启一次新的 render 阶段,并构建一棵新的
workInProgress Fiber 树
。
- 将已经构建完的
workInProgress Fiber 树
在 commit 阶段渲染到页面。使得workInProgress Fiber 树
变为current Fiber 树
。
React Diff 算法
一个
DOM 节点
在某一时刻最多会有4个节点和它相关:
current Fiber
,如果该DOM 节点
已经在页面上,current Fiber
代表该DOM 节点
对应的 Fiber 节点。workInProgress Fiber
,如果该DOM 节点
将在本次更新中渲染到页面上,那么workInProgress Fiber
代表该DOM 节点
对应的Fiber 节点
。DOM 节点
本身。- JSX 对象 ,即类组件或函数组件返回的结果,JSX 对象中包含描述
DOM 节点
的信息。Diff 算法的本质是对比 1 和 4,生成 2。
为了降低算法复杂度,React 的 Diff 算法会预设三个限制:
-
只对同级元素进行 diff。如果一个 DOM 节点在前后两次更新时跨越了节点,那么 React 不会复用它。
-
两个不同类型的元素会产生不同的树 。如果元素从
div
变成p
,那么 React 会销毁div
及其子孙节点,并新建p
及其子孙节点。 -
可通过
key
来表示哪些子元素在不同的渲染情况下能保持稳定。jsx// 更新前 <p key="hello">hello</p> <div key="world">world</div> // 更新后 <div key="world">world</div> <p key="hello">hello</p>
如果没有
key
,则符合 2 的限定。
但如果我们使用key
指明了节点前后的对应关系后,React 知道key="hello"
的p
标签在更新后还存在,那么DOM 节点
可复用,只是需要交换一下顺序。
从同级的数量类型可将 Diff 分为两类:
- newChild 类型为 object、number、string,代表同级只有一个节点;
- newChild 类型为 Array,代表同级有多个节点。
单节点 Diff
如何判断 DOM 节点是否可复用?
-
key 不同 :
jsx// 更新前 <ul> <li key="one">one</li> <li key="two">two</li> <li key="three">two</li> </ul> // 更新后 <ul> <p key="two">three</p> </ul>
key="one"
的 li 标签和key="two"
的 p 标签,仅表示遍历到的该 fiber不能被 p 复用,后面还有兄弟 fiber 没有遍历到,所以只需要标记删除该 fiber 节点。 -
key 相同,type 不同 :
jsx// 更新前 <ul> <li key="two">two</li> <li key="three">two</li> </ul> // 更新后 <ul> <p key="two">three</p> </ul>
key="two"
的 li 标签和key="two"
的 p 标签,type 不同,表示唯一的可能性不能复用了,那么后续的兄弟 fiber 也没机会了,所以都可以标记清楚。
多节点 diff
针对多节点的更新,会有以下三种情况:
-
节点更新
jsx// 更新前 <ul> <li key="0" className="before">0</li> <li key="1">1</li> </ul> // 更新后 - 情况1 节点属性发生变化 <ul> <li key="0" className="after">0</li> <li key="1">1</li> </ul> // 更新后 - 情况2 节点类型更新 <ul> <li key="0">0</li> <li key="1">1</li> </ul>
-
节点新增或减少
jsx// 更新前 <ul> <li key="0">0</li> <li key="1">1</li> </ul> // 更新后 - 情况1 新增节点 <ul> <li key="0">0</li> <li key="1">1</li> <li key="2">2</li> </ul> // 更新后 - 情况2 删除节点 <ul> <li key="1">1</li> </ul>
-
节点位置变化
jsx// 更新前 <ul> <li key="0">0</li> <li key="1">1</li> </ul> // 更新后 <ul> <li key="1">1</li> <li key="0">0</li> </ul>
多节点 diff更新过程如下 :
第一轮遍历:
let i = 0
,遍历newChildren
,将newChildren[0]
与oldFiber
比较,判断oldFiber
是否可复用;- 如果可复用,
i++
,继续比较newChildren[i]
与oldFiber.sibling
,如果可复用,则继续遍历; - 如果不可复用:
- key 不同导致不可复用,立刻跳出整个遍历,第一轮遍历结束;
- key 相同而 type 不同导致不可复用,将
oldFiber
标记删除,继续遍历。
- 如果
newChildren
遍历完(i === newChildren.length - 1
)或者oldFiber
遍历完(oldFiber.sibling === null
),跳出遍历,第一轮遍历结束。
从上述步骤 3 中跳出,此时newChildren
没有遍历完,oldFiber
也没有遍历完,如下:
jsx
// 更新前
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
// 更新后
<li key="0">0</li>
<li key="2">1</li>
<li key="1">2</li>
`遍历到 key === 2 时,发现更新前后 key 不同,不可复用,跳出第一轮遍历;`
`此时 oldFiber 剩下 key = 1、key = 2 未遍历,newChildren 剩下 key = 2、key = 1 未遍历`
从上述步骤 4 中跳出,可能newChildren
遍历完,或者oldFiber
遍历完,或者两个都遍历完,如下:
jsx
// 更新前
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
// 更新后 - 情况1 newChildren 和 oldFiber 都遍历完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
// 更新后 - 情况2 newChildren 未遍历完,oldFiber 遍历完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
<li key="2" className="cc">2</li>
// 更新后 - 情况3 newChildren 遍历完,oldFiber 未遍历完
<li key="0" className="aa">0</li>
第二轮遍历:
-
newChildren 和 oldFiber 都遍历完:
在第一轮遍历结束后更新组件,Diff 结束;
-
newChildren 没遍历完,oldFiber 遍历完:
表示有新增节点,只需要将剩下的 newChildren 生成
workInProgress Fiber
,并依次标记新增(Placement); -
newChildren 遍历完,oldFiber 没遍历完:
表示有节点被删除,只需要遍历剩下的
oldFiber
,依次标记删除(Deletion); -
newChildren 和 oldFiber 都没遍历完:
表示有节点在本次更新中改变了位置。 声明一个变量:
jslet lastPlacedIndex = 0; // 表示最后一个可复用的节点在 oldFiber 中的位置索引
四、面试题
key 的作用
在 diff 算法中通过 key 和 type 判断 DOM 节点是否可复用。
key 的值不能是 index 或 random。
React diff 算法为什么不支持双指针?
虽然 JSX 对象的 newChildren 为数组类型,同层级的 fiber 节点之间是通过 sibling
指针链接成的单链表,不支持双指针遍历。
即 newChildren[0]
和 fiber
比较,newChildren[1]
与 fiber.sibling
比较。
所以,React diff 算法不支持双指针。
如有问题,欢迎指正~