[译] Inside Fiber: 深入了解React的新协调算法

React是一个用于构建用户界面的JavaScript库。它的核心机制是跟踪组件的state变化并将更新后的state显示到屏幕上。在React中这个过程叫做协调(reconciliation)。我们调用setState方法,React会检查stateprops是否变化,然后重新渲染组件到UI上。

React文档为这个机制提供了很好的高级概述:React元素的角色,生命周期方法和render方法,以及应用到组件childrendiffing算法。由render方法返回的元素组成的树通常被认为是”虚拟DOM“。这个术语早期有助于理解React,但是它也引起了困惑并且在React文档里已经不再使用它了。在这篇文章里我称其为React元素树。

除了React元素树,还有一颗内部实例树(组件,DOM节点等等)用于保存状态。从版本16开始,React推出了内部树和管理内部树的算法的实现,称为Fiber。通过React如何以及为什么在Fiber中使用链表

这是让你了解React内部架构系列的第一篇文章。在这篇文章中,我想提供关于这个算法的重要概念和数据结构的概述。一旦我们拥有足够的背景知识,我们就会探索该算法用于遍历和处理fiber树的主要函数。在这个系列接下来的文章中会展示React是如何使用这个算法进行首次渲染,处理stateprops的更新。在那之前,我们先了解调度器、协调过程和构建effects列表的机制的细节。

我会教你一些相当高级的知识?我鼓励你阅读它来理解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的简单组件。一旦你点击按钮,组件的state在处理函数中被更新。这样就会导致span元素的文本更新。

React协调过程中有很多活动,比如调用 生命周期方法 、更新refsFiber架构中这些活动都称为”work“。work的类型通常依赖于React元素的类型。举个例子,对于类组件,React需要创建一个实例,而函数组件则不必这样。正如你所知道的,React中有很多种类的元素,如类组件和函数组件,原生组件(DOM节点),portals等等。React元素的类型是由createElement函数的第一个参数确定的。这个函数通常用于render方法中用来创建一个元素。

在研究这些活动和fiber主要算法前,我们先来熟悉React内部使用的数据结构。

React元素到Fiber节点

React的每个组件都有UI表示,我们可以称从render方法返回的为视图或模板。这是我们ClickCounter组件的模板:

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

React元素

一个模板经过JSX编译器编译后,就会得到一堆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元素。还有些属性typekeyprops来描述元素。这些值是通过React.createElement函数传递进来的。注意React如何让文本内容作为spanbuttonchildren。以及点击事件如何作为button元素的props的一部分。React元素上还有其他一些超出本文讨论范围的字段比如ref

ClickCounterReact元素没有任何propsref

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

Fiber节点

协调过程中,每个从render返回的React元素会被合并一颗fiber树。每个React元素都有相应的fiber节点。与React元素不同的是,fiber节点不会再每次渲染是从新创建。它们是可变的数据结构,保存了组件state和DOM。

我们之前讨论过React根据元素类型执行不同活动。在我们的实例程序中,对于类组件ClickCounter会调用生命周期方法和render方法,而对于span这样的原生组件(DOM节点)会执行DOM变化。所以每个React元素会被转换成相应类型的Fiber节点,这个节点描述了需要完成的work。

你可以将Fiber理解为一种表示待做的一些work的数据结构,或者换句话说,一个work单元。Fiber架构也提供了一种方便的方式来追踪、调度、暂停和中止这些work。

当一个React元素第一次转换成fiber节点时,ReactcreateFiberFromTypeAndProps函数中使用元素中的数据来创建一个fiber。在更新中React会复用fiber节点,根据相应的React元素仅更新必要的属性。React也可能根据keyprop来移动节点,或者如果相应的的React不再从render方法中返回,那么就删除它。

查看ChildReconciler函数来了解活动列表以及React对于已经存在的fiber节点执行的函数。

因为React为每个React元素创建了fiber节点并且我们有一颗由这些元素组成的树,所以我们将有一个由fiber节点组成的树。在我们例子中看起来像这样:

所有fiber节点都是通过fiber节点上的这几个属性形成链表:childsublingreturn。要了解为什么这样做的更多细节,请阅读我的文章React如何以及为什么在Fiber中使用链表,如果你还没读过。

Current和work in process树

首次渲染后,React中存在一颗保存了应用程序状态,用于渲染UI的fiber树。这颗树通常称为current。当React开始进行更新时,它创建一颗所谓的workInProgress树,这棵树保存着将来要刷新到屏幕上的状态。

