深入浅出 React Fiber 原理实现

1,136 阅读5分钟

「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

相信使用过React的同学,对于ReactDOM.render(<App />, document.getElementById('root'))这句调用都不陌生。调用后,ReactDOM 在幕后构建 DOM 树并将应用程序呈现在屏幕上,但是 React 实际上是如何构建 DOM 树的呢?当应用程序的状态发生变化时,它如何更新树?

堆栈协调器

我们从这句代码开始:ReactDOM.render(<App />, document.getElementById('root'))

ReactDOM 模块会将 传递给协调器。这里有两个问题:<App/ >

  1. 什么是指什么?<App />
  2. 什么是调和器?

让我们解开这两个问题。

<App /> 是一个 React 元素,“元素描述了树”。

“元素是描述组件实例或 DOM 节点及其所需属性的普通对象。”

换句话说,元素不是实际的 DOM 节点或组件实例;它们是一种向React描述它们是什么类型的元素、它们拥有什么属性以及它们的子类是谁的方式。

这就是 React 真正的力量所在。React 将如何构建、渲染和管理实际 DOM 树的生命周期的所有复杂部分抽象出来,有效地使开发人员的生活更轻松。为了理解这真正意味着什么,让我们看看使用面向对象概念的传统方法。

在典型的面向对象编程世界中,开发人员需要实例化和管理每个 DOM 元素的生命周期。

让我们假设Button组件有一个状态变量isSubmitted,React正好可以解决这个问题。在 React 中,有两种元素:

  • DOM 元素:当元素的类型为字符串时,例如,<button class="okButton"> OK </button>
  • 组件元素:当类型是类或函数时,例如,其中是类或功能组件。这些是我们通常使用的典型 React 组件<Button className="okButton"> OK </Button>``<Button>

它们只是对需要在屏幕上呈现的内容的描述,实际上并不会在您创建和实例化它们时导致任何呈现。这使得 React 更容易解析和遍历它们以构建 DOM 树。实际渲染在遍历完成后发生。

当 React 遇到一个类或一个函数组件时,它会根据它的 props 询问该元素它呈现给什么元素。例如,如果<App>组件呈现这个:

<Form>
  <Button>
    Submit
  </Button>
</Form>

然后 React 会根据相应的 props询问<Form><Button>组件它们渲染的内容。例如,如果Form组件是一个如下所示的功能组件:

const Form = (props) => {
  return(
    <div className="form">
      {props.form}
    </div>
  )
}                   

React 将调用以了解它渲染的元素,并最终会看到它渲染了一个带有子元素的元素。React 会重复这个过程,直到它知道页面上每个组件的底层 DOM 标签元素。render()<div>

这种递归遍历树以了解 React 应用程序组件树的底层 DOM 标记元素的确切过程称为协调。在协调结束时,React 知道 DOM 树的结果,并且像 react-dom 或 react-native 这样的渲染器应用更新 DOM 节点所需的最小更改集

所以这意味着当你调用or 时,React 会执行一次协调。在 的情况下,它执行遍历并通过将新树与渲染树进行比较来找出树中发生了什么变化。然后它将这些更改应用于当前树,从而更新与调用对应的状态。ReactDOM.render()setState()

现在我们了解了什么是协调,让我们看看这个模型的缺陷。

哦,顺便说一句——为什么这被称为“堆栈”协调器?

该名称源自“堆栈”数据结构,这是一种后进先出机制。堆栈与我们刚刚看到的有什么关系?好吧,事实证明,由于我们有效地进行了递归,因此它与堆栈有关。

递归

function fib(n) {
  if (n < 2){
    return n
  }
  return fib(n - 1) + fib (n - 2)
}

正如我们所看到的,调用堆栈将每个调用推入堆栈,直到它 pops ,这是第一个返回的函数调用。然后它继续推送递归调用,并在到达 return 语句时再次弹出。通过这种方式,它可以有效地使用调用堆栈,直到返回并成为从堆栈中弹出的最后一项。fib()``fib(1)``fib(3)

现在,当我们提到丢帧时,我们的意思是什么,为什么这是递归方法的问题?为了理解这一点,让我从用户体验的角度简要说明什么是帧速率以及为什么它很重要。

帧率是连续图像出现在显示器上的频率。我们在计算机屏幕上看到的一切都是由屏幕上播放的图像或帧组成的,这些图像或帧以肉眼可见的瞬时速度播放。

要理解这意味着什么,可以将计算机显示器想象成一本翻书,而翻书的页面则是当你翻动它们时以某种速率播放的帧。换句话说,计算机显示器只不过是一个自动翻书,当屏幕上的事物发生变化时,它会一直播放。如果这没有意义,请观看下面的视频。

通常,为了让视频在人眼中感觉流畅且即时,视频需要以大约 30 帧每秒 (FPS) 的速率播放。任何高于此的值都会提供更好的体验。这是游戏玩家更喜欢在第一人称射击游戏中获得更高帧率的主要原因之一,因为在第一人称射击游戏中,精度非常重要。

话虽如此,如今大多数设备以 60 FPS 刷新屏幕——或者,换句话说,1/60 = 16.67 毫秒,这意味着每 16 毫秒显示一个新帧。这个数字非常重要,因为如果 React 渲染器在屏幕上渲染某些内容的时间超过 16 毫秒,浏览器将丢弃该帧。

