[译]深入React fiber架构及源码

来源:

https://medium.com/react-in-depth/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react-e1c04700ef6emedium.com

前言

React16提出了Fiber结构,其能够将任务分片,划分优先级,同时能够实现类似于操作系统中对线程的抢占式调度,非常强大。

正文

React是一个用于构建UI的JavaScript库,其核心是跟踪组件状态变化并将更新到view上。在React中,我们将此过程视为reconciliation。在调用setState方法后,框架会检查state或props是否已更改并在UI上重新呈现组件。

React的文档提供了一种更高层次的对这种机制的描述:包含React元素的作用,生命周期方法和渲染方法,以及应用于组件子元素的diffing算法等相关内容。从render方法返回的不可变React元素树通常称为“Virtual DOM”。这个术语有助于早期向人们解释React,但它也引起了歧义,并且不再在React文档中使用。在本文中,将坚持称它为React元素的树。

除了React元素的树之外,框架总是有一个用于保持状态的内部实例树(internal instances)(组件,DOM节点等),与之相对的是跟具体平台有关的public instance,也被称为Host instance 。从React 16开始,React推出了该内部实例树的新实现以及负责操作树的算法,被称为Fiber。接下来我们将了解fiber架构带来的优势,了解React在fiber中使用链表的方式和原因

这是本系列的第一篇文章,旨在教你React的内部架构。在本文中,我想提供其中的重要概念和以及与算法相关的数据结构。一旦我们有足够的背景,我们将探索用于遍历和处理fiber tree的算法和主要功能。本系列的下一篇文章将演示React如何使用该算法执行初始渲染和处理state以及props更新。从那里我们将继续讨论scheduler的详细信息,子协调过程以及构建effect list的机制。

背景

首先写一个非常简单的程序:

  1. import React, { Component } from 'react';
  2. export default class ClickCounter extends Component{
  3. constructor(props) {
  4. super(props);
  5. this.state = {count: 0};
  6. this.handleClick = this.handleClick.bind(this);
  7. }
  8. handleClick() {
  9. this.setState((state) => {
  10. return {count: state.count + 1};
  11. });
  12. }
  13. render() {
  14. return [
  15. <button key="1" onClick={this.handleClick}>Update counter</button>,
  16. <span key="2">{this.state.count}</span>
  17. ]
  18. }
  19. }

可以查看在线实例。可以看到,它是一个非常简单的组件,它从render方法返回两个子元素button和span。单击button后,组件的state将在处理程序内更新,这会导致span元素的文本更新。

React会在reconciliation期间执行各种活动。例如,以下是React在上面这个程序中第一次渲染和状态更新之后执行的高级操作:

  1. 更新state中的count属性
  2. 检索并比较ClickCounter子组件以及props
  3. 更新span元素的props

同时,在reconciliation期间,还会执行其他活动包括调用生命周期方法更新引用。所有这些活动在fiber架构中统称为“work”。work类型通常取决于React元素的类型。例如,对于class组件,React需要创建实例,而不是为function组件执行此操作。并且,React中有许多元素,例如:class和function组件,Host组件(DOM节点),protals等. React元素的类型由createElement函数的第一个参数定义,此函数通常在render方法中用于创建元素。总结起来,就是更新组件的内部状态,触发side-effects执行。

在我们开始探索fiber算法之前,让我们首先熟悉React内部使用的数据结构。

从 React Elements到Fiber nodes

React中的每个组件都有一个UI表示,这个UI可以通过调用一个view或一个从render方法返回。这是ClickCounter组件的模板:

  1. <button key="1" onClick={this.onClick}>Update counter</button>
  2. <span key="2">{this.state.count}</span>

React Elements

一旦模板通过JSX编译器编译,就会得到一堆React元素。这是从React组件的render方法返回的,而不是HTML。由于我们不需要使用JSX,因此我们的ClickCounter组件的render方法可以像这样重写:

  1. class ClickCounter {
  2. ...
  3. render() {
  4. return [
  5. React.createElement(
  6. 'button',
  7. {
  8. key: '1',
  9. onClick: this.onClick
  10. },
  11. 'Update counter'
  12. ),
  13. React.createElement(
  14. 'span',
  15. {
  16. key: '2'
  17. },
  18. this.state.count
  19. )
  20. ]
  21. }
  22. }