所有的work都是在workInProgress树的fibers上执行的。当React遍历current树,对于每个现存的fiber节点,React会创建一个代替(alternate)节点,这些代替节点组成workInProgress树。代替节点是由render方法返回的React元素的数据创建的。一旦更新都被处理了、所有相关联的work完成了,React就会有一颗准备刷新到屏幕上的代替树。一旦workInProgress树渲染到屏幕上,它就变成current树。

React的核心原则之一就是连贯性。React总是一次性更新DOM,它不会显示部分结果。workInProgress树就像一份草稿,用户是看不见它的,所以React可以先处理所有组件,然后在将它们的变化更新到屏幕上。

在源码中你会看到很多使用currentworkInProgress树节点的函数,这是其中一个函数的签名:

function updateHostComponent(current, workInProgress, renderExpirationTime) {...}
复制代码

每个fiber节点在alternate字段上保存了另一颗树上相应节点的引用。current树上节点指向workInProgress树上相应的节点,反之亦然。

Side-effects(副作用)

我们可以把React中的组件看成一个使用stateprops来得到UI页面的函数。其他的每个活动比如DOM变化或调用生命周期方法都应该被认为是副作用或作用。Effects在文档中也有提及。

你之前在React组件中可能执行过数据获取,订阅,或手动更改DOM。我们把这些操作称为副作用(或简称作用),因为它们可能影响其他组件,而且在渲染过程中无法完成。

你可以看到大部分stateprops更新如何产生副作用。由于标记effects是一种work,除了更新外,fiber节点是一种方便跟踪effects的机制。每个fiber节点都可以关联它的effects。它们保存在effectTag字段上。

因此,Fiber中的effects基本定义了更新被处理后实例需要完成的work。对于原生组件(DOM元素),work包含添加、更新或移除元素。对于类组件,React可能需要更新refs,调用componentDidMountcomponentDidUpdate生命周期方法。其他类型的fibers有相应的其他effects。

Effects list

React处理更新非常快,为了实现高性能它使用了一些有趣的技术。它们中的一个就是创建一个由包含effects的fiber节点组成的线性链表来实现快速迭代。 迭代线链列表比迭代一颗树快的多,而且无需在没有副作用的节点上浪费时间。

这个链表的目标是标记含有DOM更新或其他effects的节点并把它们关联起来。这个链表是finishedWork树的子集,节点之间使用nextEffect属性进行连接,而不是 currentworkInProgress树中使用的 child属性。

Dan Abramov为effects list描述了一种比喻。他喜欢将它想象成挂在圣诞树上的”圣诞灯“,”圣诞灯“将所有有副作用的节点绑到一起。形象点说,把下面fiber树种高亮的节点想象成有些work要做的节点。比如,我们的更新导致c2插入DOM中,d2c1改变了属性,b2触发了一个生命周期方法。effects list会把它们连接起来,如此,React在后面就可以跳过其他节点:

你可以看到有副作用的节点是如何连接到一起的。遍历节点时,React使用firstEffect指针找出list从哪开始。所以上面的图可以看成这样的线性链表:

Root of the fiber tree

每个React程序都有一个或多个DOM元素作为容器。在我们的例子中它是ID为containerdiv元素。

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

React为这些容器创建了一个fiber root对象。你可以使用DOM元素的引用来获取它。

const fiberRoot = query('#container')._reactRootContainer._internalRoot
复制代码

这个fiber root就是React保存一颗fiber树引用的地方。它保存在fiber root的current属性中。

const hostRootFiberNode = fiberRoot.current
复制代码

fiber树开始于一个特殊类型的fiber节点,它就是HostRoot。它在内部创建,作为你最顶层组件的父级。HostRootfiber节点上有个指回FiberRootstateNode属性:

fiberRoot.current.stateNode === fiberRoot; // true
复制代码

你可以通过访问最顶层HostRootfiber节点到达fiber root,接着探索fiber树。
或者你可以从组件实例中获取一个fibe节点,就像这样:

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
}
复制代码

spanDOM元素的fiber节点:

{
    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节点上有很多字段。在之前的部分中我已经描述过字段alternateeffectTagnextEffect的作用。现在来看看为什么需要其他字段。

stateNode

保存类组件实例,DOM节点,或其他与fiber节点关联的React元素类型。总的来说,我们可以说这个属性用于保存与fiber节点关联的本地状态。

type

定义与这个fiber关联的函数或类。对于类组件,它指向构造函数,对于DOM元素,它代表HTML标签。我经常使用这个字段来理解与一个fiber节点关联的元素是什么。

tag

定义the type of the fiber。它在协调算法中用于确定什么work要做。如前所述,React元素类型不同,work有所不同。createFiberFromTypeAndProps函数将React元素映射成相应的fiber节点类型。在我们的程序中,ClickCounter组件的tag属性是1,表示它是一个ClassComponentspan元素的是5表示它是一个HostComponent

updateQueue

一条state更新,回调和DOM更新的队列。

memoizedState

fiber中用于创建输出的state。当处理更新时,它表示当前渲染到屏幕上的state。

memoizedProps

在之前渲染中fiber用于创建输出的props。

pendingProps

React元素中从新数据中更新的props,需要传递给子组件或DOM元素。

key

一组子元素中的唯一标识符,帮助React从列表中找出哪些项目已变化、添加或者删除。它与React文档此处描述的”列表和keys“功能有关。

你可以在看到fiber节点完整的结构。我在上面的说明中删除了一堆字段。特别是我跳过了我在上篇文章中描述过了的组成树结构的childsiblingreturn 指针。还有一类字段像expirationTimechildExpirationTimemode,它们是给调度器用的。

通用算法

React在两个主要阶段中执行work:rendercommit

render阶段中,React将更新应用到通过setStateReact.render调度的组件,并且找出什么需要被更新到UI。如果是首次渲染,React为每个从render方法中返回的元素创建新的fiber节点。在接下来的更新中,现存的React元素的fiber会被复用和更新。这个阶段的结果是由标记了副作用的fiber节点组成的树。 effects描述了在接下来的commit阶段需要完成的work。在这个阶段中,React拥有一颗标记了effects的fiber树,并将它们应用到实例上。它遍历effects链表执行DOM更新和其他用户可见的变化。

render阶段中的work是可以异步执行的,理解这一点很重要。 React在可用时间内处理一个或多个fiber节点,然后停止运行并暂存完成的work,让步于其他事件。然后从它停止的地方继续。但有时,它可能需要放弃已完成的work,再次从顶部开始。正是因为这个阶段执行的work不会导致任何用户可见的变化,比如DOM更新,使得这些暂停成为可能。相反,后面的commit阶段总是同步的。 这是因为这个阶段执行的work会用户可见的变化,例如DOM更新。这就是为什么React需要一次性完成它们。

调用生命周期方式是React执行的一类work。一些方法在render阶段调用,其他的在commit阶段调用。下列生命周期函数在render阶段中调用:

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

如你所见,一些在render阶段中执行的遗留的生命周期函数从版本16.3开始被标记为UNSAFE。现在再文档中它们被称为遗留的生命周期函数。它们将在16.x发行版中废弃,对应的没有UNSAFE前缀的将在17.0中移除。你可以在读到更多关于这些变化和建议的迁移路线。

你对这样做的原因感到好奇吗?

好的,我们刚刚学习了render阶段不会产生像DOM更新这样的副作用,React可以异步处理组件更新(甚至可以在多个线程中运行)。然而,这些被标记为UNSAFE被误解和误用。开发人员往往在这些生命周期方法中放入带有副作用的代码,这在新的异步渲染方式中可能引起问题。尽管只有没有UNSAFE前缀的会被移除,它们在即将到来的Concurrent模式(你可以选择退出)中仍然可能引起问题。

下列生命周期函数在commit阶段中执行:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因为执行在同步的commit阶段,所以它们可以包含副作用和访问DOM。

好的,现在我们了解了用于遍历树和执行work的算法的背景知识。让我们更深入些。

Render阶段

协调算法总是从renderRoot函数使用的最顶层HostRootfiber节点开始。然而,React会跳过已经处理了的fiber节点直到它遇到有未完成work的节点。例如,如果你在组件树深层调用setState,React将从顶层开始,但是会快速跳过父级直到它到达调用setState方法的组件。

work循环的主要步骤

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

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

在上面的代码中,nextUnitOfWork保存了来自workInProgress树中有work待完成的fiber节点的引用。当React遍历Fiber树时,它使用这个变量来知道是否存在其他有未完成work的fiber节点。当前节点处理完后,这个变量将包含树中下一个fiber节点的引用或者为null。在这种情况下(译注:nextUnitOfWork=null的情况)React退出work循环并准备提交变化。

有四个主要函数用于遍历树,开始或结束work:

为了演示它们是如何使用的,看看下面遍历fiber树的动画。我在demo中使用这些函数的简化实现。每个函数都接收一个fiber节点来处理,随着React向下遍历树你会看到当前活动的fiber节点发生了变化。你可以在视频中清楚地看出算法是如何从一个分支到其他分支的。它在移动到父节点之前先完成子节点的work。

注意,垂直向下连着的表示兄弟节点,弯着连接的表示子节点,例如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;
}
复制代码

