深入 React Fiber

深入 React Fiber

我们都知道,React 是一个用于构建用户交互界面的 JavaScript 库,其核心机制就是跟踪组件的状态变化,并将更新的状态映射到到新的界面,在 React 中,我们将此过程称之为『协调』(Reconcilation),我们调用 setState 方法来改变状态,而框架本身会去检查 stateprops 是否已经更改来决定是否重新渲染组件,React 的官方文档对 协调机制 进行了良好的抽象描述,即

React 的元素、生命周期、render 方法,以及应用于组件子元素的 Diffing 算法综合起到的作用,就是『协调』

我们将从 render 方法返回的不可变的 React 元素通常称为『虚拟 DOM』,但是除了『虚拟 DOM』之外,React 框架总是在内部维护一个实例来持有状态(如组件、DOM 节点等),从版本 16 开始,React 推出了新的内部实例树的实现方法,也就是我们经常听闻的 Fiber 算法,但是在本章当中我们并不会太过深入的去介绍源码相关内容,而是主要来探索一下这个所谓的 Fiber 到底是什么以及它出现的缘由和解决的一些问题

存在的问题

之所以会推出新的 Fiber,那就说明在之前的 React 版本当中是存在一定的问题的,那么下面我们就来先看看之前的版本当中到底是存在哪些问题,这个我们可以从官方提供的 示例 开始看起,运行效果如下

至于根本原因,我们可以通过查看其对应的 源码 来分析具体原因,通过观察源码我们可以发现,代码当中使用了 requestAnimationFrame 这个 API,关于 requestAnimationFrame 这个接口的使用我们就不介绍更多内容了,直接来看代码的实现,详细可以参考之前我们整理过的 requestAnimationFrame 的使用 以及 并发模型中的 requestAnimationFrame

1
2
3
4
5
6
7
8
9
10
11
var start = new Date().getTime()

function update() {
ReactDOM.render(
<ExampleApplication elapsed={new Date().getTime() - start} />,
document.getElementById('container')
)
requestAnimationFrame(update)
}

requestAnimationFrame(update)

另外,为了每秒更新一次圆点中的数字,ExampleApplication 组件维护了一个 seconds 状态,使用 setInterval 每秒更新一次

1
2
3
4
5
6
7
8
9
componentDidMount() {
this.intervalID = setInterval(this.tick, 1000)
}

tick() {
this.setState(state => ({
seconds: (state.seconds % 10) + 1
}))
}

以上效果之所以会卡顿,究其原因,对绝大多的浏览器来说,它的页面刷新频率取决于显示器的刷新频率,比如一台刷新频率是 60FPS 的显示器,requestAnimationFrame 会每 16.66ms1000ms / 60)执行一次回调函数,但为了给主线程时间处理其他事务,一般要求产出一帧画面的时间不要超过 10ms,所以 requestAnimationFrame 的执行频率不会比显示器更高,但有可能更低

默认情况下,JavaSceipr 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系,如果 JavaSceipr 运算持续占用主线程,页面就没法得到及时的更新,当我们调用 setState 更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI,整个过程是一气呵成,不能被打断的,如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,这是就容易出现掉帧的现象,也就是说,其根本原因是因为大量的同步计算任务阻塞了浏览器的 UI 渲染

ReactReconcilationCPU 密集型的操作,旧版 React 通过『递归』的方式进行渲染,使用的是 JavaSceipr 引擎自身的函数调用栈,它会一直执行到栈空为止,也就是比对 Virtual DOM 树,找出需要变动的节点,然后同步更新它们,这个过程 React 称为 Reconcilation(协调)

React 团队使用 Fiber 实现了自己的组件调用栈以后,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务,基本思路是将运算切割为多个步骤,分批完成,也就是说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染,等浏览器忙完之后,再继续之前未完成的任务,所以使用 Fiber 架构的目的是让 Reconcilation 过程变成『可被中断』,适时地让出 CPU 执行权,让浏览器及时地响应用户的交互

如下就是优化后的效果,我们可以明显感觉到,示例运行起来会比之前看上去流畅许多

协调(Reconciliation)

我们都知道,React 的核心是定义组件,渲染组件的方式则是由环境所决定(比如 React Native),定义组件,组件状态管理,生命周期方法管理,组件更新等应该跨平台一致处理,不受渲染环境影响,而这部分内容统一由 协调器(Reconciler) 处理,不同渲染器都会使用该模块,协调器主要作用就是在组件状态变更时,调用组件树各组件的 render 方法,渲染,卸载组件

Stack Reconciler

我们知道浏览器渲染引擎是单线程的,在上面章节当中我们也提到过,在 React 之前的版本当中,计算组件树变更时将会阻塞整个线程,整个渲染过程是连续不中断完成的,而这时的其他任务都会被阻塞,如动画等,这可能会使用户感觉到明显卡顿,这个版本的协调器可以称为『栈协调器』(Stack Reconciler),其协调算法的大致实现过程可以参考官方文档中的 React Stack Reconciler

Stack Reconcilier 的主要缺陷就是不能暂停渲染任务,也不能切分任务,无法有效平衡组件更新渲染与动画相关任务间的执行顺序,即不能划分任务优先级,有可能导致重要任务卡顿,动画掉帧等问题

Fiber Reconciler

React 16 版本提出了一个更为先进的协调器,它允许渲染进程分段完成,而不必须一次性完成,中间可以返回至主进程控制执行其他任务,而这是通过计算部分组件树的变更,并暂停渲染更新,询问主进程是否有更高需求的绘制或者更新任务需要执行,这些高需求的任务完成后才开始渲染

