深入 React Fiber

844 阅读22分钟

原文

React是一个用于构建用户界面的JavaScript库。该机制的核心是跟踪组件状态变化并将更新的状态投影到屏幕的机制。在React中,我们将此过程称为和解。我们调用setState方法,并且框架检查状态或道具是否已更改,然后重新呈现UI上的组件。

React的文档对机制进行了高级概述:React元素的作用,生命周期方法和render方法,以及应用于组件子元素的diffing算法。从render方法返回的不可变React元素树通常被称为“虚拟DOM”。这个术语有助于尽早向人们解释React,但它也引起了混淆,在React文档中不再使用。在本文中,我将坚持将其称为React元素树。

除了React元素树之外,框架始终具有内部实例树(组件,DOM节点等),用于保持状态。从版本16开始,React推出了该内部实例树的新实现以及管理它的代号为Fiber的算法。要了解Fiber架构带来的优势,请查看React在Fiber中如何以及为什么使用链表

如果没有Dan Abramov的帮助,这篇文章将使我花更长的时间撰写文章,而使我的文章变得不那么全面! 👍

这是该系列的第一篇文章,旨在教您React的内部体系结构。在本文中,我想提供与算法有关的重要概念和数据结构的深入概述。有了足够的背景知识后,我们将探索用于遍历和处理纤维树的算法和主要功能。该系列的下一篇文章将演示React如何使用该算法执行初始渲染和流程状态以及道具更新。在此,我们将继续讨论sheeduler程序,child reconciliation process 以及构建effects list 的机制的详细信息。

跟着我保持关注!

在这里,我将为您提供一些相当高级的知识。我鼓励您阅读它,以了解Concurrent React内部工作背后的魔术。如果您打算开始为React做出贡献,那么这一系列文章也将为您提供很好的指导。我是逆向工程的忠实信徒,因此会有很多指向最新版本16.6.0的资源的链接。

肯定要花很多精力,因此如果您不立即了解某些内容,就不要感到压力。花费时间是值得的。请注意,使用React无需了解任何知识。本文是关于React如何在内部工作的。

设定背景

这是一个简单的应用程序,我将在整个系列中使用。 我们有一个按钮,可以简单地增加屏幕上呈现的数字:

这是实现:

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。一旦您单击按钮,组件的状态就会在处理程序中更新。反过来,这会导致span元素的文本更新。

reconciliation期间,React会执行各种活动。例如,以下是React在我们的简单应用程序中的第一次渲染和状态更新后执行的高级操作:

  • 更新ClickCounter状态下的count属性
  • 检索并比较ClickCounter的子代及其props
  • 更新span元素的props

reconciliation期间还会执行其他活动,例如调用生命周期方法更新引用所有这些活动在 Fiber 结构中统称为“work”。work类型通常取决于React元素的类型。例如,对于一个类组件,React需要创建一个实例,而对于函数组件则不需要。如您所知,React中有很多元素,例如类和函数组件,host组件(DOM节点),portals等。React元素的类型由createElement函数的第一个参数定义。此函数通常在render方法中用于创建元素。

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

从React Elements到Fiber节点

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

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

React 元素

模板通过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元素。 然后我们有描述元素的属性type,key和props。 这些值取自传递给React.createElement函数的内容。 注意React如何将文本内容表示为span和button节点的子代。 以及点击处理程序如何成为按钮元素props的一部分。 React元素上还有其他字段(例如ref字段)不在本文讨论范围之内。

ClickCounter的React元素没有任何props或key:

