深入React fiber架构及源码

SilasEve 发布于1年前
0 条问题

前言

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的机制。

背景

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

import React, { Component } from 'react';

export default class ClickCounter extends 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方法返回两个子元素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组件的模板:

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

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元素。然后我们可以通过type,key和props属性来描述元素。这些值取自传递给React.createElement函数的值。请注意React如何将文本内容表示为span和button节点的子项,以及click处理程序如何成为按钮元素props的一部分。 React元素上还有其他字段,如ref字段,超出了本文的范围,不再阐述。

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

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

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。对于我们的示例应用程序,它看起来像这样:

深入React fiber架构及源码

所有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节点:

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可以在以后跳过其他节点:

深入React fiber架构及源码

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

深入React fiber架构及源码

Root of the fiber tree

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

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

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

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

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

const hostRootFiberNode = fiberRoot.current

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

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

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

compInstance._reactInternalFiber

Fiber node structure

现在让我们看一下为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节点:

{
    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元素类型的引用。一般来说,可以认为这个属性用于保存与光纤相关的本地状态。
  • type: 定义与此光纤关联的功能或类。对于类组件,它指向构造函数;对于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 中处理。这是循环的同步部分的实现

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

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

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

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

深入React fiber架构及源码

注意,直的垂直连接表示兄弟姐妹,而弯曲的连接表示儿童,例如b1没有孩子,而b2有一个孩子c1.

这是视频的链接,您可以在其中暂停播放并检查当前节点和功能状态。

让我们从前两个函数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.

如果有下一个子节点,它将被赋值给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;
}

可以看到函数的重点是一个很大的循环。当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。第一棵树表示当前在屏幕上呈现的状态,然后是在渲染阶段构建了一个备用树,它在源代码中称为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及以前为第一部分,其之后为第二部分.

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

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

Pre-mutation lifecycle methods

例如,这是在effect tree上迭代并检查节点是否具有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 updates

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

unction 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函数中删除过程的一部分.

Post-mutation lifecycle methods

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

总结:这篇文章是我一段时间来,看到的关于react fiber架构解析最好的文章,看完之后获益匪浅;因此整理翻译了这篇文章,希望能帮到感兴趣的小伙伴.

来源:

查看原文: 深入React fiber架构及源码

  • smallpeacock
  • smallsnake
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。