剖析React内部运行机制-「译」React Fiber 架构

2,916 阅读14分钟

原文: React Fiber Architecture


介绍

React Fiber是React核心算法的持续再实现,这是React团队两年多研究的成果。

React Fiber的目标是增强其在动画、布局和手势等领域的适用性。它的主要特性是增量渲染:能够将渲染工作分割成块,并将其分散到多个帧中。

其他关键功能包括暂停、中止或重启更新工作的能力;为不同类型的更新分配优先级的能力;以及新的并发单元。

关于这篇文章

Fiber引入了一些新的概念,这些概念很难仅仅通过查看代码来理解。本文档一开始是我在关注React项目中Fiber的实现过程中所做的笔记。随着它的发展,我意识到它可能对其他人也很有帮助。

我将尽量使用最简单的语言,并通过明确定义关键术语来避免术语。如果可能的话,我还将大量链接到外部资源。

请注意,我不是React团队的一员,算不上权威人士。这不是官方文档。 我已经请求React团队的成员检查它的准确性。

这也是一项正在进行中的工作。Fiber是一个正在进行的项目,在它完成之前可能会经历重大的重构。 我也会继续尝试在这里记录它的设计,欢迎提出改进和建议。

我的目标是在阅读了本文之后,你将能够很好地理解Fiber,以便在其实现过程中进行跟踪,并最终能够对其作出贡献。

预备知识

在继续学习之前,我强烈建议你熟悉以下资源:

  • React 组件, 元素和实例 - ‘组件’经常是一个被多重理解的术语,牢牢掌握这些术语是至关重要的。
  • 协调(Reconciliation) - React协调算法的高级描述。
  • 基本理论概念 - 对没有实现负担的React概念模型的描述。其中的一些可能在第一次阅读时并不能产生深刻的认知。没关系,随着时间的推移会理解越来越深刻。
  • React设计原则 - 一定要多注意work调度部分,它很好的解释了为什么是React Fiber。

回顾

请检查预备知识部分的内容是否已经准备好。

在开始学习新内容之前,让我们先复习一些概念。

什么是协调算法?

协调算法

React使用该算法来比较两棵树之间的不同,然后决定出需要改变的部分。

更新

用于渲染React应用程序的数据变化通常是setState的结果,最终导致重新渲染。

React API的核心思想是将更新看作是导致整个应用程序重新渲染的原因。这允许开发人员以声明的方式推理,而不必担心如何有效地将应用程序从一些特定状态转换到另一个状态(A到B, B到C, C到A,等等)。

实际上,在每次更改时重新渲染整个应用程序只适用于最普通的应用;在真正的应用程序中,它所消耗的性能代价非常高。React对此进行了优化,在保持良好性能的同时重新渲染整个应用的界面。这些优化的大部分内容是一个被称为 协调 算法处理完成的。

协调是一种被普遍理解为“虚拟DOM”的算法。对其高层次的描述是这样的:当你渲染一个React应用,用于描述app的节点树被生成并且保存在内存中。然后这棵树会被放入到渲染环境——比如,在基于浏览器的应用中,它被转换成一组DOM操作。当app更新后(一般通过setState),一个新的树就会随之产生。这颗新树与先前那棵树的不同是它要计算出哪些操作需要更新到渲染app中。

尽管Fiber是对“协调算法”底层方面全新的重写,但是这个高级算法在React文档中描述大致相同。关键点在于:

  • 不同的组件类型被认定会生成不同的树,React并不会尝试去diff它们,而是将旧的树完全替换。
  • 多个组件(一个list)之间的diff使用它们keys,因为keys是“稳定的、可预测的和惟一的”。

协调与渲染

DOM只是React可以渲染的“渲染环境”之一,其他主要的目标是通过React native实现的iOS和Android视图。(这就是为什么“虚拟DOM”有点用词不当。)

它之所以能够支持如此多的目标,是因为React被设计成协调和渲染是两个独立的阶段。协调器要做的工作是计算出一棵树的哪些部分发生了变化;然后渲染器使用这些信息来更新渲染app。

这种分离意味着React DOM和React Native可以使用它们自己的渲染器,同时共享React core提供的同一个协调器。

Fiber重新实现了协调器。它变得渲染没有太高的相关性,因此渲染器也需要改变以支持(并利用)新的架构。

调度

调度

确定何时完成work的过程。

work

一些必须执行的运算。Work通常是更新的结果(例如setState)。

React的 设计原则 文档在这方面做得很好,我在这里引用一下:

