React Fiber 浅析

react-fiber

1. Background

我们知道从广义上来讲,浏览器是单线程的,它将 GUI 描绘,时间器处理,事件处理,js 执行,远程资源加载统统放在一起。在 React 15 及之前的版本,React 在对组件进行更新时,如果需要渲染更新的组件过于庞大,js 执行就会长时间占据主线程,导致页面的响应变慢。当然 React 也提供了优化的手段(shouldComponentUpdate),但是这种优化方式更多是依赖于使用者自身,这种单纯的人肉优化并没有很好地改善这种情况。

2. 一些前置概念

2-1. Renderers(渲染器)和 Reconcilers(协调器)

React 最开始服务于 DOM,后来又有了支持原生平台的 React Native,为了区分开这两者,React 内部提出了“**渲染器 (Renderers)**”的概念。

渲染器用于管理一棵 React 树,使其根据底层平台进行不同的调用。

即使 React DOM 和 React Native 渲染器区别很大,但也需要共享一些逻辑。特别是协调 (Reconciliation) 算法需要尽可能相似,这样可以让声明式渲染,自定义组件,state,生命周期方法和 refs 等特性,保持跨平台工作一致。

为了解决这个问题,不同的渲染器彼此共享一些代码。我们称 React 的这一部分为 “reconciler(协调器)”。当处理类似于 setState() 这样的更新时,reconciler 会调用树中组件上的 render(),然后决定是否进行挂载,更新或是卸载操作。

2-2. Reconciliation 协调

React 是一个用于构建用户界面的 JavaScript 库,一个核心的机制是跟踪组件的状态变化,并将更新后的状态映射到新的界面。这个过程被称为 Reconciliation(协调)。我们调用 setState 方法来改变状态,而框架本身会去检查 state 或 props 是否已经更改来决定是否重新渲染组件。

在开始了解 Fiber 架构前,先简单看一下协调过程中的 “Diffing” 算法的设计决策:

React 的 render 方法在组件 state 和 props 变更时计算返回新的树,React 需要基于这新旧两棵树之间的差别来判断如何有效率的更新 UI 以保证当前 UI 与最新的树保持同步。

这个算法问题有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作数。 然而,即使在最前沿的算法中,该算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。