这一切的实现是在代码层引入了一个新的数据结构,也就是我们的 Fiber 对象,每一个组件实例对应有一个 Fiber 实例,此 Fiber 实例负责管理组件实例的更新,渲染任务及与其他 Fiber 实例的联系,这个新推出的协调器就叫做『纤维协调器』(Fiber Reconciler),它提供的新功能主要有下面这些

  • 可切分,可中断任务
  • 可重用各分阶段任务,且可以设置优先级
  • 可以在父子组件任务间前进后退切换任务
  • render 方法可以返回多元素(即可以返回数组)
  • 支持异常边界处理异常

下面就让我们来深入的了解一下,到底什么是 Fiber

什么是 Fiber

那么什么是 Fiber 呢?其实 Fiber 是一种流程控制原语,它是一个非常底层的抽象描述,我们可以称其 协程 或者『纤程』,但是需要注意的是,协程和『线程』并不一样,协程本身是没有并发或者并行能力的(需要配合线程),它只是一种控制流程的让出机制,这里我们可以对比普通函数和 Generator 的运行方式,普通函数执行的过程中无法被中断和恢复,如下

1
2
3
4
5
6
7
8
const tasks = []

function run() {
let task
while (task = tasks.shift()) {
execute(task)
}
}

Generator 却是可以的

1
2
3
4
5
6
7
8
9
10
11
const tasks = []

function* run() {
let task
while (task = tasks.shift()) {
if (hasHighPriorityEvent()) { // 判断是否有高优先级事件需要处理,有的话让出控制权
yield
}
execute(task) // 处理完高优先级事件后,恢复函数调用栈,继续执行
}
}

所以我们可以发现,其实 React Fiber 的思想和协程的概念是契合的,也就是说 React 渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染,那么这里你可能会有一个疑问,那就是 React 是如何将控制权交回浏览器的呢?其实浏览器并没有抢占的条件,通常来说是 React 主动让出机制,这是因为

  • 一来浏览器中没有类似进程的概念,任务之间的界限很模糊,没有上下文,所以不具备中断或是恢复的条件
  • 二则是没有抢占的机制,我们无法中断一个正在执行的程序,所以我们只能采用类似协程这样控制权让出机制

观察我们上面的代码,其实上面代码示例中的 hasHighPriorityEvent() 在目前浏览器中是无法实现的,因为我们没办法判断当前是否有更高优先级的任务等待被执行,从而让其让出机制,所以我们只能换一种思路,也就是通过『超时检查的机制』来让出控制权,即确定一个合理的运行时长,然后在合适的检查点检测是否超时(比如每执行一个小任务),如果超时就停止执行,将控制权交换给浏览器,而这个方式的实现主要依赖的是浏览器提供的 requestIdleCallback 这个 API

1
2
3
4
window.requestIdleCallback(
callback: (dealine: IdleDeadline) => void,
option?: {timeout: number}
)

IdleDeadline 的接口如下

1
2
3
4
interface IdleDealine {
didTimeout: boolean // 表示任务执行是否超过约定时间
timeRemaining(): DOMHighResTimeStamp // 任务可供执行的剩余时间
}

单从名字上理解的话,requestIdleCallback 的意思是让浏览器在有空的时候就执行我们的回调,这个回调会传入一个期限,表示浏览器有多少时间供我们执行,为了不耽误事,我们最好在这个时间范围内执行完毕,现在我们知道了如何让出机制,那么浏览器什么时候有空呢?我们先来看一下浏览器在一帧(Frame,可以认为事件循环的一次循环)内可能会做什么事情,通常来说可能会做执行下列任务,而且它们的执行顺序基本是固定的

  • 处理用户输入事件
  • JavaScript 执行
  • requestAnimation 调用
  • 布局 Layout
  • 绘制 Paint

通常,客户端线程执行任务时会以帧的形式划分,大部分设备控制在 30-60 帧是不会影响用户体验,在两个执行帧之间,主线程通常会有一小段空闲时间,requestIdleCallback 可以在这个『空闲期』(Idle Period)调用『空闲期回调』(Idle Callback)执行一些任务

但是在浏览器繁忙的时候,可能不会有盈余时间,这时候 requestIdleCallback 回调可能就不会被执行,所以在这种情况下可以通过 requestIdleCallback 的第二个参数指定一个超时时间

另外不建议在 requestIdleCallback 中进行 DOM 操作,因为这可能导致样式重新计算或重新布局(比如操作 DOM 后马上调用 getBoundingClientRect()),这些时间很难预估的,很有可能导致回调执行超时,从而掉帧

但是这个超时时间不是死的,低优先级的可以慢慢等待,高优先级的任务应该率先被执行,目前 React 预定义了五个优先级

  • Immediate-1),这个优先级的任务会同步执行,或者说要马上执行且不能中断
  • UserBlocking250ms),这些任务一般是用户交互的结果,需要即时得到反馈
  • Normal5s),应对哪些不需要立即感受到的任务,例如网络请求
  • Low10s),这些任务可以放后,但是最终应该得到执行,例如分析通知
  • Idle(没有超时时间),一些没有必要做的任务(比如隐藏的内容)

但是目前 requestIdleCallback 只有 Chrome 支持,所以为了支持其它浏览器,React 干脆自己 实现 了一个,它是利用 MessageChannel 模拟将回调延迟到绘制操作之后执行,如下图

看到这里你可能会问,我们在上面提到过,使用 Generator 函数也是可以控制函数的执行流程,那么为什么官方不直接采用 Generator 的实现方式呢?关于这一点的原因,可以参考官方在 Fiber Principles: Contributing To Fiber 当中的回答,这里我们就不详细展开了