{
    ?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方法,而对于host组件(DOM节点),它执行DOM突变。因此,每个React元素都被转换为相应类型的Fiber节点,该节点描述了需要完成的工作。

您可以将Fiber视为代表要执行的某些工作或换句话说,一个工作单元的数据结构。 Fiber的体系结构还提供了一种方便的方式来track, schedule, pause and abort工作。

第一次将React元素转换为Fiber节点时,React使用元素中的数据在createFiberFromTypeAndProps函数中创建Fiber。在随后的更新中,React重用了Fiber节点,并仅使用来自相应React元素的数据来更新必要的属性。如果相应的React元素不再从render方法返回,React可能还需要根据关键props在层次结构中移动节点或将其删除。

checkout ChildReconciler函数以查看React对现有Fiber节点执行的所有活动和相应功能的列表。

因为React为每个React元素创建了一个fiber,并且由于我们拥有这些元素的树,所以我们将拥有一棵fiber节点树。在我们的示例应用程序中,它看起来像这样:

使用fiber节点上的以下属性,通过链接链表所有fiber节点:child, sibling and return。有关为什么它如此工作的更多详细信息,请查看我的文章如何以及为什么在React中使用Fiber中的链表(如果您还没有阅读过的话)。

Current and work in progress trees

在第一个渲染之后,React最终以一棵fiber树结束,该树反映了用于渲染UI的应用程序的状态。该树通常被称为当前树。当React开始进行更新时,它将构建一个所谓的workInProgress树,该树反映将刷新到屏幕的将来状态。

所有工作均在workInProgress树的fiber上执行。当React遍历当前树时,它为每个现有的fiber节点创建一个替代节点,该节点构成workInProgress树。该节点是使用render方法返回的React元素中的数据创建的。一旦处理完更新并完成所有相关工作,React将准备好备用树以刷新到屏幕上。在屏幕上呈现此workInProgress树后,它将成为当前树。

React的核心原则之一是一致性。 React总是一口气更新DOM,不会显示部分结果。 workInProgress树充当用户不可见的“草稿”,因此React可以先处理所有组件,然后将其更改刷新到屏幕上。

在源代码中,您将看到很多从当前树和workInProgress树获取 fiber 节点的函数。这是其中一个功能的签名:

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

每个fiber节点在备用字段中都拥有另一棵树对其对应节点的引用。当前树中的节点指向workInProgress树中的节点,反之亦然。

副作用

我们可以将React中的组件视为使用状态和道具来计算UI表示的函数。其他所有活动,例如使DOM发生突变或调用生命周期方法,都应视为副作用,或者简单地视为一种效果。在文档中还提到了effects

您可能之前已经执行过数据获取,订阅或手动从React组件更改DOM的操作。我们将这些操作称为“副作用”(或简称为“effect”),因为它们会effect其他组件,并且在render过程中无法完成。

您可以看到大多数state和props更新将如何导致副作用。而且,由于应用effect是一种work,因此fiber节点是跟踪effect以及更新的便捷机制。每个fiber节点都可以具有与其相关的效果。它们被编码在effectTag字段中。

因此,Fiber中的effect基本上定义了处理更新后实例需要完成的work。对于host组件(DOM元素),work包括添加,更新或删除元素。对于类组件,React可能需要更新引用并调用componentDidMountcomponentDidUpdate生命周期方法。还有其他effect对应于其他类型的fiber。

效果清单

React流程更新非常快,并且为了达到那种性能水平,它采用了一些有趣的技术。其中之一是建立具有effect的fiber节点的线性列表,以实现快速遍历。遍历线性链表比一棵树快得多,并且不需要花时间在没有副作用的节点上。

该列表的目的是标记具有DOM更新或与之关联的其他effect的节点。 此列表是FinishedWork树的子集,并使用nextEffect属性而不是当前树和workInProgress树中使用的子属性进行链接。

丹·阿布拉莫夫(Dan Abramov)为effect列表提供了一个类比。 他喜欢将其视为一棵圣诞树,“圣诞灯”将所有有效节点绑定在一起。 为了直观地看到这一点,让我们想象一下下面的 fiber 节点树,其中突出显示的节点有一些工作要做。 例如,我们的更新导致将c2插入DOM,d2c1更改属性,而b2触发生命周期方法。 effect列表会将它们链接在一起,以便React以后可以跳过其他节点:

您可以看到具有effect的节点如何链接在一起。 当遍历节点时,React使用firstEffect指针找出列表的起始位置。 因此,上图可以表示为线性列表,如下所示:

Root of the fiber tree

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

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

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

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

该fiber根是React保留对fiber树的引用的位置。 它存储在fiber根的当前属性中:

const hostRootFiberNode = fiberRoot.current

Fiber树从特殊类型的fiber节点HostRoot开始。 它是在内部创建的,并充当最顶层组件的父级。 通过stateNode属性,从HostRoot fiber节点到fiber根都有一个链接:

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

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

compInstance._reactInternalFiber

Fiber节点结构

现在让我们看一下 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节点上有很多字段。我已经在上一节中介绍了alternate,effectTag和nextEffect字段的用途。现在让我们看看为什么我们需要其它字段。

stateNode

保留对组件,DOM节点或与fiber节点关联的其他React元素类型的类实例的引用。通常,我们可以说此属性用于保存与fiber关联的局部状态。

type

定义与此fiber关联的功能或类。对于类组件,它指向构造函数,对于DOM元素,它指定HTML标记。我经常使用此字段来了解fiber节点与哪些元素相关。

tag

定义fiber的类型。在调和算法中使用它来确定需要完成的工作。如前所述,工作取决于React元素的类型。函数createFiberFromTypeAndProps将React元素映射到相应的fiber节点类型。在我们的应用程序中,ClickCounter组件的属性标记为1,表示类组件,而span元素的属性标记为5,表示HostComponent。

updateQueue

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

memoizedState

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

memoizedProps

在上一个渲染期间用于创建输出的fiber支柱。

pendingProps

已经从React元素中的新数据更新的道具,需要将其应用于子组件或DOM元素。

key

带有一组子代的唯一标识符,以帮助React找出哪些项目已更改,已添加或已从列表中删除。它与此处所述的React的“列表和键”功能有关。

您可以在此处找到fiber节点的完整结构。在上面的说明中,我省略了很多字段。特别是,我跳过了构成子树数据结构的指针子项,同级和返回,这在我之前的文章中已有介绍。以及计划程序专用的一类字段,例如expirationTime,childExpirationTime和mode

通用算法

React在两个主要阶段执行工作:render和commit。

在第一个渲染阶段,React将更新应用于通过setStateReact.render计划的组件,并找出需要在UI中进行更新的内容。如果是初始渲染,React会为从render方法返回的每个元素创建一个新的 fiber节点。在以下更新中,将重新使用和更新现有React元素的fiber。该阶段的结果是一棵带有副作用的fiber节点树。效果描述了在下一个提交阶段中需要完成的工作。在此阶段,React会绘制一棵标记有效果的纤维树,并将其应用于实例。它遍历效果列表,并执行DOM更新和用户可见的其他更改。

重要的是要了解,第一个渲染阶段的工作可以异步执行。React可以根据可用时间处理一个或多个 fiber 节点,然后停止存储已完成的工作并屈服于某个事件。然后从中断处继续。但是有时,它可能需要放弃所做的工作,然后重新从头开始。由于在此阶段执行的工作不会导致任何用户可见的更改(例如DOM更新),因此可以实现这些暂停。相反,随后的提交阶段始终是同步的。这是因为在此阶段执行的工作会导致用户可见的更改,例如DOM更新。这就是为什么React需要一次完成这些操作。

调用生命周期方法是React进行的一种work。一些方法在渲染阶段被调用,而其他方法在提交阶段被调用。这是在执行第一个渲染阶段时调用的生命周期的列表:

  • [UNSAFE_]componentWillMount(已弃用)
  • [UNSAFE_]componentWillReceiveProps(已弃用)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate(不建议使用)
  • render

如您所见,从渲染版本16.3开始,在渲染阶段执行的某些旧式生命周期方法被标记为UNSAFE。它们现在在文档中称为旧版生命周期。它们将在以后的16.x版本中弃用,而没有UNSAFE前缀的对应版本将在17.0中删除。您可以在此处详细了解这些更改以及建议的迁移路径。

您是否对此感到好奇?

好吧,我们刚刚了解到,由于渲染阶段不会产生DOM更新之类的副作用,因此React可以异步地处理组件的更新(甚至可能在多个线程中进行)。但是,标有UNSAFE的生命周期常常被误解和巧妙地滥用。开发人员倾向于将带有副作用的代码放入这些方法中,这可能会导致新的异步呈现方法出现问题。尽管只会删除没有UNSAFE前缀的对应项,但它们仍可能在即将出现的并发模式(您可以选择退出)中引起问题

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

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

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

Ok,现在我们有背景了解用于walk the tree 与 perform work的通用算法。让我们继续深入下去。

渲染阶段

Reconciliation算法始终使用renderRoot函数从最顶层的HostRoot fiber 节点开始。但是,React会退出(跳过)已处理的 fiber节点,直到发现工作未完成的节点为止。例如,如果您在组件树的深处调用setState,则React将从顶部开始,但是会快速跳过父级,直到它到达调用了setState方法的组件。

work loop的主要步骤

所有fiber节点都在工作循环中进行处理。这是循环同步部分的实现:

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

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

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

  • performUnitOfWork
  • beginWork
  • completeUnitOfWork
  • completeWork

为了演示它们的用法,请看以下遍历fiber树的动画。我在演示中使用了这些功能的简化实现。每个功能都需要一个fiber节点进行处理,随着React在树上消失,您可以看到当前活动的 fiber 节点发生了变化。您可以在视频中清楚地看到算法是如何从一个分支转到另一个分支的。在移交给parent之前,它首先为children完成了工作。

注意,笔直的垂直连接表示同级,而弯曲的连接表示子级,例如 b1没有孩子,而b2有一个孩子c1。

这是视频的链接,您可以在其中暂停播放并检查当前节点和功能状态。 从概念上讲,您可以将“开始begin”视为“进入stepping into”组件,而将“完整complete”视为“逐步stepping out”。 当我解释这些功能的作用时,您也可以在此处使用示例和实现。

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

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;
}