在render方法中调用React.createElement会创建两个数据结构:

  1. [
  2. {
  3. $$typeof: Symbol(react.element),
  4. type: 'button',
  5. key: "1",
  6. props: {
  7. children: 'Update counter',
  8. onClick: () => { ... }
  9. }
  10. },
  11. {
  12. $$typeof: Symbol(react.element),
  13. type: 'span',
  14. key: "2",
  15. props: {
  16. children: 0
  17. }
  18. }
  19. ]

可以看到React将$$typeof属性添加到这些对象,以将它们唯一地标识为React元素。然后我们可以通过type,key和props属性来描述元素。这些值取自传递给React.createElement函数的值。请注意React如何将文本内容表示为span和button节点的子项,以及click处理程序如何成为按钮元素props的一部分。 React元素上还有其他字段,如ref字段,超出了本文的范围,不再阐述。

同时ClickCouter元素没有任何的props或者key:

  1. {
  2. $$typeof: Symbol(react.element),
  3. key: null,
  4. props: {},
  5. ref: null,
  6. type: ClickCounter
  7. }

Fiber nodes

在reconciliation期间,来自render方法返回的每个React元素的数据被合并到fiber node树中,每个React元素都有一个相应的fiber node。与React元素不同,每次渲染过程,不会再重新创建fiber。这些可变的数据包含组件state和DOM。 我们之前讨论过,根据React元素的类型,框架需要执行不同的活动。在我们的示例应用程序中,对于class组件ClickCounter,它调用生命周期方法和render方法,而对于span Host 组件(DOM节点),它执行DOM更新。因此,每个React元素都会转换为相应类型的Fiber节点,用于描述需要完成的工作。

可以这样认为:fiber作为一种数据结构,用于代表某些worker,换句话说,就是一个work单元,通过Fiber的架构,提供了一种跟踪,调度,暂停和中止工作的便捷方式。

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

可以查看ChildReconciler函数的实现,来了解React为现有fiber节点执行的所有活动和相应函数的列表。因为React为每个React元素创建了一个fiber node,并且因为我们有一个这些元素的树,所以我们将拥有一个fiber node tree。对于我们的示例应用程序,它看起来像这样:

fiber - 图1

所有fiber节点都通过使用fiber节点上的以下属性:child,sibling和return来构成一个fiber node的linked list(后面我们称之为链表)。有关它为什么以这种方式工作的更多详细信息,可以查看这篇文章

Current and work in progress trees

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

所有work都在workInProgress tree中的fiber上执行。当React遍历current tree时,对于每个现有fiber节点,它会使用render方法返回的React元素中的数据创建一个备用(alternate)fiber节点,这些节点用于构成workInProgress tree(备用tree)。处理完更新并完成所有相关工作后,React将备用tree刷新到屏幕。一旦这个workInProgress tree在屏幕上呈现,它就会变成current tree。

React的核心原则之一是一致性。 React总是一次更新DOM - 它不会显示部分结果。 workInProgress tree对用户不可见,因此React可以先处理完所有组件,然后将其更改刷新到屏幕。

在源代码中,可以看到很多函数从current tree和workInProgress tree中获取fiber节点:

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

每个fiber节点都会通过alternate字段保持对另一个树的对应节点的引用。current tree中的节点指向workInProgress tree中的备用节点,反之亦然。

Side-effects

我们可以将React中的一个组件视为一个使用state和props来计算UI的函数。每个其他活动,如改变DOM或调用生命周期方法,都应该被认为是side-effects,react文档中是这样描述的side-effects的:

You’ve likely performed data fetching, subscriptions, or manually changing the DOM 的from React components before. We call these operations “side effects” (or “effects” for short) because they can affect other components and can’t be done during rendering.