其实我们上面介绍那么多,如果简单的理解的话,我们可以把 Fiber 认为是一种数据结构或者说执行单元(会在下面进行介绍),我们将它视作一个执行单元,每次执行完一个执行单元,React 就会检查现在还剩多少时间,如果没有时间就将控制权让出去,React 没有使用 Generator 这种让出机制,而是实现了自己的调度让出机制,这个机制就是基于 Fiber 这个执行单元的

简单总结一下就是

  • React 应用中的基础单元是组件,应用以组件树形式组织,渲染组件
  • Fiber 协调器基础单元则是协调单元(协调器算法组成单元),应用以 Fiber 树形式组织,应用 Fiber 算法
  • 组件树和 Fiber 树结构对应,一个组件实例有一个对应的 Fiber 实例
  • Fiber 协调器算法负责整个应用层面的协调,而 Fiber 实例则负责对应组件的协调

下面我们就来看看从 React 元素到 Fiber 节点是如何转化的,也就是 ReactFiber 架构做了哪些改造

Fiber 节点

我们都知道,React 元素并非真实的 DOM 节点或组件实例,而是一种描述方式,用于描述 DOM 元素的类型、拥有的属性以及包含的子元素,这也正是 React 的核心所在,React 将构建、渲染以及管理真实 DOM 树生命周期这些复杂的逻辑进行了抽象,从而避免了我们直接操作真实 DOM 而引起的巨大性能消耗

在协调期间,从 render 方法返回的每个 React 元素的数据都会被合并到 Fiber 节点树中,每个 React 元素都有一个相应的 Fiber 节点,与 React 元素不同,不会在每次渲染时重新创建这些 Fiber ,这些是持有组件状态和 DOM 的可变数据结构

因此,这意味着当我们调用 ReactDOM.render()setState() 时,React 将执行协调,在 setState 的情况下,它执行遍历并通过将新树与已渲染的树进行区分来找出树中发生了什么变化,然后将这些更改应用于当前树,从而更新与 setState() 调用相关的 state

我们在之前提到过,旧版 React 是通过『递归』的方式进行渲染的,也就是说这是基于函数调用栈的协调算法,只不过这种依赖于调用栈的方式不能随意中断、也很难被恢复,不利于异步处理,这种调用栈,不是程序所能控制的,如果你要恢复递归现场,可能需要从头开始,恢复到之前的调用栈,所以针对于这种情况就需要对 React 现有的数据结构进行调整,模拟函数调用栈,将之前需要递归进行处理的事情分解成增量的执行单元,将递归转换成迭代

React 目前的做法是使用链表,每个 VirtualDOM 节点内部现在使用 Fiber 表示,它的结构大概如下

1
2
3
4
5
6
7
export type Fiber = {     
type: any, // Fiber 类型信息
return: Fiber | null, // 链表结构,指向父节点,或者 render 该节点的组件
child: Fiber | null, // 指向第一个子节点
sibling: Fiber | null, // 指向下一个兄弟节点
// ...
}

用图片来展示这种关系会更直观一些

因为 React 为每个 React 元素创建一个 Fiber 节点,并且因为我们有一个这些元素组成的树,所以我们可以得到一个 Fiber 节点树,所有 Fiber 节点都通过链表连接,具体是使用 Fiber 节点上的 childsiblingreturn 属性,至于它为什么以这种方式工作,可以参考 如何以及为什么 React Fiber 使用链表遍历组件树 这篇文章,这里我们就不详细展开了

下面我们来深入的了解一下 Fiber 当中的节点类型,它们有以下这些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export type Fiber = {   
// 节点的类型信息
tag: WorkTag, // 标记 Fiber 类型,例如函数组件、类组件、宿主组件
type: any, // 节点元素类型,对应的 function/class/module 类型组件名

// 树结构相关信息
return: Fiber | null, // 处理完当前 fiber 后返回的 fiber,通常返回当前 fiber 所在 fiber 树的父级 fiber 实例
child: Fiber | null,
sibling: Fiber | null,
key: null | string, // 子节点的唯一键,即我们渲染列表传入的 key 属性

// 节点实例的状态
// 对于宿主组件,这里保存宿主组件的实例,例如 DOM 节点
// 对于类组件来说,这里保存类组件的实例
// 对于函数组件说,这里为空,因为函数组件没有实例
stateNode: any, // 所在组件树的根组件 FiberRoot 对象
pendingProps: any, // 当前处理过程中的组件 props 对象
memoizedProps: any, // 缓存的之前组件 props 对象
memoizedState: any, // 上一次渲染的组件状态

// 副作用
effectTag: SideEffectTag, // 当前节点的副作用类型,例如节点更新、删除、移动
nextEffect: Fiber | null, // 和节点关系一样,React 同样使用链表来将所有有副作用的 Fiber 连接起来

// fiber 的版本池,即记录 fiber 更新过程,便于恢复
alternate: Fiber | null,

// 组件状态更新及对应回调函数的存储队列
updateQueue: UpdateQueue<any> | null,

// 描述当前 fiber 实例及其子 fiber 树的数位,如 AsyncUpdates 特殊字表示默认以异步形式处理子树
// 一个 fiber 实例创建时,此属性继承自父级 fiber,在创建时也可以修改值,但随后将不可修改
internalContextTag: TypeOfInternalContext,
}