函数performUnitOfWork从workInProgress树接收一个 fiber 节点,并通过调用beginWork函数开始工作。 此功能将启动 fiber 需要执行的所有活动。 为了演示的目的,我们只记录 fiber 的名称以表示工作已经完成。 函数beginWork总是返回指向下一个要在循环中处理的子项的指针,或者返回null。

如果有下一个child,它将在workLoop函数中分配给变量nextUnitOfWork。 但是,如果没有子节点,React会知道它已经到达分支的末尾,因此可以完成当前节点。 节点完成后,需要为兄弟姐妹perform work,然后回溯到父节点。 这是在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目前仅完成了之前的兄弟姐妹的工作。尚未完成父节点的工作。只有从子节点开始的所有分支都完成后,它才能完成父节点和回溯的工作。

从实现中可以看到,performUnitOfWork和completeUnitOfWork都主要用于迭代目的,而主要活动发生在beginWork和completeWork函数中。在本系列的以下文章中,随着React进入beginWork和completeWork函数,我们将了解ClickCounter组件和span节点的情况。

提交阶段

该阶段从功能completeRoot开始。这是React更新DOM并调用突变前后生命周期方法的地方。

当React进入这一阶段时,它有2棵树和效果列表。第一棵树代表当前在屏幕上呈现的状态。然后在渲染阶段构建了另一棵树。在源代码中被称为finishWork或workInProgress,代表需要在屏幕上反映的状态。该备用树通过子级和同级指针类似地链接到当前树。

