vlambda博客
学习文章列表

【React】594- React Fiber:深入理解 React reconciliation 算法


译文:Leiy 

https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/

React 是一个用于构建用户交互界面的 JavaScript 库,其核心机制就是跟踪组件的状态变化,并将更新的状态映射到到新的界面。在 React 中,我们将此过程称之为协调。我们调用setState方法来改变状态,而框架本身会去检查state或 props是否已经更改来决定是否重新渲染组件。

React 的官方文档对协调机制进行了良好的抽象描述:React 元素、生命周期、 render 方法,以及应用于组件子元素的diffing算法综合起到的作用,就是协调。

render方法返回的不可变的 React 元素通常称为虚拟 DOM。这个术语有助于早期向人们解释 React,但它也引起了混乱,并且不再用于 React 文档。在本文中,我将坚持称它为 React 元素的树。

除了 React 元素的树之外,框架总是在内部维护一个实例来持有状态(如组件、 DOM 节点等)。从版本 16 开始, React 推出了内部实例树的新的实现方法,以及被称之为Fiber的算法。

下文中,我们将结合 ClickCounter 组件展开说明。我们有一个按钮,点击它将会使屏幕上渲染的数字加1

【React】594- React Fiber:深入理解 React reconciliation 算法

代码如下:

class ClickCounter extends React.Component {
constructor(props) {
super(props);
this.state = {count: 0};
this.handleClick = this.handleClick.bind(this);
}

handleClick() {
this.setState((state) => {
return {count: state.count + 1};
});
}


render() {
return [
<button key="1" onClick={this.handleClick}>Update counter</button>,
<span key="2">{this.state.count}</span>
]
}
}

这个组件很简单,从render() 方法中返回两个子元素buttonspan

单击button按钮时,组件将更新处理程序,进而使span元素的文本进行更新。

React 在协调(reconciliation) 期间执行各种活动。例如,以下是 React 在我们的ClickCounter组件中的第一次渲染和状态更新之后执行的高级操作:

  • 更新ClickCounter组件中stateconut属性。

  • 检索并比较ClickCounter的子组件及其props

  • 更新span元素的props

协调(reconciliation) 期间执行了其他活动,包括调用生命周期方法或更新refs所有这些活动在 Fiber 架构中统称为 work。 work类型通常取决于 React 元素的类型。

例如,对于class组件,React 需要创建实例,而functional组件则不需要执行此操作。正如我们所了解的,React 中有许多元素类型,例如classfunctional组件,host组件(DOM节点)等。React 元素的类型由createElement函数的第一个参数决定,此函数通常用于创建元素的render方法中。

在我们开始探索活动细节和主要的fiber算法之前,让我们先熟悉 React 内部使用的数据结构。

React 中的每个组件都有一个UI表示,我们可以称之为从render方法返回的一个视图或模板。这是ClickCounter组件的模板:【React】594- React Fiber:深入理解 React reconciliation 算法

React Elements

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

class ClickCounter {
...
render() {
return [
React.createElement(
'button',
{
key: '1',
onClick: this.onClick
},
'Update counter'
),
React.createElement(
'span',
{
key: '2'
},
this.state.count
)
]
}
}

render方法中调用的React.createElement会产生两个如下的数据结构:

[
{
$$typeof: Symbol(react.element),
type: 'button',
key: "1",
props: {
children: 'Update counter',
onClick: () => { ... }
}
},
{
$$typeof: Symbol(react.element),
type: 'span',
key: "2",
props: {
children: 0
}
}
]

您可以看到 React 将属性添加到$$typeof这些对象中,以将它们唯一地标识为React 元素。然后我们有描述元素的属性typekey、和props。这些值取自传递给react.createElement函数的内容。

注意 React 如何将文本内容表示为spanbutton节点的子节点,以及click处理程序如何成为button元素的props的一部分,以及 React 元素上的其他字段,比如ref字段,超出了本文的范围。

ClickCounter的 React 元素没有任何propskey

{
$$typeof: Symbol(react.element),
key: null,
props: {},
ref: null,
type: ClickCounter
}

Fiber nodes

协调(reconciliation) 过程中,render方法返回的每个 React 元素的数据将被合并到Fiber节点树中,每个 React 元素都有一个对应的Fiber节点。