Fiber 包含的属性可以划分为五个部分

  • 结构信息,上面已经介绍过,Fiber 使用链表的形式来表示节点在树中的定位
  • 节点类型信息,tag 表示节点的分类、type 保存具体的类型值,如 divMyComp
  • 节点的状态,节点的组件实例 propsstate 等,它们将影响组件的输出
  • 副作用,在协调过程中发现的副作用就保存在节点的 effectTag 中(类似打上标记),这里也使用了链表结构,将本次渲染的所有副作用节点都收集起来,通过 nextEffect 连接起来
  • workInProgressWIP)树,React 在协调过程中会构建一颗新的树,可以认为是一颗表示当前工作进度的树,还有一颗表示已渲染界面的旧树,React 就是一边和旧树比对,一边构建 WIP 树的, alternate 指向旧树的同等节点

下面我们就来简单的看看各个属性的作用

type 和 key

FibertypekeyReact 元素起着同样的作用(实际上,Fiber 从一个元素创建时,这两个属性直接被复制过来),type 描述了它对应的组件,对于合成组件来说 type 是一个函数或者类组件本身,对于原生元素(divspan 等),它是一个字符串,从概念上来说,type 是在执行时被栈帧追踪的函数(如在 v = f(d) 中)

而与 type 一起的 key,被用来在协调过程中决定 Fiber 是否可以再利用,也就是在协调阶段用来标识 Fiber,以检测是否可重用该 Fiber 实例

child 和 sibling

表示当我们在组件上调用 render() 时返回的元素,例如

1
2
3
4
5
6
7
const Name = (props) => {
return (
<div className="name">
{props.name}
</div>
)
}

<Name> 的子元素是 <div>,因为它返回一个 <div> 元素,而 sibling 字段则对应 render 返回多个孩子节点的情况,如下

1
2
3
const Name = (props) => {
return ([<Child1 />, <Child2 />])
}

在上述情况下,<Child1><Child2> 是父元素 <Name> 的子元素,这两个子元素组成一个单链表,head 指针指向第一个孩子节点,所以在上例中 Parent 的孩子节点是 Child1Child1 的兄弟节点是 Child2,如果放到函数当中类比的话,可以认为一个子 Fiber 是一个尾调用函数

return

return 是当前 Fiber 处理完成后需要返回的 Fiber,从概念上来说它对应栈帧返回的地址,从逻辑上讲,它是返回到父 Fiber 节点,因此可以理解为父 Fiber,如果一个 Fiber 有多个子 Fiber,每个子 Fiber 返回的 Fiber 都是它的父 Fiber,在上面示例中的 Child1Child2return 就是 Parent

pendingProps 和 memoizedProps

分别表示组件当前传入的及之前的 propsmemoizedProps 主要用来存储函数执行结果的值,以便以后可以使用它,从而避免重新计算,pendingProps 表示传递给组件的 props

当传入的 pendingProps 等于 memoizedProps 时,它表示 Fiber 之前的输出可以复用,从而避免不必要的工作

alternate

可以理解为一个 Fiber 版本池,用于交替记录组件更新(切分任务后变成多阶段更新)过程中 Fiber 的更新,因为在组件更新的各阶段,更新前及更新过程中 Fiber 状态并不一致,在需要恢复时(比如冲突),即可使用另一者直接回退至上一版本 Fiber

  • 使用 alternate 属性双向连接一个当前 Fiber 和其 workInProgress,当前 Fiber 实例的 alternate 属性指向其 workInProgressworkInProgressalternate 属性指向当前稳定 Fiber
  • 当前 Fiber 的替换版本是其 workInProgressworkInProgress 的交替版本是当前 Fiber
  • workInProgress 更新一次后,将同步至当前 Fiber,然后继续处理,同步直至任务完成
  • workInProgress 指向处理过程中的 Fiber,而当前 Fiber 总是维护处理完成的最新版本的 Fiber

tag

我们先来看看如何创建 Fiber 实例,如下

1
2
3
4
5
6
7
var createFiber = function (
tag: TypeOfWork,
key: null | string,
internalContextTag: TypeOfInternalContext,
) {
return new FiberNode(tag, key, internalContextTag)
}

可以发现这里有一个 tag 属性,它主要用来标记 Fiber 类型,而 Fiber 实例是和组件对应的,所以其类型基本上对应于组件类型,主要有以下这些

1
2
3
4
5
6
7
8
9
10
11
12
13
export type TypeOfWork = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10

export const IndeterminateComponent = 0  // 尚不知是类组件还是函数式组件
export const FunctionalComponent = 1  // 函数式组件
export const ClassComponent = 2  // Class 类组件
export const HostRoot = 3  // 组件树根组件,可以嵌套
export const HostPortal = 4  // 子树
export const HostComponent = 5  // 标准组件,如 div/span 等
export const HostText = 6  // 文本
export const CallComponent = 7  // 组件调用
export const CallHandlerPhase = 8  // 调用组件方法
export const ReturnComponent = 9  // placeholder(占位符)
export const Fragment = 10  // 片段

stateNode 和 FiberRoot

FiberRoot 对象,主要用来管理组件树组件的更新进程,同时记录组件树挂载的 DOM 容器相关信息

1
2
3
4
5
6
7
8
export type FiberRoot = {
containerInfo: any, // fiber 节点的容器元素相关信息,通常会直接传入容器元素
current: Fiber, // 当前 fiber 树中激活状态(正在处理)的 fiber 节点
remainingExpirationTime: ExpirationTime, // 此节点剩余的任务到期时间
isReadyForCommit: boolean, // 更新是否可以提交
finishedWork: Fiber | null, // 准备好提交的已处理完成的 workInProgress
nextScheduledRoot: FiberRoot | null, // 多组件树 FirberRoot 对象以单链表存储链接,指向下一个需要调度的 FiberRoot
}

