深入研究 React 的名为 Fiber 的新架构,了解新协调算法的两个主要阶段。我们将详细了解 React 如何更新 state 和 props 以及处理 children。
React 是一个用于构建用户界面的 JavaScript 库。其核心是跟踪组件状态变化并将更新后的状态投射到屏幕的机制。在 React 中,我们将此过程称为协调。我们调用 setState 方法,框架检查状态或道具是否已更改并在 UI 上重新呈现组件。
React 的文档很好地概括了该机制:React 元素的作用、生命周期方法和渲染方法,以及应用于组件子组件的差异算法。从 render 方法返回的不可变 React 元素树通常被称为“虚拟 DOM”。该术语有助于早期向人们解释 React,但它也引起了混淆,并且不再在 React 文档中使用。在本文中,我将坚持称其为 React 元素树。
除了 React 元素树之外,框架始终有一个内部实例树(组件、DOM 节点等)用于保持状态。从版本 16 开始,React 推出了该内部实例树和算法的新实现管理它的代号为 Fiber。要了解 Fiber 架构带来的优势,请查看React 在 Fiber 中使用链表的方式和原因。
这是本系列的第一篇文章,旨在向您介绍 React 的内部架构。在本文中,我想深入概述与算法相关的重要概念和数据结构。一旦我们有了足够的背景知识,我们将探索用于遍历和处理 fiber 树的算法和主要函数。本系列的下一篇文章将演示 React 如何使用该算法来执行初始渲染和处理状态以及道具更新。从那里我们将继续讨论调度程序的细节、子协调过程和构建效果列表的机制。
我要在这里给你一些非常高级的知识?我鼓励您阅读它以了解 Concurrent React 内部工作原理背后的魔力。如果您打算开始为 React 做贡献,本系列文章也将为您提供很好的指导。我坚信逆向工程,因此会有很多链接指向最新版本 16.6.0 的源代码。
要吸收的东西肯定很多,所以如果您不能立即理解某些内容,请不要感到压力。一切都值得花时间。请注意,您无需了解任何内容即可使用 React。这篇文章是关于 React 内部如何工作的。
Setting the background
这是一个简单的应用程序,我将在整个系列中使用。我们有一个按钮,可以简单地增加屏幕上呈现的数字:
1 | class ClickCounter extends React.Component { |
你可以在 这里 玩。如您所见,它是一个简单的组件,从 render 方法返回两个子元素 button 和 span。只要单击按钮,组件的状态就会在处理程序中更新。这反过来会导致 span 元素的文本更新。
React 在 reconciliation 期间执行各种活动。例如,在我们的简单应用程序中,这是 React 在第一次渲染期间和状态更新之后执行的高级操作:
- 更新 ClickCounter 状态下的计数属性
- 检索并比较 ClickCounter 的子项及其道具
- 更新 span 元素的 props
在reconciliation期间执行其他活动,例如调用生命周期方法或更新引用。所有这些活动在 Fiber 架构中统称为“work”。work 类型通常取决于 React 元素的类型。例如,对于类组件,React 需要创建一个实例,而对于功能组件则不需要。如你所知,我们在 React 中有很多种元素,例如类和功能组件、宿主组件(DOM 节点)、portals 等。React 元素的类型由 createElement 函数的第一个参数定义。这个函数一般用在 render 方法中创建一个元素。
在我们开始探索 Activity 和主要的 Fiber 算法之前,让我们先熟悉一下 React 内部使用的数据结构。
From React Elements to Fiber nodes
React 中的每个组件都有一个 UI 表示,我们可以调用视图或从 render 方法返回的模板。这是我们的 ClickCounter 组件的模板:
1 | <button key="1" onClick={this.onClick}>Update counter</button> |
React Elements
一旦模板通过 JSX 编译器,你最终会得到一堆 React element。这是 React 组件的 render 方法真正返回的内容,而不是 HTML。因为我们不需要使用 JSX,所以我们的 ClickCounter 组件的 render 方法可以这样重写:
1 | class ClickCounter { |
在 render 方法中调用 React.createElement 将创建两个数据结构,如下所示:
1 | [ |
你可以看到 React 为这些对象添加了属性 $$typeof 以将它们唯一标识为 React 元素。然后我们有描述元素的属性 type、key 和 props。这些值取自您传递给 React.createElement 函数的内容。请注意 React 如何将文本内容表示为 span 和 button 节点的子节点。以及点击处理程序如何成为按钮元素道具的一部分。 React 元素上还有其他字段,例如 ref 字段,超出了本文的范围。
ClickCounter 的 React 元素没有任何属性或键:
1 | { |
Fiber nodes
在reconciliation期间,从 render 方法返回的每个 React 元素的数据被合并到 fiber 节点树中。每个 React 元素都有一个对应的 fiber 节点。与 React 元素不同,fiber 不会在每次渲染时重新创建。这些是可变数据结构,用于保存组件状态和 DOM。
我们之前讨论过,根据 React 元素的类型,框架需要执行不同的活动。在我们的示例应用程序中,对于类组件 ClickCounter,它调用生命周期方法和渲染方法,而对于 span 宿主组件(DOM 节点),它执行 DOM 变异。所以每个 React 元素都被转换成对应类型的 Fiber 节点,描述需要完成的工作。
您可以将 fiber 视为一种数据结构,它代表一些要做的工作,或者换句话说,一个工作单元。 Fiber 的架构还提供了一种方便的方式来跟踪、安排、暂停和中止工作。
当 React 元素第一次被转换为 fiber 节点时,React 使用元素中的数据在 createFiberFromTypeAndProps 函数中创建一个 fiber。在随后的更新中,React 重用了 fiber 节点,并仅使用来自相应 React 元素的数据更新了必要的属性。如果相应的 React 元素不再从 render 方法返回,React 可能还需要根据 key prop 移动层次结构中的节点或将其删除。
查看 ChildReconciler 函数以查看 React 为现有 fiber 节点执行的所有活动和相应函数的列表。
因为 React 为每个 React 元素创建了一个 fiber,并且由于我们有一棵包含这些元素的树,所以我们将有一棵 fiber 节点树。在我们的示例应用程序中,它看起来像这样:
所有 fiber 节点都通过链表连接,使用 fiber 节点上的以下属性:child、sibling 和 return。有关它为何以这种方式工作的更多详细信息,请查看我的文章 The how and why on React’s usage of linked list in Fiber 如果您还没有阅读过它。
Current and work in progress trees
在第一次渲染之后,React 最终得到一个 fiber 树,它反映了用于渲染 UI 的应用程序的状态。这棵树通常被称为 current。当 React 开始处理更新时,它会构建一个所谓的 workInProgress 树,它反映了要刷新到屏幕的未来状态。
所有工作都在 workInProgress 树中的 fiber 上执行。当 React 遍历当前树时,它会为每个现有的 fiber 节点创建一个替代节点,该节点构成 workInProgress 树。该节点是使用 render 方法返回的 React 元素的数据创建的。一旦处理完更新并完成所有相关工作,React 将准备好备用树以刷新到屏幕上。一旦这个 workInProgress 树呈现在屏幕上,它就成为 current。
React 的核心原则之一是一致性。 React 总是一口气更新 DOM - 它不会显示部分结果。 WorkInprogress 树是用户看不到的“草稿”,因此 React 可以首先处理所有组件,然后将其更改冲洗到屏幕上。
在源代码中,您会看到许多函数从当前树和 workInProgress 树中获取 fiber 节点。这是一个这样的函数的签名:
1 | function updateHostComponent(current, workInProgress, renderExpirationTime) {...} |
每个 fiber 节点都在 alternate 字段中保存对另一棵树的对应项的引用。当前树中的节点指向 workInProgress 树中的节点,反之亦然。
side effects
我们可以将 React 中的组件视为使用 state 和 props 来计算 UI 表示的函数。每个其他活动,如改变 DOM 或调用生命周期方法,都应被视为副作用,或者简称为效果。文档中还提到了效果:
您之前可能已经从 React 组件执行过数据获取、订阅或手动更改 DOM。我们称这些操作为“side effects”(或简称为“effect”),因为它们会影响其他组件并且无法在渲染期间完成。
您可以看到大多数状态和道具更新将如何导致副作用。由于应用效果是一种工作,因此 fiber 节点是一种除了更新之外还可以跟踪 effect 的便捷机制。每个 fiber 节点都可以有与之关联的 effect。它们在 effectTag
字段中编码。
因此,Fiber 中的 effect 基本上定义了处理更新后实例需要完成的工作。对于宿主组件(DOM 元素),工作包括添加、更新或删除元素。对于类组件,React 可能需要更新 refs 并调用 componentDidMount
和 componentDidUpdate
生命周期方法。也有对应于其他类型 fiber 的其他作用。
effects list
React 处理更新非常快,为了达到这种性能水平,它采用了一些有趣的技术。其中之一是构建具有快速迭代效果的 fiber 节点线性列表。迭代线性列表比树快得多,并且无需在没有副作用的节点上花费时间。
此列表的目标是标记具有 DOM 更新或与其关联的其他效果的节点。此列表是 finishedWork
树的子集,并使用 nextEffect
属性而不是 current
和 workInProgress
树中使用的子属性进行链接。
Dan Abramov 为 effect list 提供了一个类比。他喜欢把它想象成一棵圣诞树,用“圣诞灯”将所有有效节点捆绑在一起。为了形象化这一点,让我们想象一下下面的 fiber 节点树,其中突出显示的节点有一些工作要做。例如,我们的更新导致 c2 被插入 DOM,d2 和 c1 改变属性,b2 触发生命周期方法。效果列表会将它们链接在一起,以便 React 稍后可以跳过其他节点:
您可以看到具有 effects 的节点是如何链接在一起的。当遍历节点时,React 使用 firstEffect 指针来确定列表的开始位置。所以上图可以表示为这样的线性列表:
Root of the fiber tree
每个 React 应用程序都有一个或多个充当容器的 DOM 元素。在我们的例子中,它是带有 ID 容器的 div 元素。
1 | ReactDOM.render(React.createElement(ClickCounter), domContainer); |
React 为每个 container
(root 节点) 创建一个 fiber root 对象。您可以使用对 DOM 元素的引用来访问它:
1 | const fiberRoot = query("#container")._reactRootContainer._internalRoot; |
这个 fiber root 是 React 保存对 fiber tree 的引用的地方。它存储在 fiber root 的当前属性中:
1 | const hostRootFiberNode = fiberRoot.current; |
fiber 树以一种特殊类型的 fiber 节点开始,即 HostRoot。它是在内部创建的,并充当最顶层组件的父级。通过 stateNode 属性从 HostRoot fiber 节点返回到 FiberRoot 有一个链接:
1 | fiberRoot.current.stateNode === fiberRoot; // true |
您可以通过 fiber root 访问最顶层的 HostRoot fiber 节点来探索 fiber 树。或者您可以像这样从组件实例中获取单个 fiber 节点:
1 | compInstance._reactInternalFiber; |
Fiber node structure
现在让我们看一下为 ClickCounter 组件创建的 fiber 节点的结构:
1 | { |
span DOM 元素:
1 | { |
Fiber 节点上有相当多的字段。我在前面的部分中描述了字段 alternate、effectTag 和 nextEffect 的用途。现在让我们看看为什么我们需要其他人。
stateNode
持有对组件类实例、DOM 节点或与 Fiber 节点关联的其他 React 元素类型的引用。一般来说,我们可以说这个属性用于保存与 fiber 相关的局部状态。
type
定义与此 fiber 关联的函数或类。对于类组件,它指向构造函数,对于 DOM 元素,它指定 HTML 标记。我经常使用这个字段来了解 fiber 节点与什么元素相关。
tag
定义 fiber 的类型。它在协调算法中用于确定需要完成的工作。如前所述,工作因 React 元素的类型而异。函数 createFiberFromTypeAndProps 将 React 元素映射到相应的 fiber 节点类型。在我们的应用程序中,ClickCounter 组件的属性标记为 1
,表示一个 ClassComponent
,对于 span
元素,它的属性标记为5
,表示一个 HostComponent
。
updateQueue
状态更新、回调和 DOM 更新队列。(A queue of state updates, callbacks and DOM updates.)
memoizedState
用于创建输出的 fiber 的状态。在处理更新时,它会反映当前在屏幕上呈现的状态。
memoizedProps
在上一次渲染期间用于创建输出的 fiber 的 props。
pendingProps
已从 React 元素中的新数据更新并需要应用于子组件或 DOM 元素的道具。
key
带有一组孩子的唯一标识符,以帮助 React 确定哪些项目已更改,已添加或从列表中删除。它与此处描述的 React 的“列表和键”功能有关。
您可以在此处找到 fiber 节点的完整结构。我在上面的解释中省略了一堆字段。特别是,我跳过了构成我在上一篇文章中描述的树数据结构的指针 child
、sibling
和 return
。还有一类字段,如 expirationTime
、childExpirationTime
和特定于 Scheduler
的模式。
General algorithm
React 在两个主要阶段执行工作: render 和 commit。
在第一个 render
阶段,React 将更新应用到通过 setState 或 React.render 安排的组件,并确定 UI 中需要更新的内容。如果是初始渲染,React 会为从渲染方法返回的每个元素创建一个新的 fiber 节点。在接下来的更新中,现有 React 元素的 fiber 被重新使用和更新。该阶段的结果是标有副作用的 fiber 节点树。效果描述了在接下来的提交阶段需要完成的工作。在此阶段,React 获取标记有效果的 fiber 树并将它们应用于实例。它遍历效果列表并执行 DOM 更新和用户可见的其他更改。
重要的是要了解第一个 render 阶段的工作可以异步执行。 React 可以根据可用时间处理一个或多个 fiber 节点,然后停止以存储完成的工作并 yield 于某个事件。然后它从停止的地方继续。但有时,它可能需要放弃已完成的工作并重新从头开始。这些暂停之所以成为可能,是因为在此阶段执行的工作不会导致任何用户可见的更改,例如 DOM 更新。相反,接下来的 commit
阶段总是同步的。这是因为在此阶段执行的工作会导致用户可见的更改,例如 DOM 更新。这就是为什么 React 需要一次完成它们。
调用生命周期方法是 React 执行的一种工作。一些方法在 render 阶段调用,其他方法在 commit 阶段调用。这是在第一个渲染阶段工作时调用的生命周期列表:
- [UNSAFE_]componentWillMount (deprecated)
- [UNSAFE_]componentWillReceiveProps (deprecated)
- getDerivedStateFromProps
- shouldComponentUpdate
- [UNSAFE_]componentWillUpdate (deprecated)
- render
如您所见,从 16.3 版本开始,一些在渲染阶段执行的遗留生命周期方法被标记为不安全。它们现在在文档中称为遗留生命周期。它们将在未来的 16.x 版本中被弃用,没有 UNSAFE 前缀的对应版本将在 17.0 中删除。您可以在此处阅读有关这些更改和建议的迁移路径的更多信息。
你好奇这是什么原因吗?
好吧,我们刚刚了解到,由于 render 阶段不会产生像 DOM 更新那样的副作用,因此 React 可以异步处理对组件的异步更新(甚至可能在多个线程中进行)。但是,标有 UNSAFE 的生命周期经常有被误解和巧妙地误用。开发人员倾向于将带有副作用的代码放在这些方法中,这可能会导致新的异步渲染方法出现问题。尽管只有没有 UNSAFE 前缀的对应项会被删除,但它们仍然可能会在即将到来的并发模式(您可以选择退出)中引起问题。
下面是在第二个提交阶段执行的生命周期方法列表:
- getSnapshotBeforeUpdate
- componentDidMount
- componentDidUpdate
- componentWillUnmount
因为这些方法在同步 commit
阶段执行,所以它们可能包含副作用并触及 DOM。
好的,现在我们了解了用于遍历树和执行工作的通用算法的背景知识。让我们开始吧。
Render Phase
协调算法始终使用 renderRoot 函数从最顶层的 HostRoot fiber 节点开始。但是,React 会跳出(跳过)已经处理过的 fiber 节点,直到它找到未完成工作的节点。例如,如果您在组件树深处调用 setState,React 将从顶部开始但会快速跳过父节点,直到它找到到调用了 setState 方法的组件。
Main steps of the work loop
所有 fiber 节点都在工作循环中处理。下面是循环同步部分的实现:
1 | function workLoop(isYield){ |
在上面的代码中,nextUnitOfWork 持有对来自 workInProgress 树的 fiber 节点的引用,它有一些工作要做。当 React 遍历 Fibers 树时,它使用这个变量来了解是否有任何其他 fiber 节点有未完成的工作。当前 fiber 处理完成后,该变量将包含对树中下一个 fiber 节点的引用或空值。在这种情况下,React 退出工作循环并准备好提交更改。
有 4 个主要函数用于遍历树并启动或完成工作:
- performUnitOfWork
- beginWork
- completeUnitOfWork
- completeWork
为了演示如何使用它们,请看下面遍历 fiber 树的动画。我在演示中使用了这些功能的简化实现。每个函数都需要一个 fiber 节点来处理,随着 React 沿着树向下移动,您可以看到当前活动的 fiber 节点发生变化。您可以在视频中清楚地看到算法如何从一个分支转到另一个分支。它首先完成孩子的工作,然后再移交给父母。
请注意,笔直的垂直连接表示兄弟姐妹,而弯曲的连接表示孩子,例如 b1 没有孩子,而 b2 有一个孩子 c1。
这是视频的链接,您可以在其中暂停播放并检查当前节点和功能状态。从概念上讲,您可以将“开始”视为“步入”一个组件,将“完成”视为“走出”它。当我解释这些函数的作用时,您还可以尝试此处的示例和实现。
让我们从前两个函数 performUnitOfWork 和 beginWork 开始:
1 | function performUnitWork(workInprogree) { |
函数 performUnitOfWork
从 WorkInProgress
树接收一个 fiber 节点,并通过调用 BeginWork
函数开始工作。这是启动所有需要为 fiber 执行的活动的功能。出于此演示的目的,我们简单地记录了 fiber 的名称,以表示工作已经完成。函数 beginWork
开始工作总是将指针返回下一个孩子以在循环或 null 中进行处理。
如果有下一个孩子,它将被分配给 workLoop
函数中的变量 nextUnitOfWork
。然而,如果没有子节点,React 知道它到达了分支的末尾,因此它可以完成当前节点。一旦节点完成,它将需要为兄弟姐妹执行工作,然后回溯到父节点。这是在 completeUnitOfWork
函数中完成的:
1 | function completeUnitOfWork(workInProgress) { |
您可以看到该函数的要点是一个大的 while 循环。当 workInProgress
节点没有子节点时,React 会进入此函数。完成当前 fiber 的工作后,它会检查是否有兄弟节点。如果找到,React 退出该函数并返回指向 sibling
的指针。它将被分配给 nextUnitOfWork
变量,React 将执行从这个兄弟开始的分支的工作。重要的是要明白,此时 React 只完成了前面兄弟姐妹的工作。它尚未完成父节点的工作。只有从子节点开始的所有分支都完成后,它才完成父节点和回溯的工作。
从实现中可以看出,completeUnitOfWork
主要用于迭代目的,而主要活动发生在 beginWork
和 completeWork
函数中。在本系列的后续文章中,我们将了解当 React 进入 beginWork
和 completeWork
函数时,ClickCounter 组件和 span 节点会发生什么。
Commit Phase
该阶段从函数 completeRoot
开始。这是 React 更新 DOM 并调用突变前后生命周期方法的地方。
当 React 进入这个阶段时,它有 2 棵树和 effect 列表。第一棵树表示当前在屏幕上呈现的状态。然后在渲染阶段构建了一个 alternate 树。它在源代码中称为 finishedWork
或 workInProgress
,代表需要反映在屏幕上的状态。这个备用树通过 child
指针和 sibling
指针与当前树类似地链接。
然后,有一个 effect 列表 - 通过 nextEffect
指针链接的 finishedWork
的一个节点子集。请记住,效果列表是运行渲染阶段的结果。渲染的全部要点是确定需要插入,更新或删除哪些节点,哪些组件需要调用其生命周期方法。这就是 effect list 告诉我们的。正是在 commit
阶段迭代的一组节点。
出于调试目的,可以通过 fiber root 的当前属性访问
cuurent
树。可以通过当前树中HostFiber
节点的alternate
属性访问finishedWork
树。
在 commit
阶段运行的主要函数是 commitRoot。基本上,它执行以下操作:
- Calls the
getSnapshotBeforeUpdate
lifecycle method on nodes tagged with the Snapshot effect - Calls the
componentWillUnmount
lifecycle method on nodes tagged with the Deletion effect - Performs all the DOM insertions, updates and deletions
Sets thefinishedWork
tree as current - Calls
componentDidMount
lifecycle method on nodes tagged with the Placement effect - Calls
componentDidUpdate
lifecycle method on nodes tagged with the Update effect
在调用 pre-mutation 方法 getSnapshotBeforeUpdate
之后,React 将所有副作用提交到树中。它分两次完成。第一遍执行所有 DOM(主机)插入、更新、删除和引用卸载。然后 React 将 finishedWork
树分配给 FiberRoot,将 workInProgress
树标记为当前树。这是在提交阶段的第一遍之后完成的,因此前一棵树在 componentWillUnmount
期间仍然是当前的,但在第二遍之前,因此完成的工作在 componentDidMount/Update 期间是当前的。在第二遍中,React 调用所有其他生命周期方法和 ref 回调。这些方法作为单独的传递执行,因此整个树中的所有放置、更新和删除都已被调用。
这是运行上述步骤的功能的要旨:
1 | function commitRoot(root, finishedWork) { |
这些子函数中的每一个都实现了一个循环,该循环遍历 effect 列表并检查效果的类型。当它找到与函数目的相关的效果时,它就会应用它。
Pre-mutation lifecycle methods
例如,下面是遍历 effect 树并检查节点是否具有 Snapshot
effect 的代码:
1 | function commitBeforeMutationLifecycles() { |
对于类组件,这种效果意味着调用 getSnapshotBeforeUpdate 生命周期方法。
DOM updates
commitAllHostEffects
是 React 执行 DOM 更新的函数。该函数基本上定义了需要为节点完成的操作类型并执行它:
1 | function commitAllHostEffects() { |
有趣的是,React 在 commitDeletion
函数中调用 componentWillUnmount
方法作为删除过程的一部分。
Post-mutation lifecycle methods
commitAllLifecycles 是 React 调用所有剩余生命周期方法 componentDidUpdate
和 componentDidMount
的函数。
我们终于完成了。让我知道您对这篇文章的看法或在评论中提问。查看系列中的下一篇文章深入解释 React 中的状态和道具更新。我还有很多文章在为调度程序、子协调过程以及如何构建效果列表提供深入的解释。我还计划制作一个视频,展示如何以本文为基础调试应用程序。