本文转载自这可能是最通俗的 React Fiber(时间分片) 打开方式这篇文章。
为什么要引入 Fiber 架构
React 为什么要引入 Fiber 架构? 看看下面的火焰图,这是React V15 下面的一个列表渲染资源消耗情况。整个渲染花费了130ms。🔴 在这里面 React 会递归比对VirtualDOM树,找出需要变动的节点,然后同步更新它们, 一气呵成。这个过程 React 称为 Reconcilation(中文可以译为协调)。
在 Reconcilation 期间,React 会霸占着浏览器资源,一则会导致用户触发的事件得不到响应, 二则会导致掉帧,用户可以感知到这些卡顿。
同步模式下的 React:
优化后的 Concurrent 模式下的 React:
React 的 Reconcilation 是CPU密集型的操作, 它就相当于我们上面说的’长进程‘。所以初衷和进程调度一样,我们要让高优先级的进程或者短进程优先运行,不能让长进程长期霸占资源。
所以React 是怎么优化的?🔴 为了给用户制造一种应用很快的'假象',我们不能让一个程序长期霸占着资源. 你可以将浏览器的渲染、布局、绘制、资源加载(例如HTML解析)、事件响应、脚本执行视作操作系统的'进程',我们需要通过某些调度策略合理地分配CPU资源,从而提高浏览器的用户响应速率, 同时兼顾任务执行效率。
🔴 所以 React 通过 Fiber 架构,让自己的Reconcilation 过程变成可被中断。适时地让出CPU执行权,除了可以让浏览器及时地响应用户的交互,还有其他好处:
- 与其一次性操作大量 DOM 节点相比, 分批延时对DOM进行操作,可以得到更好的用户体验。
这就是为什么React 需要 Fiber。
何为 Fiber
对于 React 来说,Fiber 可以从两个角度理解:
一种流程控制语
Fiber 也称协程、或者纤程。
**其实协程和线程并不一样,协程本身是没有并发或者并行能力的(需要配合线程),它只是一种控制流程的让出机制。**要理解协程,你得和普通函数一起来看, 以Generator为例:
普通函数执行的过程中无法被中断和恢复
:
const tasks = []
function run() {
let task
while (task = tasks.shift()) {
execute(task)
}
}
而 Generator
可以:
const tasks = []
function * run() {
let task
while (task = tasks.shift()) {
// 🔴 判断是否有高优先级事件需要处理, 有的话让出控制权
if (hasHighPriorityEvent()) {
yield
}
// 处理完高优先级事件后,恢复函数调用栈,继续执行...
execute(task)
}
}
React Fiber 的思想和协程的概念是契合的: 🔴 React 渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。
那么现在你应该有以下疑问:
1️⃣ 浏览器没有抢占的条件, 所以React只能用让出机制?
2️⃣ 怎么确定有高优先任务要处理,即什么时候让出?
3️⃣ React 那为什么不使用 Generator?
答1️⃣: 没错, React主动让出。
一是浏览器中没有类似进程的概念,任务之间的界限很模糊,没有上下文,所以不具备中断/恢复的条件。二是没有抢占的机制,我们无法中断一个正在执行的程序。
所以我们只能采用类似协程这样控制权让出机制。这个和上文提到的进程调度策略都不同,它有更一个专业的名词:合作式调度(Cooperative Scheduling), 相对应的有抢占式调度(Preemptive Scheduling)。
这是一种’契约‘调度,要求我们的程序和浏览器紧密结合,互相信任。 比如可以由浏览器给我们分配执行时间片(通过requestIdleCallback实现, 下文会介绍),我们要按照约定在这个时间内执行完毕,并将控制权还给浏览器。
答2️⃣: requestIdleCallback API
上面代码示例中的 hasHighPriorityEvent()
在目前浏览器中是无法实现的,我们没办法判断当前是否有更高优先级的任务等待被执行。
只能换一种思路,通过超时检查的机制来让出控制权。解决办法是: 确定一个合理的运行时长,然后在合适的检查点检测是否超时(比如每执行一个小任务),如果超时就停止执行,将控制权交换给浏览器。
举个例子,为了让视图流畅地运行,可以按照人类能感知到最低限度每秒60帧的频率划分时间片,这样每个时间片就是 16ms。
其实浏览器提供了相关的接口 —— requestIdleCallback
API:
window.requestIdleCallback(
callback: (dealine: IdleDeadline) => void,
option?: {timeout: number}
)
IdleDeadline
的接口如下:
interface IdleDealine {
didTimeout: boolean // 表示任务执行是否超过约定时间
timeRemaining(): DOMHighResTimeStamp // 任务可供执行的剩余时间
}
单从名字上理解的话,requestIdleCallback
的意思是让浏览器在'有空'的时候就执行我们的回调,这个回调会传入一个期限,表示浏览器有多少时间供我们执行, 为了不耽误事,我们最好在这个时间范围内执行完毕。
那浏览器什么时候有空?
我们先来看一下浏览器在一帧(Frame,可以认为事件循环的一次循环)内可能会做什么事情:
浏览器在一帧内可能会做执行下列任务,而且它们的执行顺序基本是固定的:
- 处理用户输入事件
- Javascript执行
- requestAnimation 调用
- 布局 Layout
- 绘制 Paint
上面说理想的一帧时间是 16ms
(1000ms / 60),如果浏览器处理完上述的任务(布局和绘制之后),还有盈余时间,浏览器就会调用 requestIdleCallback
的回调。例如:
但是在浏览器繁忙的时候,可能不会有盈余时间,这时候requestIdleCallback回调可能就不会被执行。 为了避免饿死,可以通过requestIdleCallback的第二个参数指定一个超时时间。
另外不建议在requestIdleCallback中进行DOM操作
,因为这可能导致样式重新计算或重新布局
(比如操作DOM后马上调用 getBoundingClientRect),这些时间很难预估的,很有可能导致回调执行超时,从而掉帧。
目前 requestIdleCallback
目前只有Chrome支持。所以目前 React 自己实现了一个。它利用MessageChannel
模拟将回调延迟到'绘制操作'之后执行。
任务优先级
上面说了,为了避免任务被饿死,可以设置一个超时时间. 这个超时时间不是死的,低优先级的可以慢慢等待, 高优先级的任务应该率先被执行. 目前 React 预定义了 5 个优先级, 这个我在[《谈谈React事件机制和未来(react-events)》]中也介绍过:
Immediate(-1)
- 这个优先级的任务会同步执行, 或者说要马上执行且不能中断UserBlocking(250ms)
- 这些任务一般是用户交互的结果, 需要即时得到反馈Normal (5s)
- 应对哪些不需要立即感受到的任务,例如网络请求Low (10s)
- 这些任务可以放后,但是最终应该得到执行. 例如分析通知Idle (没有超时时间)
- 一些没有必要做的任务 (e.g. 比如隐藏的内容), 可能会被饿死
答3️⃣: 太麻烦
-
Generator 不能在栈中间让出。比如你想在嵌套的函数调用中间让出, 首先你需要将这些函数都包装成Generator,另外这种栈中间的让出处理起来也比较麻烦,难以理解。除了语法开销,现有的生成器实现开销比较大,所以不如不用。
-
Generator 是有状态的, 很难在中间恢复这些状态。
一个执行单元
Fiber的另外一种解读是“纤维”: 这是一种数据结构或者说执行单元
。我们暂且不管这个数据结构长什么样,🔴将它视作一个执行单元,每次执行完一个“执行单元”, React 就会检查现在还剩多少时间,如果没有时间就将控制权让出去。
上文说了,React 没有使用 Generator 这些语言/语法层面的让出机制,而是实现了自己的调度让出机制。这个机制就是基于’Fiber‘这个执行单元的,它的过程如下:
假设用户调用 setState
更新组件, 这个待更新的任务会先放入队列中, 然后通过 requestIdleCallback
请求浏览器调度:
updateQueue.push(updateTask);
requestIdleCallback(performWork, {timeout});
现在浏览器有空闲或者超时了就会调用 performWork
来执行任务:
// 1️⃣ performWork 会拿到一个Deadline,表示剩余时间
function performWork(deadline) {
// 2️⃣ 循环取出updateQueue中的任务
while (updateQueue.length > 0 && deadline.timeRemaining() > ENOUGH_TIME) {
workLoop(deadline);
}
// 3️⃣ 如果在本次执行中,未能将所有任务执行完毕,那就再请求浏览器调度
if (updateQueue.length > 0) {
requestIdleCallback(performWork);
}
}
workLoop
的工作大概猜到了,它会从更新队列(updateQueue)中弹出更新任务来执行,每执行完一个“执行单元”,就检查一下剩余时间是否充足,如果充足就进行执行下一个执行单元,反之则停止执行,保存现场,等下一次有执行权时恢复。
// 保存当前的处理现场
let nextUnitOfWork: Fiber | undefined // 保存下一个需要处理的工作单元
let topWork: Fiber | undefined // 保存第一个工作单元
function workLoop(deadline: IdleDeadline) {
// updateQueue中获取下一个或者恢复上一次中断的执行单元
if (nextUnitOfWork == null) {
nextUnitOfWork = topWork = getNextUnitOfWork();
}
// 🔴 每执行完一个执行单元,检查一次剩余时间
// 如果被中断,下一次执行还是从 nextUnitOfWork 开始处理
while (nextUnitOfWork && deadline.timeRemaining() > ENOUGH_TIME) {
// 下文我们再看performUnitOfWork
nextUnitOfWork = performUnitOfWork(nextUnitOfWork, topWork);
}
// 提交工作,下文会介绍
if (pendingCommit) {
commitAllWork(pendingCommit);
}
}
React的 Fiber 改造
1. 数据结构的调整
左侧是Virtual DOM,右侧可以看作diff的递归调用栈。
上文中提到 React 16 之前,Reconcilation 是同步的、递归执行的
。也就是说这是基于函数’调用栈‘的Reconcilation算法,因此通常也称它为Stack Reconcilation
。
栈挺好的,代码量少,递归容易理解, 至少比现在的 React Fiber 架构好理解, 递归非常适合树这种嵌套数据结构的处理。
只不过这种依赖于调用栈的方式不能随意中断、也很难被恢复, 不利于异步处理。这种调用栈,不是程序所能控制的,如果你要恢复递归现场,可能需要从头开始, 恢复到之前的调用栈。
因此首先我们需要对React现有的数据结构进行调整,模拟函数调用栈
, 将之前需要递归进行处理的事情分解成增量的执行单元,将递归转换成迭代。
React 目前的做法是使用链表
, 每个 VirtualDOM
节点内部现在使用 Fiber 表示, 它的结构大概如下:
export type Fiber = {
// Fiber 类型信息
type: any,
// ...
// ⚛️ 链表结构
// 指向父节点,或者render该节点的组件
return: Fiber | null,
// 指向第一个子节点
child: Fiber | null,
// 指向下一个兄弟节点
sibling: Fiber | null,
}
用图片来展示这种关系会更直观一些:
使用链表结构只是一个结果,而不是目的,React 开发者一开始的目的是冲着模拟调用栈去的。
调用栈最经常被用于存放子程序的返回地址。在调用任何子程序时,主程序都必须暂存子程序运行完毕后应该返回到的地址。因此,如果被调用的子程序还要调用其他的子程序,其自身的返回地址就必须存入调用栈,在其自身运行完毕后再行取回。除了返回地址,还会保存
本地变量
、函数参数
、环境传递
。
React Fiber 也被称为虚拟栈帧(Virtual Stack Frame), 你可以拿它和函数调用栈类比一下, 两者结构非常像:
函数调用栈 | Fiber | |
---|---|---|
基本单位 | 函数 | Virtual DOM 节点 |
输入 | 函数参数 | Props |
本地状态 | 本地变量 | State |
输出 | 函数返回值 | React Element |
下级 | 嵌套函数调用 | 子节点(child) |
上级引用 | 返回地址 | 父节点(return) |
Fiber 和调用栈帧一样, 保存了节点处理的上下文信息,因为是手动实现的,所以更为可控,我们可以保存在内存中,随时中断和恢复。
有了这个数据结构调整,现在可以以迭代的方式来处理这些节点了。来看看 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;
}
}
你可以配合上文的 workLoop 一起看,Fiber 就是我们所说的工作单元,performUnitOfWork 负责对 Fiber 进行操作,并按照深度遍历的顺序返回下一个 Fiber。
因为使用了链表结构,即使处理流程被中断了,我们随时可以从上次未处理完的Fiber继续遍历下去。
整个迭代顺序和之前递归的一样, 下图假设在 div.app 进行了更新:
比如你在text(hello)中断了,那么下一次就会从 p 节点开始处理。
这个数据结构调整还有一个好处,就是某些节点异常时,我们可以打印出完整的“节点栈”,只需要沿着节点的return
回溯即可。
2. 两个阶段的拆分
除了Fiber 工作单元的拆分,两阶段的拆分也是一个非常重要的改造,在此之前都是一边Diff一边提交的。先来看看这两者的区别:
-
⚛️ 协调阶段: 可以认为是 Diff 阶段, 这个阶段可以被中断, 这个阶段会找出所有节点变更,例如节点新增、删除、属性变更等等,这些变更React 称之为'副作用(Effect)'。以下生命周期钩子会在协调阶段被调用:
- constructor
- componentWillMount 废弃
- componentWillReceiveProps 废弃
- static getDerivedStateFromProps
- shouldComponentUpdate
- componentWillUpdate 废弃
- render
-
⚛️ 提交阶段: 将上一个阶段计算出来的需要处理的 副作用(Effects) 一次性执行了。这个阶段必须同步执行,不能被打断。这些生命周期钩子在提交阶段被执行:
- getSnapshotBeforeUpdate() 严格来说,这个是在进入 commit 阶段前调用
- componentDidMount
- componentDidUpdate
- componentWillUnmount
也就是说,在协调阶段如果时间片用完,React就会选择让出控制权。因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。
需要注意的是:因为协调阶段可能被中断、恢复,甚至重做,React 协调阶段的生命周期钩子可能会被调用多次!!! 例如 componentWillMount 可能会被调用两次。
因此建议 协调阶段的生命周期钩子不要包含副作用。索性 React 就废弃了这部分可能包含副作用的生命周期方法,例如componentWillMount
、componentWillUpdate
。v17后我们就不能再用它们了, 所以现有的应用应该尽快迁移.
现在你应该知道为什么“提交阶段”必须同步执行,不能中断的吧? 因为我们要正确地处理各种副作用,包括DOM变更、还有你在componentDidMount中发起的异步请求、useEffect 中定义的副作用... 因为有副作用,所以必须保证按照次序只调用一次,况且会有用户可以察觉到的变更, 不容出错。
3. Reconcilation
接下来就是就是我们熟知的Reconcilation
(为了方便理解,本文不区分Diff和Reconcilation, 两者是同一个东西)阶段了. 思路和 Fiber 重构之前差别不大, 只不过这里不会再递归去比对、而且不会马上提交变更。
首先再进一步看一下Fiber的结构:
interface Fiber {
/**
* ⚛️ 节点的类型信息
*/
// 标记 Fiber 类型, 例如函数组件、类组件、宿主组件
tag: WorkTag,
// 节点元素类型, 是具体的类组件、函数组件、宿主组件(字符串)
type: any,
/**
* ⚛️ 结构信息
*/
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,
// 子节点的唯一键, 即我们渲染列表传入的key属性
key: null | string,
/**
* ⚛️ 节点的状态
*/
// 节点实例(状态):
// 对于宿主组件,这里保存宿主组件的实例, 例如DOM节点。
// 对于类组件来说,这里保存类组件的实例
// 对于函数组件说,这里为空,因为函数组件没有实例
stateNode: any,
// 新的、待处理的props
pendingProps: any,
// 上一次渲染的props
memoizedProps: any, // The props used to create the output.
// 上一次渲染的组件状态
memoizedState: any,
/**
* ⚛️ 副作用
*/
// 当前节点的副作用类型,例如节点更新、删除、移动
effectTag: SideEffectTag,
// 和节点关系一样,React 同样使用链表来将所有有副作用的Fiber连接起来
nextEffect: Fiber | null,
/**
* ⚛️ 替身
* 指向旧树中的节点
*/
alternate: Fiber | null,
}
Fiber 包含的属性可以划分为 5 个部分:
-
🆕 结构信息 - 这个上文我们已经见过了,Fiber 使用链表的形式来表示节点在树中的定位。
-
节点类型信息 - 这个也容易理解,tag表示节点的分类、type 保存具体的类型值,如div、MyComp。
-
节点的状态 - 节点的组件实例、props、state等,它们将影响组件的输出。
-
🆕 副作用 - 这个也是新东西. 在 Reconciliation 过程中发现的“副作用”(变更需求)就保存在节点的
effectTag
中(想象为打上一个标记)。那么怎么将本次渲染的所有节点副作用都收集起来呢? 这里也使用了链表结构,在遍历过程中React会将所有有‘副作用’的节点都通过nextEffect
连接起来。 -
🆕 替身 - React 在 Reconciliation 过程中会构建一颗新的树(官方称为workInProgress tree,WIP树),可以认为是一颗表示当前工作进度的树。还有一颗表示已渲染界面的旧树,React就是一边和
旧树
比对,一边构建WIP树
的。 alternate 指向旧树的同等节点。
现在可以放大看看beginWork 是如何对 Fiber 进行比对的:
function beginWork(fiber: Fiber): Fiber | undefined {
if (fiber.tag === WorkTag.HostComponent) {
// 宿主节点diff
diffHostComponent(fiber)
} else if (fiber.tag === WorkTag.ClassComponent) {
// 类组件节点diff
diffClassComponent(fiber)
} else if (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的基本原理已经足够了。
上图是 Reconciliation
完成后的状态,左边是旧树,右边是WIP树。对于需要变更的节点,都打上了“标签”。 在提交阶段,React 就会将这些打上标签的节点应用变更。
4. 双缓冲
WIP 树
构建这种技术类似于图形化领域的“双缓存(Double Buffering)”技术, 图形绘制引擎一般会使用双缓冲技术,先将图片绘制到一个缓冲区,再一次性传递给屏幕进行显示,这样可以防止屏幕抖动,优化渲染性能。
WIP 树构建这种技术类似于图形化领域的'双缓存(Double Buffering)'技术, 图形绘制引擎一般会使用双缓冲技术,先将图片绘制到一个缓冲区,再一次性传递给屏幕进行显示,这样可以防止屏幕抖动,优化渲染性能。
双缓存技术还有另外一个重要的场景就是异常的处理,比如当一个节点抛出异常,仍然可以继续沿用旧树的节点,避免整棵树挂掉。
你可以将 WIP 树想象成从旧树中 Fork 出来的功能分支,你在这新分支中添加或移除特性,即使是操作失误也不会影响旧的分支。当你这个分支经过了测试和完善,就可以合并到旧分支,将其替换掉. 这或许就是’提交(commit)阶段‘的提交一词的来源吧?
5. 副作用的收集和提交
接下来就是将所有打了 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
}
开启 Concurrent Mode
后,我们可以得到以下好处(详见Concurrent Rendering in React):
- 快速响应用户操作和输入,提升用户交互体验。
- 让动画更加流畅,通过调度,可以让应用保持高帧率。
- 利用好
I/O
操作空闲期或者CPU空闲期,进行一些预渲染。比如离屏(offscreen)不可见的内容,优先级最低,可以让 React 等到CPU空闲时才去渲染这部分内容。这和浏览器的preload等预加载技术差不多。 - 用
Suspense
降低加载状态(load state)的优先级,减少闪屏。比如数据很快返回时,可以不必显示加载状态,而是直接显示出来,避免闪屏;如果超时没有返回才显式加载状态。