然后,有一个effects list通过nextEffect指针链接的finishWork树中的一部分节点。请记住,effects list是运行渲染阶段的结果。渲染的全部目的是确定需要插入,更新或删除哪些节点,以及哪些组件需要调用其生命周期方法。这就是effects list告诉我们的。而正是在提交阶段迭代的节点集。

出于调试目的,可以通过 fiber 根的current属性访问当前树。可以通过当前树中HostFiber节点的替代属性来访问finishedWork树。

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

  • 在标记有Snapshot effect的节点上调用getSnapshotBeforeUpdate生命周期方法
  • 在标记有“删除”effect的节点上调用componentWillUnmount生命周期方法
  • 执行所有DOM插入,更新和删除
  • 将FinishedWork树设置为当前树
  • 在标记有Placement效果的节点上调
  • componentDidMount生命周期方法
  • 在标记有Update效果的节点上调用componentDidUpdate生命周期方法

调用了pre-mutation的方法getSnapshotBeforeUpdate之后,React会在树中提交所有副作用。它分两次通过。第一遍执行所有DOM(主机)的insertions, updates, deletions and ref unmounts。然后React将完成的工作树分配给FiberRoot,将workInProgress树标记为当前树。这是在提交阶段的第一遍之后完成的,因此前一棵树在componentWillUnmount期间仍然是当前的,但是在第二遍之前,因此已完成的工作在componentDidMount/Update期间是当前的。在第二遍中,React调用所有其他生命周期方法和ref回调。这些方法作为单独的过程执行,因此整个树中的所有放置,更新和删除操作均已被调用。

这是执行上述步骤的功能要点:

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

这些子功能中的每个子功能都实现一个循环,该循环遍历effect-list并检查effect类型。当发现与功能目的有关的effect时,将其应用。

Pre-mutation lifecycle methods

例如,以下是在效果树上迭代并检查节点是否具有Snapshot effect的代码:

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

对于类组件,此effect意味着调用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在commitDeletion函数中的删除过程中调用componentWillUnmount方法。

Post-mutation lifecycle methods

commitAllLifecycles是React调用所有剩余生命周期方法componentDidUpdate和componentDidMount的函数。 我们终于完成了。让我知道您对本文的看法或在评论中提出问题。查阅该系列中的下一篇文章,深入了解React中的state和props更新