在其当前实现中,React递归遍历树,并在单个标记期间调用整个更新树的呈现函数。但是在将来,它可能会开始延迟一些更新以避免丢失帧 (可以理解为交互卡顿)

这是React设计中的一个常见主题。一些流行的框架实现了“push”方法,即在新数据可用时执行计算。然而,React坚持使用“pull”方法,在这种方法中,计算可以延迟到必要的时候。

React不是一般的数据处理框架。它是一个用于构建用户界面的框架。我们认为它在应用程序中处于独特的位置,可以知道哪些计算是相关的,哪些不是。

如果浏览器屏幕外发生了什么(比如click事件等),我们可以延迟与之相关的一些逻辑。如果数据到达的速度快于帧速率,我们可以将它们合并然后批量更新。我们可以优先处理来自用户交互的工作(如单击按钮所引起的动画),而不是不太重要的后台工作(如渲染刚从网络加载的新内容),以避免丢失帧 (可以理解为交互卡顿)

重点是:

  • 在UI层面上,并不需要把每一次更新都立刻呈现出来;事实上,做这类的事情是很浪费资源的,这会导致帧下降以及降低用户体验。
  • 不同类型的更新有不同的优先权——动画更新需要比来自数据存储的更新更快地完成。
  • 基于push的方法需要应用程序(你,程序员)来决定如何安排工作。基于pull的方法允许框架(React)为您做出明智的决策。

React在此之前并没有很好地利用调度,因此一个更新会立即重新渲染整个子树。今天,利用调度优势的React核心算法是Fiber背后的驱动思想。


现在,我们准备深入讨论Fiber的实现。下一节比我们到目前为止讨论的内容更具有技术性。在继续之前,请确保你对前面的内容达到可以接受的状态。

什么是fiber?

我们将讨论React Fiber的核心架构。相对于应用开发人员通常的认知,Fiber是一个相当底层的抽象。如果你发现自己在试图理解它的过程中受挫,不要气馁。不断尝试,最终会搞定它的。(当你最终理解它后,请建议我们如何更好的提高本章节的描述)。

我们开始吧!


我们已经为Fiber确立了一个主要的目标就是让React更好的利用调度。具体来说,我们需要能够:

  • 暂停工作,稍后再回来做。
  • 为不同类型的work定义优先级。
  • 重用以前完成的工作。
  • 丢弃不再需要的工作。

为了做到这一点,我们首先需要一种把work分解成单位的方法。从某种意义上说,这就是Fiber。一根 光纤 代表一个工作单位。

为了更进一步,让我们回到 React组件作为数据函数 的概念,通常表示为:

v = f(d)

渲染React app好比调用一个函数,该函数的函数体包含了对其他函数的调用等等。这个类比在讨论Fiber时很有用。

计算机跟踪程序执行的典型方式就是使用“堆栈”。当一个函数被执行,一个新的堆栈帧被添加到堆栈中。这个堆栈帧代表了函数所执行的那项工作。

在处理ui时,遇到的问题是如果一次性执行了太多的工作,就会导致动画丢失帧,看起来很不稳定。更重要的是,一些工作可能是不必要的,如果它被一个更近期的更新取代。这就是UI组件和函数之间的比较失败的地方,因为组件比一般的函数有更具体的关注点。

更新的浏览器(和React Native)实现的api可以帮助解决这个问题:requestIdleCallback在空闲期间调度被调用的低优先级函数,requestAnimationFrame在下一个动画帧调度被调用的高优先级函数。现在的问题是,为了使用这些api,你需要一种方法来将渲染工作分解为增量单元。如果你只依赖于调用堆栈,它将一直工作,直到堆栈为空。

如果我们可以定制调用堆栈的行为来优化渲染ui,那岂不是更好?如果我们可以随意中断调用堆栈并手动操作堆栈帧,那岂不是更好?

这就是React Fiber的目标。Fiber是栈的重新实现,专门用于React组件。你可以将单个Fiber视为一个虚拟堆栈帧。

重新实现堆栈的好处是,你可以 将堆栈帧保存在内存中 ,并在需要的时候以任何方式执行它们。这对于完成我们的调度目标是至关重要的。

除了调度之外,手动处理堆栈帧还可以解决并发性和错误边界等潜在特性问题。我们将在以后的章节中讨论这些问题。

在下一节中,我们将更多地研究一个fiber的结构。

fiber 的结构

注意:随着我们对实现细节的了解越来越具体,发生变化的可能性也越来越大。如果您注意到任何错误或过时的信息,请提交一份PR。