与 React 元素不同,Fiber不是在每此渲染上都重新创建的,它们是保存组件状态和DOM的可变数据结构。

我们之前讨论过,根据 React 元素的类型,框架需要执行不同的活动。在我们的示例中,对于类组件ClickCounter,它调用生命周期方法方法和render方法,而对于span host 组件(dom节点),它执行DOM修改。因此,每个 React 元素都被转换成相应类型的Fiber节点,用于描述需要完成的工作。

您可以将Fiber视为一种数据结构,它表示一些要做的工作,或者一个工作单元。Fiber的架构还提供了一种方便的方式来跟踪、调度、暂停和中止工作。

当react元素第一次转换为Fiber节点时,React 使用元素中的数据在createFiberFromTypeAndProps函数中创建一个Fiber。在随后的更新中,React 重用这个Fiber节点,并使用来自相应的 React 元素的数据更新必要的属性。

如果不再从render方法返回相应的 React 元素,React 可能还需要根据key属性来移动或删除层级结构中的节点。

检查ChildReconciler函数,查看所有活动的列表以及 React 为现有Fiber节点执行的相应函数。

因为 React 为每个 React 元素创建一个Fiber,而且我们有一个这些元素组成的树,所以我们可以得到一个Fiber节点树。对于我们的示例,如下所示:

【React】594- React Fiber:深入理解 React reconciliation 算法

所有fiber节点通过链接列表进行连接:childsiblingreturn

Current 树以及 workInProgress 树

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

所有的工作都是在工作进度workInProgress树的fibler上进行的。当 React 遍历当前树时,它为每个现有的fiber节点创建一个备用节点,该节点构成workInProgress树。此节点是使用render方法返回的 React 元素中的数据创建的。

一旦处理了更新并完成了所有相关工作,React 将有一个备用树准备刷新到屏幕上。在屏幕上呈现此工作进度树后,它将成为current树。

React 的核心原则之一是一致性。 React总是一次性更新DOM(它不会显示部分结果)。workInProgress树用作用户看不到的"草稿",因此 React 可以先处理所有组件,然后将其更改刷新到屏幕上。

在源代码中,您将看到许多函数从current树和workInProgress树中获取fiber节点。下面是这样一个函数:

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

每个fiber节点都保存对备用字段中另一棵树的对应节点的引用。current树中的节点指向WorkInProgress树中的节点,反之亦然。

副作用

我们可以把 React 中的一个组件看作是一个使用stateprops来计算UI呈现的函数,任何其他活动,比如改变DOM或调用生命周期方法,都应该被认为是一种副作用,或者简单地说,是一种效果。https://reactjs.org/docs/hooks-overview.html#%EF%B8%8F-effect-hook中也提到了影响:

你之前可能已经在 React 组件中执行过数据获取、订阅或者手动修改过 DOM。我们统一把这些操作称为“副作用”,或者简称为“作用”。(因为它们会影响其他组件,并且在渲染期间无法完成。)

您可以看到大多数stateprops更新将如何导致副作用。由于"作用"work的一种,所以除了更新之外,fiber节点是跟踪"作用"的一种方便机制。每个fiber节点都可以具有与其相关联的效果。它们在effectTag字段中编码。

因此,fiber中的"作用"基本上定义了在处理更新后实例需要完成的工作:

  • 对于host宿主组件(dom元素),包括添加、更新或删除元素。

  • 对于类组件,React 可能需要更新refs并调用componentDidMountcomponentDiddUpdate生命周期方法。

  • 还有其他与其他fiber相对应的效应。

副作用列表

React 进程更新非常快,为了达到这个性能水平,它使用了一些有趣的技术。其中之一是建立一个具有快速迭代效果的fiber节点线性列表。迭代线性列表比树快得多,不需要花时间在没有副作用的节点上。

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

这里有一个作用列表的类比,把它想象成一棵圣诞树,"圣诞灯"把所有有效的节点绑在一起。为了将其可视化,让我们想象一下下面的fiber节点树,其中突出显示的节点有一些工作要做,例如,我们的更新导致C2插入到DOM中,D2C1更改属性,B2触发生命周期方法。效果列表将它们链接在一起,以便 React 可以稍后跳过其他节点:

【React】594- React Fiber:深入理解 React reconciliation 算法

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