可以看到大多数state和props更新将side-effects。由于应用effects是一种work,fiber节点是一种方便的机制,可以跟踪除更新之外的effects。每个fiber节点都可以具有与之相关的effects, 通过fiber节点中的effectTag字段表示。

因此,Fiber中的effects基本上定义了处理更新后需要为实例完成的工作,对于Host组件(DOM元素),工作包括添加,更新或删除元素。对于class组件,React可能需要更新ref并调用componentDidMount和componentDidUpdate生命周期方法,还存在与其他类型的fiber相对应的其他effects。

Effects list

React能够非常快速地更新,并且为了实现高性能,它采用了一些有趣的技术。其中之一是构建带有side-effects的fiber节点的线性列表,其具有快速迭代的效果。迭代线性列表比树快得多,并且没有必要在没有side effects的节点上花费时间。

此列表的目标是标记具有DOM更新或与其关联的其他effects的节点,此列表是finishedWork tree的子集,并使用nextEffect属性,而不是current和workInProgress树中使用的child属性进行链接。

Dan Abramove为effecs list提供了一个类比: 他喜欢将它想象成一棵圣诞树,“圣诞灯”将所有带有effects的节点绑定在一起。为了使这个effects list可视化,让我们想象下面的fiber node tree,其中橙色的节点都有一些effects需要处理。例如,我们的更新导致c2被插入到DOM中,d2和c1被用于更改属性,而b2被用于激活生命周期方法。effects list将它们链接在一起,以便React可以在以后跳过其他节点:

fiber - 图2

你可以看到带有effects的节点是如何链接在一起的,当遍历节点时,React使用firstEffect指针来确定effects list的开始位置。所以上图可以表示为这样的线性列表

fiber - 图3

Root of the fiber tree

每个React应用程序都有一个或多个作为container的DOM元素。在我们的例子中,它是带有id为“container”的div元素。

  1. const domContainer = document.querySelector('#container');
  2. ReactDOM.render(React.createElement(ClickCounter), domContainer);

React为每个container创建一个fiber root 对象,可以使用对DOM元素的引用来访问它:

  1. const fiberRoot = query('#container')._reactRootContainer._internalRoot

这个fiber root 是React保存对fiber tree引用的地方。它存储在fiber tree的current属性中:

  1. const hostRootFiberNode = fiberRoot.current

fiber tree以特殊类型的fiber节点(HostRoot)开始。它是在内部创建的,并充当最顶层组件的父级,HostRoot fiber节点通过stateNode属性指向FiberRoot:

  1. fiberRoot.current.stateNode === fiberRoot; // true

可以通过fiber root访问最顶端的HostRoot的fiber node来探索fiber tree。或者,可以从组件实例中获取单个fiber节点,如下所示:

  1. compInstance._reactInternalFiber

Fiber node structure

现在让我们看一下为ClickCounter组件创建的fiber节点的结构:

  1. {
  2. stateNode: new ClickCounter,
  3. type: ClickCounter,
  4. alternate: null,
  5. key: null,
  6. updateQueue: null,
  7. memoizedState: {count: 0},
  8. pendingProps: {},
  9. memoizedProps: {},
  10. tag: 1,
  11. effectTag: 0,
  12. nextEffect: null
  13. }

以及span节点:

  1. {
  2. stateNode: new HTMLSpanElement,
  3. type: "span",
  4. alternate: null,
  5. key: "2",
  6. updateQueue: null,
  7. memoizedState: null,
  8. pendingProps: {children: 0},
  9. memoizedProps: {children: 0},
  10. tag: 5,
  11. effectTag: 0,
  12. nextEffect: null
  13. }