performUnitOfWork函数接收一个workInProgress树中的fiber节点,通过调用beginWork函数开始工作。这个函数将开启一个fiber节点所有执行的活动。为了演示,我们简单的打印出fiber的name表示work已经完成。beginWork函数总是返回一个指向循环中下一个待处理child的指针或者null

如果存在下一个child,它将在workLoop函数中赋值给nextUnitOfWork变量。 但是,如果不存在child,React知道它已经到达分支的末尾,所以它可以完成当前节点。一旦节点完成,它需要执行兄弟节点的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完成work后,会检查是否有兄弟节点。如果有,React退出这个函数并返回指向兄弟节点的指针。它将赋值给nextUnitOfWork变量,React将从这个兄弟节点开始为这个分支执行work。在这个时候,React只完成了之前兄弟节点的work,理解这点很重要。它没有完成父节点的work。只有当所有以子节点开始的分支都完成后,它才完成父节点的work并回到父节点。

你可以从实现中看出,performUnitOfWorkcompleteUnitOfWork主要起到迭代的作用,而主要活动是在beginWorkcompleteWork函数中进行的。在这个系列接下来的文章中,我们将学到当React进入beginWorkcompleteWork函数中时ClickCounter组件和span节点发生了什么。

Commit阶段

这个阶段开始于completeRoot函数.在这个阶段中React更新DOM,调用变更前后生命周期函数。

当React进入这个阶段,它有两颗树和effects链表。一颗树代表当前渲染在屏幕上的状态。然后有颗在render阶段创建的alternate树。在源码中它被称为finishedWorkworkInProgress,代表需要被显示到屏幕上的状态。alternate树和current树类似,通过child和sibling指针连接。

然后,还有一条effects链 —— finishedWork树节点的子集,通过nextEffect指针连接的。记住effect链是render阶段的运行结果。render阶段的目标就是确定哪些节点需要插入、更新或删除 ,以及需要调用哪些组件的生命周期方法。这就是在commit阶段遍历的节点集。

为了调试,current树可通过fiber rootcurrent属性访问。finishedWork树可以通过current树中HostFiber节点的alternate属性访问。

commit阶段的主要函数是commitRoot。 大体上,它做了下面这些事:

  • 在带有Snapshoteffect标记的节点上调用 getSnapshotBeforeUpdate生命周期方法
  • 在带有Deletioneffect标记的节点上调用 componentWillUnmount生命周期方法
  • 执行所有DOM的插入、更新、删除
  • 设置finishedWork树作为current
  • 在带有Placementeffect标记的节点上调用 componentDidMount生命周期方法
  • 在带有Updateeffect标记的节点上调用 componentDidUpdate生命周期方法

在调用变更前方法getSnapshotBeforeUpdate之后,React在树中提交所有副作用。分成两次完成。第一次执行所有DOM(host)的插入、更新、删除,和ref卸载。然后,React将finishedWork树分配给FiberRoot,标记workInProgress树作为current树。这是在commit阶段第一部分之后,第二个部分之前完成的。所以在componentWillUnmount中之前的树仍然是当前的。componentDidMount/Updatefinished树是当前的。在第二部分中React调用其他生命周期方法和ref回调。这些方法作为独立部分执行,因此所有的插入、更新和删除在整颗树中都已被调用。

这是运行上述步骤的函数的大体结构:

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

每个子函数都实现循环遍历effects list并检查effects的类别。当发现effect和该函数作用有关时会应用它。

变更前生命周期方法

例如,这是遍历effects树并检查节点是否有Snapshoteffect的代码:

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方法作为删除过程的一部分。

变更后生命周期方法

commitAllLifecycles是React调用剩下的componentDidUpdatecomponentDidMount生命周期方法的函数。

终于结束了。在评论区中告诉我你觉得这篇文章怎么样或问我问题。查看这个系列的下一篇文章深入理解React中的state和props更新。我计划写更多的文章深入解释调度器,协调过程,以及effects list是如何创建的。我也计划创建个视频,使用这篇文章作基础展示如何调试程序。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享