【React】594- React Fiber:深入理解 React reconciliation 算法如您所见,React 按照从子到父的顺序应用副作用。

Fiber 的根节点

每个 React 应用程序都有一个或多个充当容器的DOM元素。在我们的例子中它是带有idcontainerdiv元素。

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

React为每个容器创建一个fiber根对象。您可以使用对DOM元素的引用来访问它:

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

这个Fiber根节点是 React 保存对fibler树的引用的地方。它存储在fiber根对象的currrent属性中:

const hostRootFiberNode = fiberRoot.current

这个Fiber树以一个特殊类型的Fiber节点HostRoot 开始。它在内部创建的,并充当最顶层组件的父级。HostRoot节点可通过stateNode属性返回到FiberRoot

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

您可以通过fiber根访问最顶层的hostRoot fiber节点来浏览fiber树。或者可以从组件实例中获取单个fiber节点,如下所示:

compInstance._reactInternalFiber

Fiber 节点结构

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

{
stateNode: new ClickCounter,
type: ClickCounter,
alternate: null,
key: null,
updateQueue: null,
memoizedState: {count: 0},
pendingProps: {},
memoizedProps: {},
tag: 1,
effectTag: 0,
nextEffect: null
}

以及span DOM 元素:

{
stateNode: new HTMLSpanElement,
type: "span",
alternate: null,
key: "2",
updateQueue: null,
memoizedState: null,
pendingProps: {children: 0},
memoizedProps: {children: 0},
tag: 5,
effectTag: 0,
nextEffect: null
}

fiber节点上有很多字段。在前面的我已经描述了字段alternateeffectTagnextEfect的用途。现在让我们看看为什么我们需要其他的字段。

stateNode

保存组件的类实例、DOM节点或与Fiber节点关联的其他 React 元素类型的引用。总的来说,我们可以认为该属性用于保持与一个Fiber节点相关联的局部状态。

type

定义与此fiber关联的函数或类。

对于类组件,它指向构造函数;对于DOM元素,它指定HTML标记。(使用这个字段来了解fiber节点与什么元素相关。)

tag

定义fiber的类型,它在reconciliation(协调)算法中确定需要做什么工作。

如前所述,工作取决于 React 元素的类型。函数createFiberFromTypeAndProps将 React 元素映射到相应的fiber节点类型。在我们的案例中,ClickCounter组件的tag1,表示classComponent;而对于span元素,属性tag5,表示hostComponent

updateQueue

状态更新、回调和DOM更新的队列。

memoizedState

用于创建输出的fiber的状态,处理更新时,它会反映当前在屏幕上呈现的状态。

memoizedProps

在前一次渲染期间用于创建输出的fiberprops

pendingProps

已从 React 元素中的新数据更新并且需要应用于子组件或DOM元素的props

key

唯一标识符,当具有一组子元素的时候,可帮助 React 确定哪些项发生了更改、添加或删除。

在上文中省略了一些字段:特别是数据结构指针childsiblingreturn。以及一类特定于调度器的字段,如expirationTimechildExpirationTimemode

通用算法

React 在两个主要阶段执行工作:rendercommit

在第一个render阶段,React 通过setUpdateReact.render计划性的更新组件,并确定需要在UI中更新的内容。

如果是初始渲染,React 会为render方法返回的每个元素创建一个新的Fiber节点。在后续更新中,现有 React 元素的Fiber节点将被重复使用和更新。这一阶段是为了得到标记了副作用的Fiber节点树。

副作用描述了在下一个commit阶段需要完成的工作。在当前阶段,React 持有标记了副作用的Fiber树并将其应用于实例。它遍历副作用列表、执行 DOM更新和用户可见的其他更改。

我们需要重点理解的是,第一个render阶段的工作是可以异步执行的。React 可以根据可用时间片来处理一个或多个Fiber节点,然后停下来暂存已完成的工作,并转而去处理某些事件,接着它再从它停止的地方继续执行。但有时候,它可能需要丢弃完成的工作并再次从顶部开始。

由于在此阶段执行的工作不会导致任何用户可见的更改(如 DOM 更新),因此暂停行为才有了意义。

与之相反的是,后续commit阶段始终是同步的。这是因为在此阶段执行的工作会导致用户可见的变化,例如DOM更新。这就是为什么 React 需要在一次单一过程中完成这些更新。