fiber节点上有很多字段,我在前面的部分中描述了alternate字段,effectTag和nextEffect的用途。现在让我们看看为什么我们需要其他字段:

  • stateNode:保存对组件的类实例,DOM节点或与fiber节点关联的其他React元素类型的引用。一般来说,可以认为这个属性用于保存与fiber相关的本地状态。
  • type:定义与此fiber关联的功能或类。对于类组件,它指向构造函数;对于DOM元素,它指定HTML tag。可以使用这个字段来理解fiber节点与哪个元素相关。
  • tag:定义fiber的类型。它在reconcile算法中用于确定需要完成的工作。如前所述,工作取决于React元素的类型,函数createFiberFromTypeAndProps将React元素映射到相应的fiber节点类型。在我们的应用程序中,ClickCounter组件的属性标记是1,表示ClassComponent,而span元素的属性标记是5,表示Host Component。
  • updateQueue:用于状态更新,回调函数,DOM更新的队列
  • memoizedState:用于创建输出的fiber状态。处理更新时,它会反映当前在屏幕上呈现的状态。
  • memoizedProps:在前一次渲染期间用于创建输出的props
  • pendingProps:已从React元素中的新数据更新,并且需要应用于子组件或DOM元素的props
  • key:具有一组children的唯一标识符,可帮助React确定哪些项已更改,已添加或从列表中删除。它与此处描述的React的“list and key”功能有关。

可以在此处找到fiber节点的完整结构。在上面的解释中省略了一堆字段,尤其跳过了child,sibling和return,组成了树数据结构。以及特定于Scheduler的expirationTime,childExpirationTime和mode等字段类别。

General algorithm

React把一次渲染分为两个阶段:rendercommit

在render阶段时,React通过setState或React.render来执行组件的更新,并确定需要在UI中更新的内容。如果是第一次渲染,React会为render方法返回的每个元素,创建一个新的fiber节点。在接下来的更新中,将重用和更新现有React元素的fiber 节点。render阶段的结果是生成一个部分节点标记了side effects的fiber节点树,side effects描述了在下一个commit阶段需要完成的工作。在此阶段,React采用标有side effects的fiber树并将其应用于实例。它遍历side effects列表并执行DOM更新和用户可见的其他更改。

一个很重要的点是,render阶段可以异步执行。 React可以根据可用时间来处理一个或多个fiber节点,然后停止已完成的工作,并让出调度权来处理某些事件。然后它从它停止的地方继续。但有时候,它可能需要丢弃完成的工作并再次从头。由于在render阶段执行的工作不会导致任何用户可见的更改(如DOM更新),因此这些暂停是不会有问题的。相反,在接下来的commit阶段始终是同步的,这是因为在此阶段执行的工作,将会生成用户可见的变化,例如, DOM更新,这就是React需要一次完成它们的原因。

调用生命周期方法是React执行的一种工作。在render阶段调用某些方法,在commit阶段调用其他方法。在render阶段时调用的生命周期列表如下:

  • [UNSAFE_]componentWillMount (已废弃)
  • [UNSAFE_]componentWillReceiveProps (已废弃)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate (已废弃)
  • render

可以看到,在render阶段执行的一些遗留生命周期方法在react 16.3中标记为UNSAFE。它们现在在文档中称为遗留生命周期,将在未来的16.x版本中弃用,而没有UNSAFE前缀的版本将在17.0中删除。可以在此处详细了解这些更改以及建议的迁移路径

为什么会废弃这些声明周期函数呢呢?

因为在render阶段不会产生像DOM更新这样的副作用,所以React可以异步处理与组件异步的更新(甚至可能在多个线程中执行)。然而,标有UNSAFE的生命周期经常被误解和滥用,开发人员倾向于将带有副作用的代码放在这些方法中,这可能会导致新的异步渲染方法出现问题。虽然只有没有UNSAFE前缀的副本会被删除,但它们仍然可能在即将出现的concurrent模式中引起问题。

以下是commit阶段执行的生命周期方法列表:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因为这些方法在同步commit阶段执行,所以它们可能包含副作用并获取DOM。

Render阶段

reconciliation算法始终使用renderRoot函数从最顶端的HostRoot fiber节点开始。但是,React会跳过已经处理过的fiber节点,直到找到未完成工作的节点。例如,如果在组件树中调用setState,则React将从顶部开始,但会快速跳过父节点,直到它到达调用了setState方法的组件。