(引用自协调 - React

React 主要是基于以下的两个假设,对 “Difffing“ 算法进行了优化

  1. 两个不同类型的元素会产生出不同的树;
  2. 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定;

2-3. Fiber 架构的目标

上文已经提到,React 15 及更早的版本在更新组件时,会持续占用主线程,这样主线程上的布局、动画等周期性任务以及交互响应就无法立即得到处理,导致掉帧。Stack reconciler 是指 React 15 及更早的 reconciler 解决方案,它其实是自顶向下的递归 mount/update,这种自顶向下递归的方法在节点较多的时候会需要很长的处理时间。

stack-reconciler

于是就有人提出来,如果仅依靠浏览器自己去调用堆栈,它将一直工作到堆栈为空为止……如果我们能够随意中断堆栈的调用并手动操作堆栈帧,那不是很好吗?通过设置固定的时间分片,在每个分片内灵活地处理堆栈的任务,并且进行一次页面的渲染,这样就可以保证页面的刷新频率。

fiber-reconciler

但是除了需要对任务进行切片,还需要增加一个任务的优先级,因为对于用户体验来说,用户输入的响应事件要优先于请求填充内容,并且高优先级的任务可以打断低优先级的任务。

Fiber 架构的主要目标是(后两个其实属于附带的目标):

  • 能够把可中断的任务切片处理。
  • 能够调整优先级,重置并复用任务。
  • 能够在父元素与子元素之间交错处理,以支持 React 中的布局。
  • 能够在 render() 中返回多个元素。
  • 更好地支持错误边界。

3. 数据结构及算法

3-1. 从 React 元素到 Fiber 节点

在讲 Fiber 架构之前,我们先看一下 React 在 V15 及之前是怎么处理虚拟 DOM 以及实际节点的。在 React 运行的时候,会存在有以下三种实例:

DOM - 真实的 DOM 节点
Instances - react 维护的虚拟 DOM 节点
Elements - 对 UI 进行描述 eg. type, props

显然要做到上文提到的增量更新,按照这些实例是很难满足的,所以 React 在原有的基础上进行扩展:

DOM - 真实的 DOM 节点
effect - 即副作用 (side effect),包括 DOM change 等操作
workInProgress - workInProgress tree是reconcile过程中从fiber tree建立的当前进度快照,用于断点恢复
fiber - fiber tree与vDOM tree类似,用来描述增量更新所需的上下文信息
Elements - 对 UI 进行描述 eg. type, props

fiber tree上各节点的主要结构(每个节点称为fiber):

1
2
3
4
5
6
7
8
// fiber tree节点结构
{
stateNode,
child,
return,
sibling,
...
}

return表示当前节点处理完毕后,应该向谁提交自己的成果(effect list),在这里 fiber tree 使用的是链表结构

3-2. Fiber 节点

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

根据不同 React 元素的类型,框架需要执行不同的活动。对于类组件,它调用生命周期方法和 render 方法,而对于 span 宿主组件(DOM 节点),它进行得是 DOM 修改。因此,每个 React 元素都会转换为 相应类型 的 Fiber 节点,用于描述需要完成的工作。

您可以将 Fiber 视为表示某些要做的工作的数据结构,或者说,是一个工作单位。Fiber 的架构还提供了一种跟踪、规划、暂停和销毁工作的便捷方式。

当 React 元素第一次转换为 Fiber 节点时,React 在 createFiberFromTypeAndProps 函数中使用元素中的数据来创建 Fiber。在随后的更新中,React 会再次利用 Fiber 节点,并使用来自相应 React 元素的数据更新必要的属性。如果不再从 render 方法返回相应的 React 元素,React 可能还需要根据 key 属性来移动或删除层级结构中的节点。

3-3. Fiber Tree

上文提到 fiber tree 是链表结构,链表结构跟以往的 reactNode tree 相比,有着更高的遍历效率,并且是可暂停、撤销、重新开始的。

fiber-tree

3-4. current tree 及 workInProgress tree

在第一次渲染之后,React 最终得到一个 Fiber 树,它反映了用于渲染 UI 的应用程序的状态。这棵树通常被称为 current 树(当前树)。当 React 开始处理更新时,它会构建一个所谓的 workInProgress 树(工作过程树),它反映了要刷新到屏幕的未来状态。

所有工作都在 workInProgress 树的 Fiber 节点上执行。当 React 遍历 current 树时,对于每个现有 Fiber 节点,React 会创建一个构成 workInProgress 树的备用节点,这一节点会使用 render 方法返回的 React 元素中的数据来创建。处理完更新并完成所有相关工作后,React 将准备好一个备用树以刷新到屏幕。一旦这个 workInProgress 树在屏幕上呈现,它就会变成 current 树。这种方法我们称之为双缓冲技术 (double buffering)

React 的核心原则之一是一致性。 React 总是一次性更新 DOM - 它不会显示部分中间结果。workInProgress 树充当用户不可见的「草稿」,这样 React 可以先处理所有组件,然后将其更改刷新到屏幕。
在源代码中,您将看到很多函数从 current 和 workInProgress 树中获取 Fiber 节点。这是一个这类函数的签名:

1
2
3
function updateHostComponent(current, workInProgress, renderExpirationTime) {
...
}

每个Fiber节点持有备用域在另一个树的对应部分的引用。来自 current 树中的节点会指向 workInProgress 树中的节点,反之亦然。

这样做的好处:

  • 能够复用内部对象(fiber)
  • 节省内存分配、GC的时间开销

3-5. 优先级策略

每个工作单元运行时有6种优先级:

  • synchronous 与之前的Stack reconciler操作一样,同步执行
  • task 在next tick之前执行
  • animation 下一帧之前执行
  • high 在不久的将来立即执行
  • low 稍微延迟(100-200ms)执行也没关系
  • offscreen 下一次render时或scroll时才执行

synchronous 首屏(首次渲染)用,要求尽量快,不管会不会阻塞UI线程。animation 通过requestAnimationFrame 来调度,这样在下一帧就能立即开始动画过程;后3个都是由requestIdleCallback回调执行的;offscreen 指的是当前隐藏的、屏幕外的(看不见的)元素

高优先级的比如键盘输入(希望立即得到反馈),低优先级的比如网络请求,让评论显示出来等等。另外,紧急的事件是允许插队的。

3-6. requestIdleCallback 和 requestAnimationFrame

一些较新的浏览器提供了这个 API,我们可以看一下 MDN 上的介绍:

window.requestIdleCallback() 方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。

函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。值得留意的是,为了兼顾更多的浏览器和确定空闲时间间隔等原因,React 是自己内部实现了一个 requestIdleCallback。

3-7. 工作循环

所有的 Fiber 节点都会在 工作循环 中进行处理。如下是该循环的同步部分的实现:

1
2
3
4
5
6
7
function workLoop(isYieldy) {
if (!isYieldy) {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
} else {...}
}

nextUnitOfWork 持有 workInProgress 树中的 Fiber 节点的引用,这个树有一些工作要做。当 React 遍历 Fiber 树时,它会使用这个变量来知晓是否有任何其他 Fiber 节点具有未完成的工作。处理过当前 Fiber 后,变量将持有树中下一个 Fiber 节点的引用或 null。在这种情况下,React 退出工作循环并准备好提交更改。
遍历树、初始化或完成工作主要用到 4 个函数:

  • performUnitOfWork
  • beginWork
  • completeUnitOfWork
  • completeWork

为了演示他们的使用方法,我们可以看看如下展示的遍历 Fiber 树的动画。我已经在演示中使用了这些函数的简化实现。每个函数都需要对一个 Fiber 节点进行处理,当 React 从树上下来时,您可以看到当前活动的 Fiber 节点发生了变化。从视频中我们可以清楚地看到算法如何从一个分支转到另一个分支。它首先完成子节点的工作,然后才转移到父节点进行处理。

wookloop

3-7. reconciliation

以 fiber tree 为蓝本,把每个 fiber 作为一个工作单元,自顶向下逐节点构造 workInProgress tree(构建中的新 fiber tree)

具体过程如下(以组件节点为例):

  1. 如果当前节点不需要更新,直接把子节点 clone 过来,跳到5;要更新的话打个 tag
  2. 更新当前节点状态(props, state, context等)
  3. 调用 shouldComponentUpdate(),false 的话,跳到5
  4. 调用 render()获得新的子节点,并为子节点创建 fiber(创建过程会尽量复用现有 fiber,子节点增删也发生在这里)
  5. 如果没有产生 child fiber,该工作单元结束,把 effect list 归并到 return,并把当前节点的 sibling 作为下一个工作单元;否则把 child 作为下一个工作单元
  6. 如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元;否则,立即开始做
  7. 如果没有下一个工作单元了(回到了 workInProgress tree 的根节点),第 1 阶段结束,进入pendingCommit 状态

际上是1-6的工作循环,7是出口,工作循环每次只做一件事,做完看要不要喘口气。工作循环结束时, workInProgress tree 的根节点身上的 effect list 就是收集到的所有 side effect(因为每做完一个都向上归并)

所以,构建 workInProgress tree 的过程就是 diff 的过程,通过 requestIdleCallback 来调度执行一组任务,每完成一个任务后回来看看有没有插队的(更紧急的),每完成一组任务,把时间控制权交还给主线程,直到下一次 requestIdleCallback 回调再继续构建 workInProgress tree

4. 参考资料

  1. Inside Fiber: in-depth overview of the new reconciliation algorithm in React
  2. React Fiber Architecture
  3. Codebase Overview - React
  4. Reconciliation - React
  5. The how and why on React’s usage of linked list in Fiber to walk the component’s tree
  6. React Fiber架构 - 司徒正美
  7. 完全理解React Fiber
  8. window.requestAnimationFrame - Web API 接口参考 | MDN
  9. requestIdleCallback - Web API 接口参考 | MDN