Fiber 架构

本小节当中我们尝试来简单的梳理一下 Fiber 架构,但是不会过多的涉及源码,对源码感兴趣的话可以参考 官方仓库 来了解更多

优先级(ExpirationTime VS PriorityLevel)

我们已经知道 Fiber 可以切分任务并设置不同优先级,那么是如何实现划分优先级的呢,其表现形式什么呢?主要有以下两种方式

  • ExpirationTime

Fiber 切分任务并调用 requestIdleCallbackrequestAnimationFrame,保证渲染任务和其他任务,在不影响应用交互,不掉帧的前提下,稳定执行,而实现调度的方式正是给每一个 Fiber 实例设置到期执行时间,不同时间即代表不同优先级,到期时间越短,则代表优先级越高,需要尽早执行

所谓的到期时间(ExpirationTime),是相对于调度器初始调用的起始时间而言的一个时间段,调度器初始调用后的某一段时间内,需要调度完成这项更新,这个时间段长度值就是到期时间值

  • PriorityLevel

React 15.x 版本中就已经出现了对于任务的优先层级划分

1
2
3
4
5
6
7
8
9
10
export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5

module.exports = {
NoWork: 0, // No work is pending.
SynchronousPriority: 1, // For controlled text inputs. Synchronous side-effects.
AnimationPriority: 2, // Needs to complete before the next frame.
HighPriority: 3, // Interaction that needs to complete pretty soon to feel responsive.
LowPriority: 4, // Data fetching, or result from updating stores.
OffscreenPriority: 5, // Won't be visible but do the work in case it becomes visible.
}

相对于 PriorityLevel 的简单层级划分,在 React 16.x 版本中使用的则是 ExpirationTime 的到期时间方式表示任务的优先级,可以更好的对任务进行切分,调度

调度器(Scheduler)

