React Fiber Architecture
- Published on
- — 4302 Words
- Authors
- Name
- Richard Shih
- @realshiddong
英文原文:https://github.com/acdlite/react-fiber-architecture
原文写于 2016-10-19
介绍
React Fiber 是 React 核心算法的持续重新实现。这是 React 团队进行了两年多研究的结晶。
React Fiber 的目标是增加其在动画、布局和手势等领域的适用性。其主要特点是增量渲染:将渲染工作分成多个块,并在多个帧上分散它。
其他主要功能包括能够暂停、中止或重用工作,以应对新的更新;能够为不同类型的更新分配优先级;以及新的并发原语。
关于本文
Fiber 引入了几个新颖的概念,光从代码上看可能很难理解。本文档最初是我在跟随 React 项目中 Fiber 的实现过程中做的笔记集合。随着它的发展,我意识到它可能也对其他人有用。
我将尝试使用尽可能简单的语言,并通过明确定义关键术语来避免术语。我还会在可能的情况下大量链接外部资源。
请注意,我不是 React 团队的成员,也没有任何官方的发言权。这不是官方文档。我已经请求 React 团队成员审查其准确性。
本文档也是正在进行中的工作。Fiber 是一个正在进行的项目,在完成之前可能会经历重大重构。我也在不断地尝试在这里记录其设计。改进和建议非常受欢迎。
我希望在阅读本文档后,您能够足够了解 Fiber,以便在它实现时跟随,甚至最终能够回馈 React。
先决条件
在继续之前,我强烈建议您在开始之前先熟悉以下资源:
- React Components, Element, and Instances - 组件是一个会经常提到的术语。牢牢掌握这些术语至关重要。
- Reconciliation - React 协调算法的高级描述。
- React Basic Theoretical Concepts - 没有实现细节的 React 概念模型描述。其中一些可能在第一次阅读时没有理解。没关系,随着时间你会有更多理解。
- React Design Principles - 需要特别注意有关调度部分。它很好的解释了我们为什么需要 React Fiber。
回顾
如果您还没有查看先决条件部分,请先查看。
在我们深入了解一些新概念之前,让我们回顾一下几个概念。
什么是 reconciliation
reconciliation
协调(reconciliation)是指 React 使用的算法,用于对比两个树形结构,以确定哪些部分需要被更改。
update
一个 React 应用中用于渲染的数据的更改。通常是 setState 的结果。最终导致重新渲染。
React API 的核心思想是,将更新视为导致整个应用重新渲染。这使得开发者可以以声明性的方式进行推理,而不必担心如何有效地将应用从任何特定状态转换到另一个状态(从 A 到 B,从 B 到 C,从 C 到 A等等)。
实际上,在每次更改时重新渲染整个应用程序只适用于最简单的应用程序;在实际的应用程序中,这在性能方面是无法承受的。React 有一些优化技巧,可以在保持良好性能的同时创造整个应用程序重新渲染的效果。其中大部分优化是协调过程的一部分。
协调是虚拟 DOM 背后的算法。高级描述大致如下:当您渲染一个 React 应用程序时,会生成并保存一个描述应用程序的节点树。然后,将该树刷新到渲染环境中 - 例如,在浏览器应用程序中,它会被转换为一组 DOM 操作。当应用程序更新(通常通过 setState
)时,会生成一个新的树。新树会与先前的树进行比较,计算出更新呈现的应用程序所需的操作。
尽管 Fiber 是协调器的从头开始重写,但 React 文档中描述的高级算法基本相同。关键点包括:
- 不同的组件类型被假定会生成截然不同的树形结构。React 不会尝试对它们进行差异比较,而是会完全替换旧的树形结构。
- React 使用键来进行列表差异化比较。这些键应该是“稳定的,可预测的,且唯一的”。
协调 vs 渲染
DOM 只是 React 可以渲染到的众多渲染环境之一,其他主要目标是通过 React Native 渲染本机 iOS 和 Android 视图。这就是为什么“虚拟 DOM”有点不准确的原因。
它可以支持这么多目标的原因是因为 React 的设计使得协调和渲染是分开的阶段。协调器的工作是计算树中哪些部分已更改;渲染器使用该信息实际更新呈现的应用程序。
这种分离意味着 React DOM 和 React Native 可以使用自己的渲染器,同时共享由 React 核心提供的相同的协调器。
Fiber 重新实现了协调器。尽管渲染器需要改变以支持(并利用)新的架构,但它主要关注的不是渲染。
React 中的协调和渲染是两个独立的过程。
协调是比较组件生成的新树和前一个树,找出更新 DOM 所需的最小更改数的过程。这个过程涉及比较每个组件的 props 和 state,以及组件类型和子级的顺序。React 使用差异化算法来执行协调,并最小化所需更新的数量。
另一方面,渲染是实际创建 UI 元素并在屏幕上显示它们的过程。这个过程涉及基于组件的更新 props 和 state 创建新的 React 元素树。然后将新树与协调过程中的前一个树进行比较,并对 DOM 进行任何必要的更改。
总之,协调是确定需要更新的部分,渲染是实际更新 UI 的过程。
调度
scheduling
确定何时执行任务的过程
work
必须执行的任何计算。work通常是更新(例如 setState
)的结果。
React 的设计原则文档在这个主题上讲得非常好,我在这里引用一下它的原话:
在当前的实现中,React 递归遍历整个更新的树,在单个时钟周期内调用整个更新树的渲染函数。然而,将来可能会开始延迟一些更新,以避免丢帧。
这是 React 设计中的一个常见主题。一些流行的库实现了“推”方法,即在新数据可用时执行计算。然而,React 采用“拉”方法,即可以延迟计算直到必要时。
React 不是一个通用的数据处理库。它是一个用于构建用户界面的库。我们认为,它在应用程序中处于独特的位置,知道哪些计算现在是相关的,哪些不是。
如果某个组件在屏幕外面,我们可以延迟与其相关的任何逻辑。如果数据到达的速度比帧率快,我们可以合并和批量更新。我们可以优先处理来自用户交互的工作(例如由按钮单击引起的动画),而不是较不重要的后台工作(例如渲染刚从网络加载的新内容),以避免丢帧。
主要的要点是:
- 在 UI 中,不需要立即应用每个更新;实际上,这样做可能是浪费的,会导致丢帧并降低用户体验。
- 不同类型的更新具有不同的优先级 —— 例如,动画更新需要比来自数据存储的更新更快地完成。
- 推式方法需要应用程序(程序员)决定如何安排工作。拉式方法允许框架(React)变得智能,为您做出这些决策。
React 目前并没有明显地利用调度功能;更新会立即导致整个子树重新渲染。重构 React 的核心算法以利用调度功能是 Fiber 的主要驱动思想。
现在我们已经准备好深入了解 Fiber 的实现。下一节比我们之前讨论的更加技术性,请确保您对前面的内容感到舒适,然后再继续。
什么是 fiber?
现在我们将讨论 React Fiber 架构的核心。Fiber 比应用程序开发者通常考虑的抽象级别要低得多。如果您在尝试理解它时感到沮丧,请不要气馁。继续尝试,它最终会让您感到清晰明了。(当您最终理解它时,请建议如何改进本节内容。)
我们开始吧!
我们已经确定了 Fiber 的主要目标是使 React 能够利用调度功能。具体而言,我们需要能够:
- 暂停工作并稍后回来。
- 为不同类型的工作分配优先级。
- 重用先前完成的工作。
- 如果不再需要,则终止工作。
为了做到这些,我们首先需要一种将工作分解为单元的方法。在某种意义上,这就是 fiber 的作用。Fiber 表示一种工作单位 (unit of work)。
为了更深入地了解,让我们回到 React 组件的概念,它们是数据函数,通常表示为
v = f(d)
因此,渲染 React 应用类似于调用一个包含对其他函数的调用的函数体,以此类推。当思考 fiber 时,这个类比很有用。
计算机通常使用调用堆栈来跟踪程序的执行。当函数被执行时,会向堆栈中添加一个新的堆栈帧。该堆栈帧表示该函数执行的工作。
在处理 UI 时,问题在于如果一次执行了太多的工作,就会导致动画丢帧并变得不流畅。而且,如果某些工作被更近期的更新所取代,则其中一些工作可能是不必要的。这就是 UI 组件和函数之间比较失效的地方,因为组件具有比一般函数更具体的关注点。
较新的浏览器(和 React Native)实现了 API,以帮助解决这个确切的问题:requestIdleCallback
调度一个低优先级函数,在闲置期间被调用,而 requestAnimationFrame
则在下一帧动画时调度一个高优先级函数。问题在于,为了使用这些 API,您需要一种将渲染工作分解为增量单元的方法。如果仅依赖于调用堆栈,它将继续执行工作,直到堆栈为空。
如果我们能够自定义调用堆栈的行为以优化 UI 渲染,是不是会很棒?如果我们能够随意中断调用堆栈并手动操作堆栈帧,是不是会很棒?
这就是 React Fiber 的目的。Fiber 是针对 React 组件专门重新实现的堆栈。您可以将单个 fiber 视为虚拟堆栈帧。
重新实现堆栈的优点在于,可以将堆栈帧保留在内存中,并以任何您想要的方式(和时间)执行它们。这对于实现我们对调度的目标至关重要。
除了调度之外,手动处理堆栈帧还为并发和错误边界等功能提供了潜力。我们将在未来的部分中涵盖这些主题。
在下一节中,我们将更深入地了解 fiber 的结构。
fiber 的结构
注意:随着我们更具体地讨论实现细节,某些内容可能会发生变化。如果您发现任何错误或过时的信息,请提交 PR。
具体而言,一个 fiber 是一个包含有关组件、其输入和输出的信息的 JavaScript 对象。
一个 fiber 对应一个堆栈帧,但它也对应一个组件的实例。
以下是一些属于 fiber 的重要字段。(此列表不全)
type 和 key
fiber 的type和key与 React 元素的作用相同。(实际上,当从元素创建 fiber 时,这两个字段直接复制过来。)
fiber 的类型描述它对应的组件。对于复合组件,类型是函数或类组件本身。对于宿主组件(如 div
、span
等),类型是一个字符串。
从概念上讲,类型是正在被堆栈帧追踪执行的函数(如 v = f(d)
)。
与类型一起,键在协调期间用于确定是否可以重用 fiber。
child 和 sibling
这些字段指向其他 fiber,描述 fiber 的递归树结构。
child fiber 对应于组件的 render
方法返回的值。因此,在以下示例中:
function Parent() {
return <Child />
}
Parent
的 child fiber 对应于 Child
。
sibling 字段解决了 render
返回多个子元素的情况(这是 Fiber 中的一个新功能!):
function Parent() {
return [<Child1 />, <Child2 />]
}
child fibers 形成一个单链表,其头部是第一个子元素。因此,在此示例中,Parent
的 child 是 Child1
,Child1
的 sibling 是 Child2
。
回到我们的函数类比,您可以将 child fiber 视为尾调用的函数。
return
返回 fiber 是程序在处理当前 fiber 后应返回的 fiber。从概念上讲,它与堆栈帧的返回地址相同。它也可以被视为父 fiber。
如果一个 fiber 有多个 child fiber,每个 child fiber 的返回 fiber 都是它们的父 fiber。因此,在我们之前的示例中,Child1
和 Child2
的返回 fiber 是 Parent
。
pendingProps 和 memoizedProps
从概念上讲,props 是函数的参数。fiber 的 pendingProps
在执行开始时设置,memoizedProps
在执行结束时设置。
当传入的 pendingProps
等于 memoizedProps
时,这表明 fiber 的先前输出可以被重用,从而避免不必要的工作。
pendingWorkPriority
priority 是表示 fiber 所代表的工作优先级的数字。ReactPriorityLevel 模块列出了不同的优先级级别以及它们所代表的内容。
除了 NoWork
(表示0) 之外,较大的数字表示较低的优先级。例如,您可以使用以下函数来检查fiber的优先级是否至少与给定级别一样高:
function matchesPriority(fiber, priority) {
return fiber.pendingWorkPriority !== 0 && fiber.pendingWorkPriority <= priority
}
是的,这个函数只是为了说明,它实际上不是 React Fiber 代码库的一部分。
调度程序使用 priority 字段来搜索下一个要执行的工作单元。该算法将在以后的部分中讨论。
alternate
flush
flush a fiber 就是把它的输出渲染到屏幕上。
work-in-progress
一个尚未完成的 Fiber,从概念上来说,可以看做是一个尚未返回的堆栈帧。
在任何时候,一个组件实例最多有两个与之对应的 Fiber:当前已刷新的 Fiber 和正在进行的 Fiber。
当前 Fiber 的备用 Fiber 是正在进行的 Fiber,而正在进行的 Fiber 的备用 Fiber 是当前 Fiber。
一个 Fiber 的备用 Fiber 是通过 lazy(惰性)方式使用 cloneFiber
函数来创建的。与其总是创建一个新的对象,cloneFiber
将尝试重用 Fiber 的备用 Fiber(如果存在),从而最小化内存分配。
你应该将 alternate
字段视为一种实现细节,但它在代码库中经常出现,因此在这里讨论它是有价值的。
output
host component
React 应用程序的叶节点。它们是特定于渲染环境的(例如,在浏览器应用程序中,它们是 div,span 等)。在 JSX 中,它们使用小写标签名称表示。
从概念上来说,Fiber 的输出是函数的返回值。
每个 Fiber 最终都会有输出,但是输出仅由宿主组件在叶节点处创建。然后将输出传输到树上方。
输出最终是由渲染器传递给渲染器的,以便它可以将更改刷新到渲染环境中。渲染器的责任是定义如何创建和更新输出。
未来的章节
目前,这就是全部内容,但是本文档远远没有完成。未来的章节将描述在更新的整个生命周期中使用的算法。涉及的主题包括:
- 调度程序如何找到要执行的下一个工作单元。
- 如何跟踪和传播优先级通过 Fiber 树。
- 调度程序如何知道何时暂停和恢复工作。
- 如何刷新并标记工作已完成。
- 副作用(例如生命周期方法)的工作原理。
- 协程是什么以及如何使用它来实现上下文和布局等功能?