React 要做的一种工作就是调用生命周期方法。一些方法是在render阶段调用的,而另一些方法则是在commit阶段调用。

这是在第一个render阶段调用的生命周期列表:

  • [UNSAFE_] componentWillMount(弃用)

  • [UNSAFE_] componentWillReceiveProps(弃用)

  • getDerivedStateFromProps

  • shouldComponentUpdate

  • [UNSAFE_] componentWillUpdate(弃用)

render

正如你所看到的,从版本 16.3 开始,在render阶段执行的一些保留的生命周期方法被标记为UNSAFE,它们现在在文档中被称为遗留生命周期。它们将在未来的16.x 发布版本中弃用,而没有UNSAFE前缀的方法将在17.0中移除。

那么这么做的目的是什么呢?

好吧,我们刚刚了解到,因为render阶段不会产生像DOM更新这样的副作用,所以 React 可以异步处理组件的异步更新(甚至可能在多个线程中执行)。

但是,标有UNSAFE的生命周期经常被误解和滥用。开发人员倾向于将带有副作用的代码放在这些方法中,这可能会导致新的异步渲染方法出现问题。虽然只有没有UNSAFE 前缀的对应方法将被删除,但它们仍可能在即将出现的并发模式(您可以选择退出)中引起问题。

接下来罗列的生命周期方法是在第二个 commit 阶段执行的:

  • getSnapshotBeforeUpdate

  • componentDidMount

  • componentDidUpdate

  • componentWillUnmount

因为这些方法都在同步的commit阶段执行,他们可能会包含副作用,并对DOM进行一些操作。

至此,我们已经有了充分的背景知识,下面我们可以看下用来遍历树和执行一些工作的通用算法。

Render 阶段

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

工作循环的主要步骤

所有的Fiber节点都会在工作循环中进行处理。如下是该循环的同步部分的实现:

function workLoop(isYieldy) {
if (!isYieldy) {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
} else {...}
}

在上面的代码中,nextUnitOfWork持有workInProgress树中的Fiber 节点的引用,这个树有一些工作要做:当 React 遍历Fiber树时,它会使用这个变量来知晓是否有任何其他Fiber节点具有未完成的工作。处理过当前Fiber后,变量将持有树中下一个Fiber节点的引用或null。在这种情况下,React 退出工作循环并准备好提交更改。

遍历树、初始化或完成工作主要用到4个函数:

  • performUnitOfWork

  • beginWork

  • completeUnitOfWork

  • completeWork

为了演示他们的使用方法,我们可以看看如下展示的遍历Fiber树的动画。我已经在演示中使用了这些函数的简化实现。每个函数都需要对一个Fiber节点进行处理,当 React 从树上下来时,您可以看到当前活动的Fiber节点发生了变化。从GIF中我们可以清楚地看到算法如何从一个分支转到另一个分支。它首先完成子节点的工作,然后才转移到父节点进行处理。

注意,垂直方向的连线表示同层关系,而折线连接表示父子关系,例如,b1 没有子节点,而 b2 有一个子节点 c1。

从概念上讲,你可以将开始视为进入一个组件,并将完成视为离开它。

我们首先开始研究performUnitOfWorkbeginWork这两个函数:

function performUnitOfWork(workInProgress) {
let next = beginWork(workInProgress);
if (next === null) {
next = completeUnitOfWork(workInProgress);
}
return next;
}

function beginWork(workInProgress) {
console.log('work performed for ' + workInProgress.name);
return workInProgress.child;
}

函数performUnitOfWorkworkInProgress树接收一个Fiber节点,并通过调用beginWork函数启动工作,这个函数将启动所有Fiber执行工作所需要的活动。出于演示的目的,我们只logFiber节点的名称来表示工作已经完成。函数beginWork始终返回指向要在循环中处理的下一个子节点的指针或null

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

这是completeUnitOfWork函数执行的代码:

function completeUnitOfWork(workInProgress) {
while (true) {
let returnFiber = workInProgress.return;
let siblingFiber = workInProgress.sibling;

nextUnitOfWork = completeWork(workInProgress);

if (siblingFiber !== null) {
// If there is a sibling, return it
// to perform work for this sibling
return siblingFiber;
} else if (returnFiber !== null) {
// If there's no more work in this returnFiber,
// continue the loop to complete the parent.
workInProgress = returnFiber;
continue;
} else {
// We've reached the root.
return null;
}
}
}