我们在之前介绍的协调器的主要作用就是在组件状态变更时,调用组件树各组件的 render 方法,渲染,卸载组件,而 Fiber 使得应用可以更好的协调不同任务的执行,协调器内关于高效协调的实现,我们可以称它为调度器(Scheduler

顾名思义,调度器即调度资源以执行指定任务,React 应用中应用组件的更新与渲染,需要占用系统 CPU 资源,如果不能很好的进行资源平衡,合理调度,优化任务执行策略,那很容易造成 CPU 这一紧缺资源的消耗和浪费,容易造成页面卡顿,动画掉帧,组件更新异常等诸多问题

React 15.x 版本中,组件的状态变更将直接导致其子组件树的重新渲染,新版本 Fiber 算法将在调度器方面进行全面改进,主要的关注点是

  1. 合并多次更新,没有必要在组件的每一个状态变更时都立即触发更新任务,有些中间状态变更其实是对更新任务所耗费资源的浪费
  2. 任务优先级,不同类型的更新有不同优先级,例如用户操作引起的交互动画可能需要有更好的体验,其优先级应该比完成数据更新高
  3. 推拉式调度,基于推送的调度方式更多的需要开发者编码间接决定如何调度任务,而拉取式调度更方便 React 框架层直接进行全局自主调度

下面我们就来简单的了解一下调度器与优先级与任务调度相关内容

  • 调度器与优先级

React 的协调算法中,任务由 Fiber 实例描述,所以要划分任务优先级,等效于设置 Fiber 的到期时间(expirationTime),调度器内提供了 computeExpirationForFiber 方法以计算某一个 Fiber 的到期时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function computeExpirationForFiber(fiber) {
let expirationTime;
if (isWorking) {
if (isCommitting) {
expirationTime = Sync // 若当前处于任务提交阶段(更新提交至 DOM 渲染)时,设置当前 fiber 到期时间为 Sync,即同步执行模式
} else {
expirationTime = nextRenderExpirationTime // 若处于 DOM 渲染阶段时,则需要延迟此 fiber 任务,将 fiber 到期时间设置为下一次 DOM 渲染到期时间
}
} else { // 若不在任务执行阶段,则需重新设置 fiber 到期时间
if ( // 若明确设置 useSyncScheduling 且 fiber.internalContextTag 值不等于 AsyncUpdates,则表明是同步模式,设置为 Sync
useSyncScheduling
&& !(fiber.internalContextTag & AsyncUpdates)
) {
expirationTime = Sync // 同步更新,设置为同步标记
} else { // 否则,调用computeAsyncExpiration方法重新计算此fiber的到期时间
expirationTime = computeAsyncExpiration() // 异步更新,计算异步到期时间
}
}
return expirationTime
}

// 计算异步任务的到期时间(得到 ExpirationTime 单位的当前时间)
function computeAsyncExpiration() {
const currentTime = recalculateCurrentTime() // 聚合相似的更新在一起,更新应该在 ~1000ms,最多 1200ms 内完成
const expirationMs = 1000 // 对于每个 fiber 的期望到期时间的增值,最大值为 1000ms
const bucketSizeMs = 200 // 到期时间的可接受误差时间 200ms
// 返回包含误差时间在内的到期时间
return computeExpirationBucket(currentTime, expirationMs, bucketSizeMs)
}
  • 任务调度

React 应用更新时,Fiber 从当前处理节点,层层遍历至组件树根组件,然后开始处理更新,调用前面的 requestIdleCallbackAPI 执行更新处理,主要调度逻辑是通过 scheduleWork 来实现的

  1. 通过 fiber.return 属性,从当前 Fiber 实例层层遍历至组件树根组件
  2. 依次对每一个 Fiber 实例进行到期时间判断,若大于传入的期望任务到期时间参数,则将其更新为传入的任务到期时间
  3. 调用 requestWork 方法开始处理任务,并传入获取的组件树根组件 FiberRoot 对象和任务到期时间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// expirationTime 为期望的任务到期时间
function scheduleWork(fiber, expirationTime: ExpirationTime) {
return scheduleWorkImpl(fiber, expirationTime, false)
}

function scheduleWorkImpl(fiber, expirationTime) {
let node = fiber
while (node !== null) {
if (node.expirationTime === NoWork || // 向上遍历至根组件 fiber 实例,并依次更新 expirationTime 到期时间
node.expirationTime > expirationTime
) {
node.expirationTime = expirationTime // 若 fiber 实例到期时间大于期望的任务到期时间,则更新 fiber 到期时间
}
if (node.alternate !== null) { // 同时更新 alternate fiber 的到期时间
if (node.alternate.expirationTime === NoWork ||
node.alternate.expirationTime > expirationTime
) {
node.alternate.expirationTime = expirationTime // 若 alternate fiber 到期时间大于期望的任务到期时间,则更新 fiber 到期时间
}
}
if (node.return === null) { // node.return 为空,说明到达组件树顶部
if (node.tag === HostRoot) {
const root = node.stateNode // 确保是组件树根组件并获取 FiberRoot 实例
requestWork(root, expirationTime) // 请求处理任务
} else {
return
}
}
node = node.return // 获取父级组件 fiber 实例
}
}

处理任务的 requestWork 方法实现如下

  1. 首先比较任务剩余到期时间和期望的任务到期时间,若大于,则更新值
  2. 判断任务期望到期时间(expirationTime),区分同步或异步执行任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 当根节点发生更新时,调度器将调用 requestWork 方法开始任务处理过程
function requestWork(root: FiberRoot, expirationTime) {
const remainingExpirationTime = root.remainingExpirationTime
if (remainingExpirationTime === NoWork ||
expirationTime < remainingExpirationTime
) {
root.remainingExpirationTime = expirationTime // 若任务剩余到期时间大于期望的任务到期时间,则需要更新
}
if (expirationTime === Sync) {
performWork(Sync, null) // 同步
} else {
scheduleCallbackWithExpiration(expirationTime) // 异步
}
}

更新队列(UpdateQueue)

我们知道如果需要实现组件的异步更新,肯定需要在更新前将更新任务进行存储,然后异步任务开始的时候读取更新并实现组件更新,存储更新任务就需要一个数据结构,最常见的就是栈和队列,Fiber 的实现方式就是队列

Fiber 切分任务为多个任务单元(Work Unit)后,需要划分优先级然后存储在更新队列,随后按优先级进行调度执行,我们知道每一个组件都对应有一个 Fiber 实例,Fiber 实例即负责管理调度组件的任务单元,所以需要为每一个组件 Fiber 实例维护一个更新队列,Fiber 更新队列由 ReactFiberUpdateQueue 模块实现,主要涉及

  • 创建更新队列
  • 添加更新至更新队列
  • 添加更新至 Fiber(即 Fiber 实例对应的更新队列)
  • 处理更新队列中的更新并返回新状态对象

这里我们就简单的了解一下它的数据结构,具体的更新过程就不详细展开了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 一个更新对应的数据结构
export type Update<State> = {
expirationTime: ExpirationTime,
partialState: PartialState<any, any>,
callback: Callback | null,
isReplace: boolean,
isForced: boolean,
next: Update<State> | null,
}

// 更新队列,以单链表形式表示并持久化
// 调度一个更新任务时,将其添加至当前(current)fiber 和 workInProgress fiber 的更新队列中
// 这两个更新队列相互独立但共享同一个持久化数据结构
// workInProgress 更新队列通常是 current fiber 更新队列的子集
// 发生协调时,更新任务从 workInProgress fiber 更新队列移除
// current fiber 内的更新任务则保留,当 workInProgress 中断时可以从 current fiber 恢复
// 提交完更新时,workInProgress fiber 就会变成 current fiber
export type UpdateQueue<State> = {
// 若存在更早添加至队列的更新未被处理
// 则此已处理的更新并不会从队列中移除(先进先出原则)
// 所以需要维护 baseState,代表第一个未处理的更新的基础状态
// 通常这就是队列中的第一个更新,因为在队列首部的已处理更新会被移除
baseState: State,
// 同理,需要维护最近的未处理的更新的到期时间
// 即未处理更新中到期时间值最小的
expirationTime: ExpirationTime,
first: Update<State> | null,
last: Update<State> | null,
callbackList: Array<Update<State>> | null,
hasForceUpdate: boolean,
isInitialized: boolean
}

更新器(Updater)

调度器协调,调度的任务主要就是执行组件或组件树更新,而这些任务则具体由更新器(Updater)完成,可以说调度器是在整个应用组件树层面掌控全局,而更新器则深入到个更具体的每一个组件内部执行,每一个组件实例化时都会被注入一个更新器,负责协调组件与 React 核心进程的通信,其职责主要可以概括为以下几点

  • 找到组件实例对应的 Fiber 实例
  • 询问调度器当前组件 Fiber 实例的优先级
  • 将更新推入 Fiber 的更新队列
  • 根据优先级调度更新任务

其主要实现以下几个功能

  • 初始化组件实例并为其设置 fibre 实例和更新器
  • 初始化或更新组件实例,根据更新队列计算得到新状态等
  • 调用组件实例生命周期方法,并且调用更新器 API 更新 Fiber 实例等

主要流程有下面几个

  • 获取 Fiber 实例
  • 获取优先级,Fiber 实例的优先级是由调度器控制,所以需要询问调度器关于当前 Fiber 实例的优先级
  • 将更新任务添加至更新队列,组件状态变更时,将对应的组件更新任务划分优先级并根据优先级从高到低依次推入 Fiber 实例的更新队列
  • 调度更新任务

渲染阶段与提交阶段

React 在两个主要阶段执行工作,它们是 rendercommit

在第一个 render 阶段,React 通过 setUpdateReact.render 计划性的更新组件,并确定需要在 UI 中更新的内容,如果是初始渲染,React 会为 render 方法返回的每个元素创建一个新的 Fiber 节点,在后续更新中,现有 React 元素的 Fiber 节点将被重复使用和更新,这一阶段是为了得到标记了副作用的 Fiber 节点树,副作用描述了在下一个 commit 阶段需要完成的工作,在当前阶段,React 持有标记了副作用的 Fiber 树并将其应用于实例,它遍历副作用列表、执行 DOM 更新和用户可见的其他更改

这里需要我们注意的是,『第一个 render 阶段的工作是可以异步执行的』,即

React 可以根据可用时间片来处理一个或多个 Fiber 节点,然后停下来暂存已完成的工作,并转而去处理某些事件,接着它再从它停止的地方继续执行,但有时候,它可能需要丢弃完成的工作并再次从顶部开始,由于在此阶段执行的工作不会导致任何用户可见的更改(如 DOM 更新),因此暂停行为才有了意义,与之相反的是,后续 commit 阶段始终是同步的,这是因为在此阶段执行的工作会导致用户可见的变化,例如 DOM 更新,这就是为什么 React 需要在一次单一过程中完成这些更新

其实简单来说,渲染阶段可以认为是协调阶段,这个阶段可以被中断,处于这个阶段的时候 React 会找出所有节点变更,目的是得到标记了副作用的 Fiber 节点树,在这个过程当中以下生命周期钩子会在渲染阶段被调用

  • constructor
  • UNSAFE_componentWillMount(弃用)
  • UNSAFE_componentWillReceiveProps(弃用)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • UNSAFE_componentWillUpdate(弃用)
  • render

因为 render 阶段不会产生像 DOM 更新这样的副作用,所以 React 可以异步处理组件的异步更新(甚至可能在多个线程中执行),也就是说在渲染阶段如果时间片用完,React 就会选择让出控制权,因为渲染阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题,但是因为渲染阶段可能被中断、恢复,甚至重做,React 渲染阶段的生命周期钩子可能会被调用多次,例如 componentWillMount 可能会被调用两次,所以建议渲染阶段的生命周期钩子不要包含副作用,索性 React 就废弃了这部分可能包含副作用的生命周期方法,例如 componentWillMountcomponentWillUpdate 等(这也是新版 React 的生命周期有所调整的原因)

下面我们再来看看提交阶段涉及到的一些生命周期方法,它会将上一个阶段计算出来的需要处理的副作用(Effect)一次性执行了,这个阶段必须同步执行,不能被打断

  • getSnapshotBeforeUpdate()(严格来说这个是在进入 commit 阶段前调用)
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

现在我们应该知道为什么说在提交阶段必须同步执行,不能中断的吧?因为我们要正确地处理各种副作用,包括 DOM 变更、还有在 componentDidMount 中发起的异步请求、useEffect 中定义的副作用等,因为有副作用,所以必须保证按照次序只调用一次,况且会有用户可以察觉到的变更,不容差池

至于为什么要拆分两个阶段,可以参考 What is meant within the README of create-subscription by async limitations? Can it be clarified? 这篇文章

因为关于 Fiber 架构的相关内容我们在上面已经简单介绍过了,所以下面我们就站在整体的角度上简单的回顾一下整个过程到底是什么样子的

渲染阶段

协调算法始终使用 renderRoot 函数从最顶层的 HostRoot 节点开始,不过 React 会略过已经处理过的 Fiber 节点,直到找到未完成工作的节点,例如如果在组件树中的深层组件中调用 setState 方法,则 React 将从顶部开始,但会快速跳过各个父项,直到它到达调用了 setState 方法的组件

而且在第一次渲染之后,React 会得到一个 Fiber 树,这棵树是在 Virtual DOM 树的基础上增加额外的信息来生成的,它本质来说是一个链表,它反映了用于渲染 UI 的应用程序的状态,这棵树通常被称为 current 树(当前树)

React 开始处理更新时,它会构建一个所谓的 workInProgress 树(工作过程树),它反映了要刷新到屏幕的未来状态,这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行,如果没有,则继续构建树的过程

workInProgress 树构建这种技术类似于图形化领域的双缓存(Double Buffering)技术,图形绘制引擎一般会使用双缓冲技术,先将图片绘制到一个缓冲区,再一次性传递给屏幕进行显示,这样可以防止屏幕抖动,优化渲染性能,放到 React 中,workInProgress 树就是一个缓冲,它在协调完毕后一次性提交给浏览器进行渲染,它可以减少内存分配和垃圾回收,workInProgress 的节点不完全是新的,比如某颗子树不需要变动,React 会克隆复用旧树中的子树,另外一个重要的场景就是异常的处理,比如当一个节点抛出异常,仍然可以继续沿用旧树的节点,避免整棵树挂掉

其中所有的 Fiber 节点都会在工作循环中进行处理,其中 workLoopSync()React 开始构建树的地方,源码可见 workLoopSync

1
2
3
4
5
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}