Main steps of the work loop

所有fiber节点都在work loop中处理。这是循环的同步部分的实现

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

在上面的代码中,nextUnitOfWork从workInProgress树中保存对fiber节点(这些节点有部分任务要处理)的引用。当React遍历Fibers树时,它使用此变量来知道是否有任何其他fiber节点具有未完成的工作。处理当前fiber后,变量将包含对树中下一个fiber节点的引用或null。在这种情况下,React退出工作循环并准备提交更改.

有4个主要功能用于遍历树并启动或完成工作:

要演示如何使用它们,请查看以下遍历fiber树的动画。已经在演示中使用了这些函数的简化实现。每个函数都需要一个fiber节点进行处理,当React从树上下来时,可以看到当前活动的fiber节点发生了变化,可以清楚地看到算法如何从一个分支转到另一个分支。它首先完成child 节点的工作,然后转移到parent身边.

fiber - 图4

注意,垂直连接表示sibling,而弯曲的连接表示child,例如b1没有child,而b2有一个childc1.

这是视频的链接,您可以在其中暂停播放并检查当前节点和功能状态,可以简单的看到,这里适用的树遍历算法是深度优先搜索(DFS)

让我们从前两个函数performUnitOfWork和beginWork开始:

  1. function performUnitOfWork(workInProgress) {
  2. let next = beginWork(workInProgress);
  3. if (next === null) {
  4. next = completeUnitOfWork(workInProgress);
  5. }
  6. return next;
  7. }
  8. function beginWork(workInProgress) {
  9. console.log('work performed for ' + workInProgress.name);
  10. return workInProgress.child;
  11. }

performUnitOfWork函数从workInProgress树接收fiber节点,并通过调用beginWork函数启动工作,即通过这个函数启动fiber需要执行的所有活动。出于演示的目的,我们只需记录fiber的名称即可表示已完成工作。beginWork函数始终返回要在循环中处理的下一个子节点的指针或null.

如果有下一个子节点,它将被赋值给workLoop函数中的nextUnitOfWork变量。但是,如果没有子节点,React知道它到达了分支的末尾,因此它就完成当前节点。一旦节点完成,它将需要为兄弟节点执行工作并在此之后回溯到父节点。这是在completeUnitOfWork函数中完成的.

  1. function completeUnitOfWork(workInProgress) {
  2. while (true) {
  3. let returnFiber = workInProgress.return;
  4. let siblingFiber = workInProgress.sibling;
  5. nextUnitOfWork = completeWork(workInProgress);
  6. if (siblingFiber !== null) {
  7. // If there is a sibling, return it
  8. // to perform work for this sibling
  9. return siblingFiber;
  10. } else if (returnFiber !== null) {
  11. // If there's no more work in this returnFiber,
  12. // continue the loop to complete the parent.
  13. workInProgress = returnFiber;
  14. continue;
  15. } else {
  16. // We've reached the root.
  17. return null;
  18. }
  19. }
  20. }
  21. function completeWork(workInProgress) {
  22. console.log('work completed for ' + workInProgress.name);
  23. return null;
  24. }

可以看到函数的重点是一个很大的循环。当workInProgress节点没有子节点时,React会进入此函数。完成当前fiber的工作后,它会检查是否有兄弟节点;如果找到,React退出该函数并返回指向兄弟节点的指针。它将被赋值给nextUnitOfWork变量,React将从这个兄弟开始执行分支的工作。重要的是要理解,在这一点上,React只完成了前面兄弟姐妹的工作。它尚未完成父节点的工作,只有在完成所有子节点工作后,才能完成父节点和回溯的工作.

从实现中可以看出,performUnitOfWork和completeUnitOfWork主要用于迭代目的,而主要活动则在beginWork和completeWork函数中进行。在后面的部分,我们将了解当React进入beginWork和completeWork函数时,ClickCounter组件和span节点会发生什么.

Commit phase

该阶段以completeRoot函数开始,这是React更新DOM并调用mutation生命周期方法的地方。