具体而言,fiber是一个JavaScript对象,它包含关于组件的输入和输出信息。

一个fiber对应了一个堆栈帧,也对应了一个组件的实例。

以下是fiber的一些属性。(这份清单并不详尽。)

typekey

fiber的typekey在React元素上面有着相同的作用。事实上,当从一个元素创建一个fiber时,这两个字段被直接复制。

一个fiber的类型描述了它所对应的组件。对于复合组件,类型是函数或类组件本身。对于宿主组件(divspan等),类型是字符串。

从概念上讲,类型是函数(如v = f(d))的组件,它的执行会由堆栈帧跟踪。

除了类型之外,在协调期间还使用key来确定是否可以重用fiber。

childsibling

这两个字段指向了其他fiber节点,描述了fiber的递归树结构。

孩子(child)fiber节点对应着组件render函数的返回值。所以在下面的例子中

function Parent() {
  return <Child />
}

组件Parent的孩子fiber节点是Child组件。

sibling字段解释了渲染返回多个子元素的情况(这是Fiber的一个新特性!)。

function Parent() {
  return [<Child1 />, <Child2 />]
}

孩子fiber节点形成了一个链表,链表的头部是第一个孩子fiber节点。在这个例子中,父结点的子结点是Child1,而Child1的兄弟结点是Child2

回到我们的函数类比,你可以把孩子fiber想象成 尾部调用函数

return

返回fiber节点是程序在处理当前fiber节点后应该返回的fiber节点。它在概念上与堆栈帧的返回地址相同。它也可以被认为是父fiber节点。

如果一个fiber节点有多个孩子fiber节点,每一个孩子的返回fiber就是父fiber节点。在上一节的例子中,Child1Child2的返回fiber节点是父结点。

pendingPropsmemoizedProps

从概念上讲,属性是函数的参数。fiber的pendingProps设置在执行的开始,memoizedProps设置在执行的最后。

当输入的pendingPropsmemoizedProps相等时,就表示fiber 之前的输出可以重复使用,避免了不必要的工作。

pendingWorkPriority

在fiber中,用一个数字来表示work的优先级。ReactPriorityLevel 模块列出了不同的优先级和它们所代表的内容。

除了NoWork是0之外,较大的数字表示较低的优先级。例如,您可以使用以下函数来检查fiber节点的优先级是否至少与给定的级别相同:

function matchesPriority(fiber, priority) {
  return fiber.pendingWorkPriority !== 0 &&
         fiber.pendingWorkPriority <= priority
}

此函数仅供参考,它并不是React Fiber代码的一部分。

调度器使用priority字段搜索要执行的下一个工作单元。这个算法将在以后的章节中讨论。

alternate(替代)

flush(刷新)

刷新 fiber就是把它的输出渲染到屏幕上。

work-in-progress

一个fiber尚未完成工作,从概念上讲,就是尚未返回堆栈帧。

在任何时候,组件实例最多有两个与之对应的fiber树:当前的、已刷新的fiber和正在工作的fiber。

当前的替代fiber就是work-in-progress,work-in-progress的替代就是当前fiber。

fiber的替代是使用名为cloneFiber的函数惰性创建的。与总是创建新对象不同,cloneFiber将尝试重用存在的替代对象,从而最小化分配。

你应该将alternate字段视为实现细节,但是它在代码库中经常出现,因此有必要在这里讨论它。

output

host component

React应用程序的叶子节点。它们特定于呈现环境(例如,在浏览器应用程序中,它们是“div”“span”等)。在JSX中,它们用小写标记名表示。

从概念上讲,fiber的输出是函数的返回值。

每个fiber最终都有输出,但输出仅由 host components (宿主组件)在叶节点上创建。然后输出被传送到树上。

输出是最终给渲染器的,这样它就可以刷新更改到渲染环境。渲染器的职责是定义如何创建和更新输出。

今后的章节

这就是目前的全部内容,但是这个文档还远远没有完成。后面的部分将描述在更新的整个生命周期中使用的算法。主题包括:

  • 调度器如何找到下一个要执行的工作单元。
  • 如何通过fiber树跟踪和传播优先级。
  • 调度器如何知道什么时候暂停和重启工作。
  • work是如何被刷新以及被标记以及完成的。
  • 副作用(比如生命周期函数)是如何工作的。
  • 协同程序是什么,以及如何使用它来实现上下文和布局等功能。

相关视频

React的下一步(ReactNext 2016)