React 遍历 current 树时,对于每个现有 Fiber 节点,React 会创建一个构成 workInProgress 树的备用节点,这一节点会使用 render 方法返回的 React 元素中的数据来创建,处理完更新并完成所有相关工作后,React 将准备好一个备用树以刷新到屏幕,一旦这个 workInProgress 树在屏幕上呈现,它就会变成 current 树,在处理完当前 Fiber 后,变量将持有树中下一个 Fiber 节点的引用或 null,在这种情况下,React 退出工作循环并准备好提交更改

其中遍历树、初始化或完成工作主要用到下面四个函数

  • performUnitOfWork()
  • beginWork()
  • completeUnitOfWork()
  • completeWork()

我们首先来看看 performUnitOfWork 这个函数

1
2
3
4
5
6
7
8
9
10
function performUnitOfWork(unitOfWork: Fiber): void {
// ...
const current = unitOfWork.alternate;
let next = beginWork(current, unitOfWork, subtreeRenderLanes)
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}

函数 performUnitOfWork 接收一个 Fiber 节点,并通过调用 beginWork 函数启动工作,这个函数将启动所有 Fiber 执行工作所需要的活动,函数 beginWork 的作用主要是从来节点比对,它始终返回指向要在循环中处理的下一个子节点的指针或 null,如果没有子节点,React 知道它到达了分支的末尾,因此它可以完成当前节点,『一旦节点完成,它将需要为同层的其他节点执行工作,并在完成后回溯到父节点』