然而实际上,浏览器有内务工作要做,所以你所有的工作都需要在 10 毫秒内完成。当您无法满足此预算时,帧速率会下降,并且屏幕上的内容会抖动。这通常称为卡顿,会对用户体验产生负面影响。

当然,对于静态和文本内容来说,这并不是一个值得关注的大问题。但是在显示动画的情况下,这个数字很关键。所以如果React reconciliation算法App每次有更新就遍历整棵树重新渲染,如果遍历时间超过16ms,就会造成丢帧,丢帧是坏的。

这就是为什么按优先级对更新进行分类而不是盲目地应用传递给协调器的每个更新会很好的一个重要原因。此外,另一个不错的功能是能够在下一帧中暂停和恢复工作。这样,React 将可以更好地控制它用于渲染的 16 毫秒预算。

这导致 React 团队重写了对账算法,新算法被称为 Fiber。我希望现在可以了解 Fiber 的存在方式和原因以及它的重要性。让我们看看 Fiber 是如何解决这个问题的。

Fiber的工作原理

实现这样的东西的挑战之一是 JavaScript 引擎的工作方式,以及在某种程度上缺乏语言中的线程。为了理解这一点,让我们简要探讨一下 JavaScript 引擎如何处理执行上下文。

JavaScript 执行栈

每当您用 JavaScript 编写函数时,JS 引擎都会创建我们所说的函数执行上下文。此外,每次 JS 引擎启动时,它都会创建一个全局执行上下文来保存全局对象——例如,window浏览器中的global对象和 Node.js 中的对象。这两个上下文都在 JS 中使用堆栈数据结构(也称为执行堆栈)进行处理。

所以,当你写这样的东西时:

function a() {
  console.log("i am a")
  b()
}

function b() {
  console.log("i am b")
}

a()

JavaScript 引擎首先创建一个全局执行上下文并将其推送到执行堆栈中。然后它为函数创建一个函数执行上下文。由于在 inside 中调用,它将为其创建另一个函数执行上下文并将其推入堆栈。a()``b()``a()``b()

当函数返回时,引擎销毁 的上下文,当我们退出函数时,销毁的上下文。执行期间的堆栈如下所示:b()``b()``a()``a()

JS 引擎处理队列中内容的方式是等待执行堆栈变空。因此,每次执行堆栈变空时,JS 引擎都会检查事件队列,从队列中弹出项目并处理该事件。需要注意的是,JS引擎只有在执行栈为空或者执行栈中唯一的一项是全局执行上下文时才会检查事件队列。

尽管我们称它们为异步事件,但这里有一个微妙的区别:这些事件在它们到达队列时是异步的,但在它们被实际处理时并不是真正的异步。

回到我们的堆栈协调器,当 React 遍历树时,它是在执行堆栈中进行的。所以当更新到达时,它们到达事件队列(有点)。并且只有当执行堆栈变空时,更新才会得到处理。这正是 Fiber 通过几乎重新实现具有智能功能的堆栈(暂停和恢复、中止等)来解决的问题。

在这里再次引用 Andrew Clark 的笔记:

“Fiber 是堆栈的重新实现,专门用于 React 组件。您可以将单个Fiber视为虚拟堆栈帧。

重新实现堆栈的优点是您可以将堆栈帧保存在内存中,并根据需要(以及任何时候)执行它们。这对于实现我们的调度目标至关重要。

除了调度之外,手动处理堆栈帧可以释放并发性和错误边界等功能的潜力。我们将在以后的章节中讨论这些主题。”

简单来说,Fiber代表一个具有自己虚拟堆栈的工作单元。在之前的协调算法实现中,React 创建了一个不可变的对象(React 元素)树,并递归地遍历树。

在当前的实现中,React 创建了一个可以变异的Fiber节点树。Fiber 节点有效地保存了组件的状态、道具以及它渲染到的底层 DOM 元素。

而且由于Fiber节点可以变异,React 不需要重新创建每个节点进行更新——它可以在有更新时简单地克隆和更新节点。此外,在纤维树的情况下,React 不会进行递归遍历;相反,它创建一个单向链表并执行父级优先、深度优先的遍历。

Fiber节点的单向链表

一个 Fiber 节点代表一个堆栈帧,但它也代表一个 React 组件的实例。一个Fiber节点包括以下成员:

类型

<div><span>等用于宿主组件(字符串),以及用于复合组件的类或函数。

key

与我们传递给 React 元素的键相同。

children

表示调用组件时返回的元素。例如:render()

const Name = (props) => {
  return(
    <div className="name">
      {props.name}
    </div>
  )
}                    

<Name><div>在这里,因为它返回一个<div>元素。

返回

表示返回栈帧,逻辑上是返回父Fiber节点。因此,它代表父级。

pendingWorkPriority

一个数字,表示Fiber所代表的工作的优先级。该ReactPriorityLevel 模块列出了不同的优先级及其代表的内容。除了NoWork为零外,较大的数字表示较低的优先级。

可以使用函数来检查Fiber的优先级是否至少与给定级别一样高。调度程序使用优先级字段来搜索下一个要执行的工作单元。