function completeWork(workInProgress) {
console.log('work completed for ' + workInProgress.name);
return null;
}

你可以看到函数的核心就是一个大的while的循环。当workInProgress节点没有子节点时,React 会进入此函数。完成当前 Fiber 节点的工作后,它就会检查是否有同层节点。

如果找的到,React 退出该函数并返回指向该同层节点的指针。它将被赋值给 nextUnitOfWork变量,React将从这个节点开始执行分支的工作。

我们需要着重理解的是,在当前节点上,React 只完成了前面的同层节点的工作。它尚未完成父节点的工作。只有在完成以子节点开始的所有分支后,才能完成父节点和回溯的工作。

从实现中可以看出,performUnitOfWorkcompleteUnitOfWork主要用于迭代目的,而主要活动则在beginWorkcompleteWork函数中进行。

commit 阶段

这一阶段从函数completeRoot开始。在这个阶段,React 更新DOM并调用变更生命周期之前及之后方法的地方。

当 React 进入这个阶段时,它有2棵树和副作用列表。第一个树表示当前在屏幕上渲染的状态,然后在render阶段会构建一个备用树。它在源代码中称为finishedWorkworkInProgress,表示需要映射到屏幕上的状态。此备用树会用类似的方法通过childsibling指针链接到current树。

然后,有一个副作用列表(它是finishedWork树的节点子集),通过nextEffect 指针进行链接。需要记住的是,副作用列表是运行render阶段的结果。渲染的重点就是确定需要插入、更新或删除的节点,以及哪些组件需要调用其生命周期方法。这就是副作用列表告诉我们的内容,它页正是在 commit 阶段迭代的节点集合。

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

commit阶段运行的主要函数是commitRoot。它执行如下下操作:

  • 在标记为Snapshot副作用的节点上调用getSnapshotBeforeUpdate生命周期。

  • 在标记为Deletion副作用的节点上调用componentWillUnmount生命周期。

  • 执行所有DOM插入、更新、删除操作。

  • finishedWork树设置为current

  • 在标记为Placement副作用的节点上调用componentDidMount生命周期。

  • 在标记为Update副作用的节点上调用componentDidUpdate生命周期。

在调用变更前方法getSnapshotBeforeUpdate之后,React 会在树中提交所有副作用,这会通过两波操作来完成。

第一波执行所有 DOM(宿主)插入、更新、删除和 ref 卸载。然后 React 将finishedWork树赋值给FiberRoot,将 workInProgress树标记为current树。这是在提交阶段的第一波之后、第二波之前完成的,因此在componentWillUnmount中前一个树仍然是current,在componentDidMount/Update期间已完成工作是current

在第二波,React 调用所有其他生命周期方法和引用回调。这些方法单独传递执行,从而保证整个树中的所有放置、更新和删除能够被触发执行。

以下是运行上述步骤的函数的要点:

function commitRoot(root, finishedWork) {
commitBeforeMutationLifecycles()
commitAllHostEffects();
root.current = finishedWork;
commitAllLifeCycles();
}

这些子函数中都实现了一个循环,该循环遍历副作用列表并检查副作用的类型。当它找到与函数目的相关的副作用时,就会执行。

更新前的生命周期方法

例如,这是在副作用树上遍历并检查节点是否具有Snapshot副作用的代码:

function commitBeforeMutationLifecycles() {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
if (effectTag & Snapshot) {
const current = nextEffect.alternate;
commitBeforeMutationLifeCycles(current, nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}

对于一个类组件,这一副作用意味着会调用getSnapshotBeforeUpdate生命周期方法。

DOM 更新

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

function commitAllHostEffects() {
switch (primaryEffectTag) {
case Placement: {
commitPlacement(nextEffect);
...
}
case PlacementAndUpdate: {
commitPlacement(nextEffect);
commitWork(current, nextEffect);
...
}
case Update: {
commitWork(current, nextEffect);
...
}
case Deletion: {
commitDeletion(nextEffect);
...
}
}
}

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

更新后的生命周期方法

commitAllLifecycles是 React 调用所有剩余生命周期方法的函数。在 React 的当前实现中,唯一会调用的变更方法就是componentDidUpdate