当React进入这个阶段时,它有2棵树和effects list。第一棵树是current tree, 表示当前在屏幕上呈现的状态,然后是在渲染阶段构建了一个备用树,它在源代码中称为finishedWork或workInProgress,表示需要在屏幕上反映的状态。此备用树通过子节点和兄弟节点指针来与current树类似地链接。

然后,有一个effects list - 通过nextEffect指针链接的,finishedWork树中节点的子集。请记住,effects list 是render阶段运行的结果。render阶段的重点是确定需要插入,更新或删除哪些节点,以及哪些组件需要调用其生命周期方法,其最终生成了effects list,也正是在提交阶段迭代的节点集。

出于调试目的,可以通过fiber root的current属性访current tree,可以通过current tree中HostFiber节点的alternate属性访问finishedWork树。

在提交阶段运行的主要功能是commitRoot。它会执行以下操作:

  • 在标记了Snapshot effect的节点上使用getSnapshotBeforeUpdate生命周期方法
  • 在标记了Deletion effect的节点上调用componentWillUnmount生命周期方法
  • 执行所有DOM插入,更新和删除
  • 将finishedWork树设置为current树
  • 在标记了Placement effect的节点上调用componentDidMount生命周期方法
  • 在标记了Update effect的节点上调用componentDidUpdate生命周期方法

在调用pre-mutation方法getSnapshotBeforeUpdate之后,React会在树中提交所有side-effects。它通过两个部分:第一部分执行所有DOM(Host)插入,更新,删除和ref卸载,然后,React将finishedWork树分配给FiberRoot,将workInProgress树标记为current树。前面这些都是在commit阶段的第一部分完成的,因此在componentWillUnmount中指向的仍然是前一个树,但在第二部分之前,因此在componentDidMount / Update中指向的是最新的树。在第二部分中,React调用所有其他生命周期方法和ref callback, 这些方法将会单独执行,因此已经调用了整个树中的所有放置(placement),更新和删除.

下面这段代码运行上述步骤的函数的要点,其中root.current=finishWork及以前为第一部分,其之后为第二部分.

  1. function commitRoot(root, finishedWork) {
  2. commitBeforeMutationLifecycles()
  3. commitAllHostEffects();
  4. root.current = finishedWork;
  5. commitAllLifeCycles();
  6. }

这些子函数中的每一个都实现了一个循环,该循环遍历effects list并检查effect的类型, 当它找到与函数功能相关的effects时,就会执行它.

Pre-mutation lifecycle methods

例如,这是在effect tree上迭代并检查节点是否具有Snapshot effect的代码:

  1. function commitBeforeMutationLifecycles() {
  2. while (nextEffect !== null) {
  3. const effectTag = nextEffect.effectTag;
  4. if (effectTag & Snapshot) {
  5. const current = nextEffect.alternate;
  6. commitBeforeMutationLifeCycles(current, nextEffect);
  7. }
  8. nextEffect = nextEffect.nextEffect;
  9. }
  10. }

对于类组件,该effect意味着调用getSnapshotBeforeUpdate生命周期方法.

DOM updates

commitAllHostEffects是React执行DOM更新的函数。该函数基本上定义了需要为节点完成并执行它的操作类型.

  1. unction commitAllHostEffects() {
  2. switch (primaryEffectTag) {
  3. case Placement: {
  4. commitPlacement(nextEffect);
  5. ...
  6. }
  7. case PlacementAndUpdate: {
  8. commitPlacement(nextEffect);
  9. commitWork(current, nextEffect);
  10. ...
  11. }
  12. case Update: {
  13. commitWork(current, nextEffect);
  14. ...
  15. }
  16. case Deletion: {
  17. commitDeletion(nextEffect);
  18. ...
  19. }
  20. }
  21. }

有趣的是,React调用componentWillUnmount方法作为commitDeletion函数中删除过程的一部分.

Post-mutation lifecycle methods

commitAllLifecycles是React调用所有剩余生命周期方法componentDidUpdate和componentDidMount的函数.