下面来看看 completeUnitOfWork 函数执行的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function completeUnitOfWork(unitOfWork: Fiber): void {
// ...
let completedWork = unitOfWork
do {
const current = completedWork.alternate
if ((completedWork.effectTag & Incomplete) === NoEffect) {
let next = next = completeWork(current, completedWork, subtreeRenderLanes)
if (next !== null) {
workInProgress = next
return
}
}
} while (completedWork !== null)
}

我们可以看到函数的核心就是一个大的 do-while 的循环,当 workInProgress 节点没有子节点时,React 会进入此函数,完成当前 Fiber 节点的工作后,它就会检查是否有同层节点,如果找的到,React 退出该函数并返回指向该同层节点的指针

这里我们需要注意的是,在当前节点上,React 只完成了前面的同层节点的工作,它尚未完成父节点的工作,只有在完成以子节点开始的所有分支后,才能完成父节点和回溯的工作

从实现中也可以看出 performUnitOfWorkcompleteUnitOfWork 主要用于迭代目的,而主要活动则在 beginWorkcompleteWork 函数中进行,也如我们所见,这四个函数一起执行工作单元的工作,并且还控制当前正在完成的工作,也就是下图当中所示这般

提交阶段

渲染阶段完成后,React 进入提交阶段,这一阶段从函数 completeRoot 开始,在这个阶段 React 更新 DOM 并调用变更生命周期之前及之后方法的地方,当 React 进入这个阶段时,它有两棵树和副作用列表,第一个树表示当前在屏幕上渲染的状态,然后在 render 阶段会构建一个备用树,它在源代码中称为 finishedWorkworkInProgress,表示需要映射到屏幕上的状态,此备用树会用类似的方法通过 childsibling 指针链接到 current

然后在构造 Fiber 树的过程中,Fiber Reconciler 会将需要更新的节点信息都保存在 Effect List 当中,也就是所谓的副作用列表,它是 finishedWork 树的节点子集,通过 nextEffect 指针进行链接,需要注意的是,副作用列表是运行 render 阶段的『结果』,渲染的重点就是确定需要插入、更新或删除的节点,以及哪些组件需要调用其生命周期方法,这就是副作用列表告诉我们的内容,它页正是在 commit 阶段迭代的节点集合

commit 阶段运行的主要函数是 commitRoot,它执行如下下操作

  • 在标记为 Snapshot 副作用的节点上调用 getSnapshotBeforeUpdate 生命周期
  • 在标记为 Deletion 副作用的节点上调用 componentWillUnmount 生命周期
  • 执行所有 DOM 插入、更新、删除操作
  • finishedWork 树设置为 current
  • 在标记为 Placement 副作用的节点上调用 componentDidMount 生命周期
  • 在标记为 Update 副作用的节点上调用 componentDidUpdate 生命周期

在调用变更前方法 getSnapshotBeforeUpdate 之后,React 会在树中提交所有副作用,这会通过两波操作来完成

  • 第一波执行所有 DOM(宿主)插入、更新、删除和 ref 卸载,然后 ReactfinishedWork 树赋值给 FiberRoot,将 workInProgress 树标记为 current 树,这是在提交阶段的第一波之后、第二波之前完成的,因此在 componentWillUnmount 中前一个树仍然是 current,在 componentDidMount/Update 期间已完成工作是 current
  • 第二波,React 调用所有其他生命周期方法和引用回调,这些方法单独传递执行,从而保证整个树中的所有放置、更新和删除能够被触发执行

这里借用 Lin Clark 演讲当中的一张图(见 React Fiber),我们可以清晰的发现这种变化

上图是协调完成后的状态,左边是旧树,右边是 WIP 树,对于需要变更的节点,都打上了标签,在提交阶段 React 就会将这些打上标签的节点应用变更

以上关于 React Fiber 的相关内容我们就介绍到这里,在接下来的 Fiber 架构的简单实现 的章节当中我们会继续深入理解其原理,并且尝试手动的实现一个简易版本的 Fiber

参考

# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×