Fiber 架构的应用目的,按照 React 官方的说法,是实现增量渲染。我们在上一篇文章里也有对增量渲染做出过简单解释,所谓增量渲染,通俗来说就是把一个渲染任务分解为多个渲染任务,而后将其分散到多个帧里面。不过严格来说,增量渲染其实也只是一种手段,实现增量渲染的目的,是为了实现任务的可中断、可恢复,并给不同的任务赋予不同的优先级,最终达成更加顺滑的用户体验。
从 Fiber 架构的设计思路来看,Fiber 架构的核心思想就是可中断、可恢复与优先级。要实现 Fiber 架构,必须要解决以下 3 个问题:
- 任务碎片化;
- 时间分片;
- 在浏览器空闲的时候执行;
这一切的实现是在代码层引入了一个新的数据结构:Fiber 对象。
1. Fiber 对象
为什么要引入新的数据结构呢?
这是因为 React 15 架构中使用树形结构串联整棵虚拟 DOM 树,并且采用递归方式对虚拟 DOM 树进行层层遍历。虽然使用递归的方式进行树的遍历操作非常直观和易于理解,但是正如我们发现,这种方式也是有局限性的,尤其是当数据量大了以后,无论是内存还是时间,递归的弊端就会很明显。并且更重要的是,递归遍历树会让我们无法分解工作为增量单元。也就是说,在这种遍历方式下,React 只能不断迭代直到它处理完所有组件,我们不能暂停特定组件的工作并在稍后恢复。你可能会说,要实现可中断的遍历好办呀,不用递归改用循环遍历就能满足中断这个要求。中断机制最重要的是考虑现场保护和现场还原,但是对于树形结构而言,就算改用非递归形式的循环遍历可以支持中断遍历过程,如果没有复杂的辅助数据结构帮助我们记录遍历顺序,那么当遍历到某个节点中断,后续再重启时是不知道下一个要遍历哪个节点的,也就是说,这种做法会让现场保护变得特别复杂。
那有没有更好的算法可以实现遍历过程可中断可恢复呢?
React 官方团队给出的答案是把整棵虚拟 DOM 树拍扁,用链表的形式描述树结构。这样做的好处是无需维护多余的变量记录维护遍历顺序,因为链表可以包含多个指针,让我们轻松找到下一个节点,从而恢复任务执行,非常利于暂停和重启。比如遍历到某个节点的时候需要暂停,那么只要记录当前指针,等到重启的时候指向下一个节点就可以了。用链表的形式描述树结构意味着需要定义不同于树节点结构的链表节点结构,因此 React 引入了新的 Fiber 对象来作为链表的节点单位。
Fiber 对象是一个 Javascript 对象,它实质上就是我们在前面提到的虚拟 DOM,只不过在新的架构中虚拟 DOM 逐渐被 Fiber 这一正式称呼所代替。Fiber 对象包含 3 个指针,分别为 return、child 和 sibling。其中 return 指向父 Fiber 节点,child 指向大儿 Fiber 节点,sibling 指向兄弟 Fiber 节点。也就是说,这 3 个指针连接父子兄弟节点以构成一颗单链表 fiber 树,其扁平化的单链表结构的特点将以往递归遍历改为了循环遍历,实现深度优先遍历。这里你可能会有些疑惑,为什么通过 return、child 和 sibling 连接而成的结构不叫 Fiber 链表而还是被称为 Fiber 树呢?实际上,这不是严格意义上的链表,它仍然是一个树的结构,你可以理解为树的链表实现。一个 Fiber 节点对应一个元素节点,我们用一个简单的示例来说明 Fiber node 之间的这种 relationship:
以上图为例,一般我们会认为 div 有三个 children,分别是 h1、h2 与 h3,但 在fiber 架构下只会把第一个children 当作 child,其他 children 则使用 child 的 sibling 来记录,并且透过 return 指回 parent node。
开篇中有提出一个问题,Fiber 架构是如何将更新任务碎片化拆分成任务单元的,该问题的答案其实就是 Fiber Node。也就是说,Fiber Node 不仅仅是一种数据节点结构,它还同时被作为 React 的任务拆分单位,一个 Fiber Node 对应着一个工作执行单元,或者说每个任务单元只负责一个节点的处理。
对于每个 Fiber 节点对象来说,它除了记录上述 3 个重要的指针外,还会记录一些其他的必要信息,比如作为静态数据结构来说,其对应的 DOM 节点基本信息,以及作为工作单元来说一些用于任务调度的信息。Fiber Node 的具体定义如下:
// packages/react-reconciler/src/ReactInternalTypes.js
export type Fiber = {|
// 作为静态数据结构,存储节点 dom 相关信息
tag: WorkTag, // 组件的类型,取决于 react 的元素类型
key: null | string,
elementType: any, // 元素类型
type: any, // 定义与此fiber关联的功能或类。对于组件,它指向构造函数;对于DOM元素,它指定HTML tag
stateNode: any, // 真实 dom 节点
// fiber 链表树相关, 主要
return: Fiber | null, // 指向他在 Fiber 节点树中的`parent`,用来在处理完这个节点之后向上返回
child: Fiber | null, // 指向自己的第一个子节点
sibling: Fiber | null, // 指向自己的兄弟节点,兄弟节点的 return 指向同一个父节点
index: number, // 在父 fiber 下面的子 fiber 中的下标
ref:
| null
| (((handle: mixed) => void) & {_stringRef: ?string, ...})
| RefObject,
// 工作单元,用于计算 state 和 props 渲染
pendingProps: any, // 本次渲染需要使用的 props
memoizedProps: any, // 上次渲染使用的 props
updateQueue: mixed, // 用于状态更新、回调函数、DOM更新的队列
memoizedState: any, // 上次渲染后的 state 状态
dependencies: Dependencies | null, // contexts、events 等依赖
mode: TypeOfMode,
// 副作用相关
flags: Flags, // 记录更新时当前 fiber 的副作用(删除、更新、替换等)状态
subtreeFlags: Flags, // 当前子树的副作用状态
deletions: Array<Fiber> | null, // 要删除的子 fiber
nextEffect: Fiber | null, // 下一个有副作用的 fiber
firstEffect: Fiber | null, // 指向第一个有副作用的 fiber
lastEffect: Fiber | null, // 指向最后一个有副作用的 fiber
// 优先级相关
lanes: Lanes,
childLanes: Lanes,
alternate: Fiber | null, // 指向 workInProgress fiber 树中对应的节点
actualDuration?: number,
actualStartTime?: number,
selfBaseDuration?: number,
treeBaseDuration?: number,
_debugID?: number,
_debugSource?: Source | null,
_debugOwner?: Fiber | null,
_debugIsCurrentlyTiming?: boolean,
_debugNeedsRemount?: boolean,
_debugHookTypes?: Array<HookType> | null,
|};
2. 双缓存 Fiber 树
Fiber tree 和 Dom tree 之间存在着映射关系,两棵 tree 的相关节点是一一对应的。 当 Fiber tree 结构发生变化时,Dom tree 也会相应的更新变化。因此,React Fiber 下的组件创建与更新,其本质上就是去构建或更新一个由多个 Fiber 节点相连组成的 Fiber 节点树的过程。在这个过程中,React 用到了双缓存技术来完成 Fiber 树的构建与替换,实现 DOM 对象的快速更新。
什么是双缓存技术呢?我们在进行图像处理的时候往往会经历渲染画面-清除画面-重新渲染画面这个过程,而清除画面后进行重绘可能会比较耗时,这时候用户就会感知到闪屏的现象。如果我们在内存中进行当前帧画面的构建,构建完毕后直接替换之前的画面,省去清屏的步骤,这样就会节省很多时间,很大程度上改善用户体验。而双缓存就是一种在内存中构建并直接替换的技术,其基本原理是在内存中绘制当前帧,绘制完毕后直接用当前帧替换上一帧,由于省掉了帧与帧之间替换的时间因此可以有效的避免闪烁问题。
根据双缓存技术的原理,在 React 中最多会同时存在两棵 Fiber 树。在第一次渲染之后,React 会生成一个对应于 UI 渲染的 current Fiber 树,它反映的是当前在屏幕中显示的内容。当发生更新时,React 会在内存中重新构建一颗新的 Fiber 树,这颗正在构建的 Fiber 树叫做 workInProgress Fiber 树,它反映的是即将要刷新到屏幕的未来状态。当 workInProgress Fiber 树构建完成后,React 会使用它直接替换 current Fiber 树,这个过程类似 Canvas 绘图过程中事先在内存中绘制了完整的新图层,然后用新图层直接替换旧图层的操作,这种方式可以达到快速更新 DOM 的目的,因为 workInProgress Fiber 树是在内存中构建的所以构建它的速度是非常快的。
workInProgress Fiber 树可以理解为一个工作快照,或者“工作草稿”,一般用户不可见。对 React 来说就是不会显示更新渲染的中间过程,React 先处理所有组件,然后将其一次性更新到屏幕上。React 官方团队的核心成员 Dan Abramov 在 Beyond React 16 演讲中用了一个非常生动形象的比喻,那就是 Git 功能分支,你可以将 workInProgress 树想象成从旧的 current 树中 Fork 出来的功能分支,你在这新分支中添加或移除特性,即使是操作失误也不会影响旧的分支。当你这个分支经过了测试和完善,就可以合并到旧分支,将其替换掉。
current Fiber 树和 workInProgress Fiber 树是存在联系的,因为每次在构建 workInProgress Fiber 树的时候,并不是完全重新构建,实际上很多属性可以复用 current Fiber 树。所以两者在代码层面必须建立关联关系。这个关联关系如何建立的呢?在 current Fiber 节点对象中有一个 alternate 属性指向对应的 workInProgress Fiber 节点对象,在 workInProgress Fiber 节点中有一个 alternate 属性也指向对应的 current Fiber 节点对象。
注意⚠️:在每一次更新完成后 workInProgress Fiber 会赋值给 current Fiber,即一旦 workInProgress Fiber 树在屏幕上呈现,它就会变成 current Fiber 树,在新一轮更新时 workInProgress Fiber 树会再重新构建。
function createWorkInProgress(current, ...) {
let workInProgress = current.alternate;
if (workInProgress === null) {
workInProgress = createFiber(...);
}
...
workInProgress.alternate = current;
current.alternate = workInProgress;
...
return workInProgress;
}
3. Scheduler 调度
我们已经通过引入 Fiber 节点对象这一数据结构将更新任务碎片化为多个任务单元,接下来 React 会去请求浏览器调度这些任务单元,浏览器接收到调度请求后就会判断当前帧是否有剩余时间,如果有剩余时间就执行任务单元。那么问题来了,浏览器是如何判断一帧是否有空闲时间的呢?这里首先要介绍两个我们在前置知识「渲染过程」中有提到过的浏览器原生 API:requestIdleCallback(rIC) 和 requestAnimationFrame(rAF)。
3.1 requestIdleCallback
基本语法 📚:var handle = window.requestIdleCallback(callback[, options])
requestIdleCallback 的 callback 参数是一个回调函数的引用,该函数会接收到一个 deadline 对象,通过这个对象可以获取当前浏览器的空闲时间以及回调是否在超时时间前已经执行的状态。利用这两个信息可以合理的安排当前帧需要做的事情,如果有空闲时间,那么就执行一小段任务;如果时间不足了,则继续 requestIdleCallback,歇一歇等到浏览器又有空闲时间的时候再接着执行。这样既不阻塞关键性事件,又能保证低优任务的时效性。
由于 requestIdleCallback 利用的是帧的空闲时间去执行任务,所以就有可能出现浏览器一直处于繁忙状态,导致回调一直无法执行,这其实也并不是我们期望的结果,那么这种情况我们就需要在调用 requestIdleCallback 的时候传入第二个配置参数 timeout 了。timeout 用来配置超时时间,如果指定了 timeout 的值,就意味着告诉浏览器在这个时间段后,callback 回调要强制执行。
注意 ⚠️:只在必需时使用 timeout 参数,因为浏览器会花费额外的开销去检查是否超时,这会产生一些性能损失,并且超时强制执行可能会带来丢帧的风险。
type Deadline = {
timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
didTimeout: boolean // 是否超时。
}
function work(deadline:Deadline) { // deadline 上面有一个 timeRemaining() 方法,能够获取当前浏览器的剩余空闲时间,单位 ms;有一个属性 didTimeout,表示是否超时
console.log(`当前帧剩余时间: ${deadline.timeRemaining()}`);
if (deadline.timeRemaining() > 1 || deadline.didTimeout) { // didTimeout 为 true 表示是因为超时而被触发
// 走到这里,说明时间有余,我们就可以在这里写自己的代码逻辑
}
// 走到这里,说明时间不够了,就让出控制权给主线程,下次空闲时继续调用
requestIdleCallback(work);
}
requestIdleCallback(work, { timeout: 1000 }); // 这边可以传一个回调函数(必传)和参数(目前就只有超时这一个参数)
3.2 requestAnimationFrame
requestAnimationFrame 字面意思为请求动画帧。官方解释为帧动画,就是可以一帧一帧的执行动画。那么问题来了,这个一帧的执行频率是多久?答案是与屏幕的刷新频率同步。也可以认为让浏览器在显示器屏幕下次刷新时执行一帧,那么显示器多次刷新屏幕就会执行多帧,如果速度够快,就会形成动画。requestAnimationFrame 和requestIdleCallback 的区别在于,rAF 的回调会在每一帧确认执行,属于高优先级任务, 而 rIC 的回调却不一定执行,属于低优先级任务。
基于 rAF 与 rIC 的特点,React core team 很自然的就想到可以利用 rAF 与 rIC 来执行任务单元,于是 React 将一些高优先级的任务比如说 animation 放到 rAF 去处理,而一些比较低优先级的任务例如 network I/O 的工作就放到 rIC 去处理。
这看起似乎相当完美,requestIdleCallback 和 requestAnimationFrame 仿佛是因此而生一般,Fiber 的早期版本确实却是使用了这样的方案,不过这已经是过去式了。但 React core team 已经推翻了之前的设计,因为他们发现 rIC 有一些比较不稳定的问题。首先是浏览器的支援度,requestIdleCallback 是一个还在实验中的 API,此功能某些浏览器尚在开发中,并且由于该功能对应的标准文档可能被重新修订,所以在未来版本的浏览器中该功能的语法和行为可能随之改变。其次是他们发现 rIC 的触发频率其实是不稳定的,比如说当切换 tab 时有可能让前一个 tab 的 rIC 被触发的机会降低。再加上 requestAnimationFrame 也过于依赖显示器刷新频率,无法在其之上进一步减少任务调度频率,以获得更大的优化空间,并且市面上的显示器刷新频率层次不齐,兼容起来比较麻烦,无法使 Scheduler 做到百分百掌控。所以 React 最终放弃了 requestIdleCallback 和 requestAnimationFrame 的使用。在 React 中,官方实现了自己的任务调度库,这个库就叫做 Scheduler。它也可以实现在浏览器空闲时执行任务,而且还可以设置任务的优先级,高优先级任务先执行,低优先级任务后执行。
3.3 Scheduler 任务调度器
Scheduler 任务调度器是 React 重要的组成部分。同时它也是个独立的包,不仅仅在 React 中可以使用,任何连续、可中断的流程都可以用 Scheduler 来调度。Scheduler 可以独自承担起任务调度的职责,你只需要将任务和任务的优先级交给它,它就可以帮你管理任务,安排任务的执行。
Scheduler 会从宏观和微观上对任务进行管控。宏观上就是对于多个任务,Scheduler 根据优先级来安排执行顺序;而微观上对于单个任务,它会有节制地去执行。什么是"有节制"呢?换句话说,线程只有一个,它不会一直占用着线程去执行任务,而是执行一会,中断一下,如此往复。不难看出,Scheduler 中有两个重要的行为:多个任务的管理和单个任务的执行控制。
为了实现多个任务的管理和单个任务的控制,Scheduler 引入了两个概念:任务优先级和时间片。任务优先级让任务按照自身的紧急程度排序,这样可以让优先级最高的任务最先被执行到。时间片规定的是单个任务在这一帧内最大的执行时间,任务一旦执行时间超过时间片,则会被打断,React 返回主线程控制权好让浏览器有机会执行其他工作,这样可以避免一直占用有限的资源执行耗时较长的任务,保证页面不会因为任务执行时间过长而产生掉帧或者影响用户交互。
3.3.1 多个任务的管理
先简单介绍一下任务的存储结构,每个 React 任务都有 StartTime 任务开始时间(任务产生时间 + delay)和 expirationTime 任务到期时间。
var expirationTime = startTime + timeout;
var newTask = {
id: taskIdCounter++, // 一个自增编号
callback, // 传入的回调函数
priorityLevel, // 优先级等级
startTime, // 创建 task 时的当前时间
expirationTime, // task 的过期时间, 优先级越高 expirationTime = startTime + timeout 越小
sortIndex: -1,
};
// sortIndex: 排序索引, 全等于过期时间. 保证过期时间越小, 越紧急的任务排在最前面
任务进来的时候,开始时间默认是当前时间,如果进入调度的时候传了延迟时间,那么开始时间则是当前时间与延迟时间的和。什么是任务到期时间呢?任务到期时间用于帮助我们对比不同任务之间的优先级,当时间到了ExpirationTime 时,如果某个 update 还未执行的话,React 将会强制执行该 update,即防止某个 update 因为优先级的原因一直被打断而未能执行。ExpirationTime 的计算公式如下:
expriationTime = 当前时间 + 一个常量(根据任务优先级改变)
这个常量指的就是 timeout,它是根据不同优先级得出的一个数值,Scheduler 自身维护了 6 种优先级,分别为:
// packages/scheduler/src/SchedulerPriorities.js
// 数值越小优先级越高
export const NoPriority = 0; // 没有任何优先级
export const ImmediatePriority = 1; // 立即执行的优先级, 级别最高
export const UserBlockingPriority = 2; // 用户阻塞级别的优先级, 比如用户输入, 拖拽等
export const NormalPriority = 3; // 正常的优先级
export const LowPriority = 4; // 低优先级
export const IdlePriority = 5; // 最低阶的优先级, 可以被闲置的那种
它们各自的对应的 timeout 数值都是不同的,具体的内容如下:
// packages/scheduler/src/SchedulerPriorities.js
var maxSigned31BitInt = 1073741823;
// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY = maxSigned31BitInt;
也就是说,假设当前时间为 5000 并且分别有两个优先级不同的任务要执行。前者属于 ImmediatePriority,后者属于 UserBlockingPriority,那么两个任务计算出来的时间分别为 4999 和 5250。通过这个时间可以比对大小得出谁的优先级高,值越小就要优先执行。
在 Scheduler 内部,任务分成了两种,未过期的和已过期的,分别用两个优先队列存储,这两个队列分别是 TimerQueue 和 TaskQueue、前者存放延时执行任务,后者则存放即将执行的任务:
- timerQueue:未过期队列,也叫待调度的队列
- taskQueue:已过期队列,也叫调度中的队列
如何区分任务是否已经过期呢?用任务的 startTime 开始时间和 currentTime 当前时间作比较:
- 如果 startTime > currentTime,那么任务没有过期,任务推入 timerQueue
- 如果 currentTime >= startTime,那么任务已过期,任务推入 taskQueue
// 伪代码大概长这样:(currentTime 当前时间,expirationTime 过期时间)
if (currentTime >= startTime) {
// 已过期
push(taskQueue, task);
} else {
// 未过期
push(timerQueue, task);
}
同队列中的任务如何排序?当任务一个个入队的时候,自然要对它们进行排序,保证紧急的任务排在前面,所以排序的依据就是任务的紧急程度。而 timerQueue 和 taskQueue 中任务紧急程度的判定标准是有区别的:
- timerQueue 以任务开始时间为优先级别排序依据,开始时间越早越紧急,因此开始时间小的排在前面。
- taskQueue 以任务到期时间为优先级别排序依据,过期时间越早越紧急,因此过期时间小的排在前面。
任务入队两个队列之后要干嘛呢?
- taskQueue,因为里面的任务已经过期了,所以需要去循环 taskQueue,挨个执行里面的任务。
- timerQueue,待调度队列,即它是一个等待队列,里面的任务不会立即执行,但 Scheduler 会通过 advanceTimers 方法来检测 timerQueue 队列中的第一个任务是否过期。如果过期了,就把该任务从 timerQueue 中拿出来放入 taskQueue;否则过一会继续检查第一个任务是否过期。
3.3.2 单个任务的执行控制
前面有提到我们需要去循环执行完已过期任务队列中的这些任务,每一次循环处理实际上就是单个任务的执行控制。Scheduler 循环处理 taskQueue 中的任务这一过程发生在 workLoop 这个非常精髓的函数中,该函数使用了一个 while 循环承载了任务中断,任务恢复,判断任务完成等功能。
单个任务的执行控制其实就是我们已经提到过很多次的任务中断和恢复,Scheduler 要实现任务中断和恢复调度效果需要两个角色:任务的调度者,任务的执行者。调度者调度一个执行者,执行者去循环 taskQueue,逐个执行任务。执行者会根据时间片中断任务执行,然后告诉调度者:我现在正执行的这个任务被中断了,还有一部分没完成,但现在必须让出控制权,你再调度一个执行者吧,好让这个任务能在之后被继续执行完(任务的恢复)。于是,调度者知道了任务还没完成,需要继续做,它会再调度一个执行者去继续完成这个任务,通过执行者和调度者的配合,可以实现任务的中断和恢复。
从源码可以看到,在浏览器每一帧的时间里,预留给 JS 线程的时间切片默认是 5ms:
// packages/scheduler/src/forks/SchedulerPostTask.js
// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
const yieldInterval = 5;
每一次 while 循环的退出就是一个时间切片的用尽。循环 taskQueue 执行里面的任务就是不断的消费该队列中的任务,消费任务队列的过程中,可以消费 1~n 个task,甚至清空整个队列。但是在每一次具体执行 task.callback之前都要进行超时检测,如果超时就需要立即退出循环并等待下一次调用。
注意⚠️:在时间切片的基础之上,可能单个 task.callback 执行时间就很长(假设 200ms),那么就需要task.callback 自己也能够检测是否超时,所以在 Fiber 树的构造过程中,每构造完成一个 Fiber Node,都会检测一次超时,如遇超时就退出 Fiber 树构造循环,等待下一次继续未完成的 Fiber 树构造。
4. Reconciler 改造
Scheduler 的调度能力使 React 可以将任务拆分成很多个切片来执行,任务切片是实现中断的前提,当这些前提准备工作完成之后,Scheduler 调度器就可以将任务提交到 Reconciler 调和器中进行下一步工作。React 在渲染页面时,不管是在老的架构中,还是在新的 Fiber 架构中,都是通过 reconciler 调和器去找出变动的元素。我们在前面有提到过,React 15 架构中的 Reconciler 与Renderer 是交替进行,如果强行中断更新过程会导致页面更新不完全,而在 React Fiber 架构下进行中断处理不会出现该问题是因为 Fiber 架构将 reconciler 执行的过程细分为了两个阶段:render / reconciliation phase 与commit phase。
React 团队的核心成员 Dan Abramov 用了一张 Fiber 架构下的 React 生命周期阶段图来描述 render phase 与commit phase。不难看出,render phase 和 commit phase 的分界线是 render 函数。
注意 ⚠️:render 函数本身属于 render / reconciliation phase 阶段。
从这张图左侧对各个阶段的描述可以看出,render 阶段的工作是可以被打断的,该阶段能够实现暂停、中止以及重新开始等增量渲染的能力。相反,commit 阶段的工作是不可中断的,这是因为在此阶段会进行真实 DOM 的更新操作,更新真实的DOM 节点这个操作将导致用户可见的更改,所因此这个过程需要一气呵成不能中断,否则会造成使用者视觉上的不连贯。
在 render 阶段,React Fiber 会找出需要更新哪些 DOM。在该过程中,React 可以根据当前可用的时间片处理一个或多个 fiber 节点,并且得益于 fiber 对象中存储的元素上下文信息以及指针域构成的链表结构,使其能够在时间片用完后将执行到一半的工作保存在内存的链表中。当 React 完成保存的工作后,该阶段会被中断,React 返还主线程的控制权让浏览器能够执行其他任务。之后,在重新获取到可用的时间片后,React 能够根据之前保存在内存的上下文信息通过快速遍历的方式找到停止的 fiber 节点并继续工作。由于在此阶段执行的工作并不会导致任何用户可见的更改,因为并没有被提交到真实的 DOM,所以这个阶段可以被中断暂停甚至中止。
我们前面描述的是暂停恢复的过程,那什么情况下 render 阶段的工作会被中止呢?答案是在遇到优先级更高的紧急更新任务时。在 render 阶段,React 每隔一段时间就会去确定是否有其他更重要的任务。如果有,那么组件的渲染在 render 过程中被打断,即当前组件只渲染到一半,react 会放弃当前组件所有干到一半的事情,去做更高优先级更重要的任务(比如说,若现在进行屏幕组件状态更新,用户又在输入,浏览器就应该先执行响应用户输入任务)。当所有高优先级任务执行完之后, react 通过 callback 回到之前渲染到一半的组件,从头开始渲染。之所以要重新渲染是为了保证组件状态的绝对可靠。举个例子,一个处于渲染过程中的组件,当前正在执行到 render 函数,这时候用户突然在某个 input 控件里输入了什么,由于用户输入事件的优先级高于组件渲染,因此React 决定去优先处理 input 控件里的按键事件,所以就会打断这个组件的渲染过程,转头专心去处理 input 控件的事情。等到 input 控件的事情处理完,再来渲染这个组件,但是这时候从原来位置重新开始,那肯定是不靠谱的,因为刚才的按键事件处理可能改变了一些状态,为了保证绝对靠谱,React 决定还是从头走一遍,于是重新去调用 getDerivedStateFromProps、shouldComponentUpdate 然后调用 render。
从上面这个例子中可以看出,一旦被更高优先级的任务中断,之后重新执行本次更新任务 render 之前的生命周期函数都会被调用,而且这种中断是完全是不可预期的,我们无法预测什么时候会出现更高优先级的紧急任务,因此,在 render 阶段的所有生命周期函数里都尽量不要做有副作用的操作,因为副作用如果被多次执行可能会产生开发者预料不到的问题。你可能会问,什么叫副作用?副作用就是纯函数不该做的操作。那什么又叫纯函数呢?纯函数就是除了根据输入参数返回结果之外,不做任何多与事情的操作。如果一个函数修改全局变量、修改类实例状态、抛出异常,或者通过 Ajax 访问服务器 API,那就不是一个纯函数。就拿访问服务器 API 为例,假如 render 阶段的生命周期函数做了访问服务器 API 的 Ajax 操作,那么很有可能产生连续对服务器的访问,因为异步渲染下 render 阶段可能会被打断而重复执行。
注意 ⚠️:这里之所以说的是尽量不要做有副作用的操作,而不是绝对地说不能有副作用操作,是因为就算有副作用操作不是纯函数,但如果操作是幂等的,即一次调用和 N 次调用产生的结果一致,也是可以的。
5. Fiber 可能存在的问题
在 Fiber 中,更新是分阶段的,具体分为两个阶段,首先是 reconciliation 的阶段,这个阶段在计算前后 Dom 树的差异,然后是 commit 的阶段,这个阶段将把更新渲染到页面上。第一个阶段是可以打断的,因为这个阶段耗时可能会很长,因此需要暂停下来去执行其他更高优先级的任务,第二个阶段则不会被打断,会一口气把更新渲染到页面上。由于 reconciliation 的阶段会被打断,可能会导致 commit 前的这些生命周期函数多次执行。React 官方目前已经把componentWillMount、componentWillReceiveProps 和componetWillUpdate 标记为 unsafe,并使用新的生命周期函数 getDerivedStateFromProps 和 getSnapshotBeforeUpdate 进行替换。因此,在使⽤传统的类组件进⾏开发时,切记不要在以上⼏个已经标记为 unsafe 的⽣命周期函数中做只需要做⼀次的操作,⽐如页⾯初始化时发起⼀个 Ajax 请求获取数据。
还有一个问题是饥饿问题,意思是如果高优先级的任务一直插入,导致低优先级的任务无法得到机会执行,这被称为饥饿问题。对于这个问题官方提出的解决方案是尽量复用已经完成的操作来缓解。相信官方也正在努力提出更好的方法去解决这个问题。
总结
Fiber 这个单词翻译过来是“丝、纤维”的意思,从字面上来理解是比线还要细的东西。在计算机科学里,我们有进程、线程之分,而 Fiber 就是比线程还要纤细的一个过程,也就是所谓的“纤程”。纤程的出现,意在对渲染过程实现更加精细的控制。从架构角度来看,Fiber 是对 React 核心算法的重写;从编码角度来看,Fiber 是 React 内部所定义的一种数据结构,它是 Fiber 树结构的节点单位;从工作流的角度来看,Fiber 节点保存了组件需要更新的状态和副作用,因此一个 Fiber 同时也对应着一个工作单元。React Fiber 的出现相当于是在更新过程中引进了一个中场指挥官,负责掌控更新过程。
A Cartoon Intro to Fiber 也对 React Fiber 做了一个生动形象的比喻:浏览器的主线程就像一名全栈开发人员,一个人负责执行 JavaScript,布局以及绘制等所有工作,并且它一次只能做一件事,比如假设此时它正在执行 JavaScript,那它就没办法进行渲染,毕竟就算人家是全能选手但也没有三头六臂更不会分身术。而 React Fiber 就像是主线程这名全栈开发人员的技术主管,它确切的知道主线程是如何工作的,并且知道应该如何指导主线程的工作,帮助主线程发挥最大潜能,使其工作效率更加高效。当然,技术主管肯定具备一些基本项目管理技能,例如如何划分工作,以及如何确定工作的优先级等。
React Fiber 架构中运用到很多优秀的设计方案,比如时间切片、树的链表实现以及双缓存技术,这些问题的解决思路完全可以应用到我们平时的架构设计中。
资料
源码下载
本次学习的源码是通过 React官网提供的 Git 地址下载的。