深入React Fiber架构的reconciliation 算法

3,070 阅读30分钟

本文为意译和整理,如有误导,请放弃阅读。 原文

前言

本文将会带你深入学习React的新架构-Fiber和新的reconciliation 算法的两个阶段。我们将会深入探讨一下React更新state和props,处理子组件的具体细节。

正文

React是一个用于构建用户界面的javascript类库。它的核心机制是对组件状态进行变更检测,然后把(存在内存中的)已更新的状态投射到用户界面上。在React中,这个核心机制被称之为reconciliation。我们调用setState方法,然后React负责去检测state或者props是否已经发生变更,最后把组件重新渲染在屏幕上。

React文档为这个机制给出了一个很高质量的阐述。react element在这里的角色,生命周期函数,render方法和应用到子组件身上的diffing算法等方面的阐述一应俱全。从组件的render方法返回的不可变的react element树被大众称为“virtual DOM”。在React发展的早期,这个概念有助于React团队向人们去解释React的运行原理,但是到了后来,人们发现这个概念过于模糊,容易让人产生歧义。故,在React文档中不再使用过这个概念了。本文中,我坚持把它叫回“react element tree”(译者注:react element tree从另外一个角度来看,它也是一个react element)。

除了这个react element tree之外,React在内部也维持着一颗叫做“internal instance tree”(这个instance又对应着component实例或者DOM对象)。这颗树是用于保存整个应用的状态的。从React的v16.0.0开始,React对这个“internal instance tree”作了一个新的实现。而用于管理这颗树的算法就是如雷贯耳的Fiber。如果你想要了解Fiber架构所带来的好处,请戳这里:在Fiber架构中,React为什么使用和如何使用单链表

这是【深入xxx】系列中的第一篇文章,它志帮助你去了解React的内部架构。在本文中,我将会深入讲解一些跟算法相关的,重要的概念和数据结构。一旦我们掌握了这些概念和数据结构,我们就可以探索整个算法和一些遍历,处理fiber node tree过程中所用到的主要函数。在这个系列中的下一篇文章中,我将会阐述React是如何应用这个算法去完成界面的的初始渲染和处理state,props的更新。后续,我们将会继续探索scheduler的实现细节,child reconciliation 的处理流程和构建effect list的机制。

我会向你输出一些真正高级的知识?是的。我鼓励你去阅读这系列的文章,去了解React Concurrent特性的背后的魔法。我坚信逆向工程(reverse-engineering)的好处,所以,我会给出很多能够跳转到Reactv16.6.0源码的链接。

整篇文章下来,要接受的东西确实太多了。所以,在你不能马上理解一个东西的时候,千万不要焦虑。你需要给点耐心,花点时间去理解它,因为这都是十分值得的。注意,你不需要掌握任何React在应用实践方面的知识。这篇文章主要是讲React的内部运行原理

背景交代

我将会用一个简单的应用示例贯穿整个系列。这个示例是这样的:我们有一个button,通过点击这个button,我们可以增加界面上一个数字的值,如下图:

下面是它的实现代码:

lass 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>
        ]
    }
}

你可以在这里玩玩它。正如你看到的代码那样,ClickCounter是一个简单的组件。这个组件有两个子元素:buttonspan。它们都是从ClickCounter组件的render方法中返回的。当你点击界面上的button的时候,内部的事件处理器就会更新组件的状态。从而最终导致span元素更新它的文本内容。

React在reconciliation过程中会执行各种各样的activity。比如说,在本示例中,以下就是React在首次渲染和在状态更新后会执行的主要操作:

  • 更新ClickCounter组件的state对象的count属性;
  • 获取(通过调用render方法来获取)和比对ClickCounter最新的children和它们的props;
  • 更新span元素的textContent属性值。

除此之外,在reconciliation期间,React还有很多别的activity要执行。比如说,调用生命周期函数啊,更新refs啊等等。所有的这些activity在Fiber架构中都被称之为“work”。不同类型的react element(react element的类型靠其对象的“type”字段来指示)一般有着不同类型的work。比方说,对于class component而言,React需要创建它的实例对象。而对于functional component而言,React不需要这么做。正如你所知道的那样,在React中,我们有着各种类型的react element。比如说,我们有class component,functional component, host component和portal等等。react element的类型是由我们调用createElement函数时所传递进入的第一个参数所决定的。而createElement函数就是组件render方法用于创建react element的那个函数。

在我们开始探索各种各种的work和fiber架构的主要算法之前,让我们先来熟悉熟悉React内部所用到的数据结构。

从react element到fiber node

React中的每一个组件都有一个相应的UI representation。它们就是从组件的render函数返回的东西。我们姑且称之为“view”或者“template”。下面就是我们示例ClickCounter组件的“template”:

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

react element

一旦一个template被JSX compiler编译过后,我们将会得到大量的react element。

更严谨点说,template被JSX compiler编译过后是先得到一个被wrap到render方法里面函数调用。只有render方法被调用了,我们才能得得到的element。

而这些react element才是组件render方法所返回的真正的东西。本质上来说,render方法所返回的东西既不是HTML标签,也不是“template”,而是一个【返回react element的】函数调用。“template”,“HTML标签”或者更严格得说“JSX”只是外在模样,我们根本不需要用它们来表示。下面是用纯JavaScript来重写的ClickCounter组件:

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

ClickCounter组件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为这些js对象都添加上了一个【用于唯一标识它们是react element的】属性:?typeof。除此之外,我们还有用于描述react element的属性:typekeyprops。这些属性值都是来自于你调用React.createElement函数时传入的参数。值得关注的是,React是如何表示span和button节点的文本类型的子节点的,click事件处理器是如何成为props的一部分的。当然,react element身上还有一些其他的属性,比如“ref”字段。不过,它们不在本文的讨论范围内。

ClickCounter组件所对应的react element没有任何的props和key:

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

fiber node

在reconciliation(发音:【ˌrekənsɪliˈeɪʃn】)期间,组件render方法所返回的react element身上的数据都会被合并到对应的fiber node身上,这些fiber node也因此组成了一颗与react element tree相对应的fiber node tree。 需要牢牢记住的是,每一个react element都会有一个与之对应的fiber node。跟react elment不一样的是,fiber node不需要在每一次界面更新的时候都重新创建一遍(译者注:react element恰恰相反。每一次都会被重新创建一遍)。fiber node是一种用于保存组件状态和DOM对象的可变数据结构

我们先前提到过,针对不同类型的react element,React需要执行不同的activity。在我们这个示例中,对于ClickCounter而言,这个activity就是调用它的生命周期方法和render方法。而对于span这个host component而言,这个activity就是执行DOM操作。每个react element都会被转换成一个相应类型的fiber node。不同类型的work标志着不同类型的fiber node。

你可以把fiber node看做一种普通的数据结构。只不过,这种数据结构代表的是某种需要去完成的work。也可以换句话说,一个fiber node表示一个work单元。Fiber架构也同时提供了一种便利的方式去追踪,调度,暂停和中断work。

createFiberFromTypeAndProps函数中,React利用了来自于react element身上的数据完成了对从react element到fiber node的首次转换。在随后的更新中,对于一些依旧存在的react element而言,React不会重新创建而是复用之前的fiber node。React仅仅在必要的时候,利用其对应的react element身上的数据去更新fiber node身上的相关属性。同时,React也需要根据key属性去调整fiber node在树上的层级或者当render方法不再返回该fiber node所对应的react element的时候把这个fiber node删除掉。

你可以在 ChildReconciler 函数中看到所有的activity和用于处理已存在的fiber node的相关函数。

在没引入fiber架构之前,我们已经存在一颗叫react element tree的东西。在引入fiber架构后,因为React会为每一个react element去创建一个与之相对应的fiber node。所以,我们也就有了一颗与react element tree相对应的fiber node tree。在我们这个 示例中,这颗fiber node tree长这样的:

正如你说见的那样,所有的fiber node通过childsiblingreturn链成了一个linked list。如果你想知道为什么这种设计是行得通的,你可以阅读我的另外一篇文章 The how and why on React’s usage of linked list in Fiber

Current tree和 workInProgress tree

在application的首次渲染之后,React会生成一整颗的fiber node tree用于保存【那些已经用于渲染用户界面的】状态(也就是react组件的state)。这颗树一般被称之为current tree。当React进入更新流程后,它又构建了另外一颗叫做workInProgress tree的节点树。这颗树保存着那些即将会被flush到用户界面的应用状态。

所有在workInProgress tree上的fiber node的work都会被执行完成。当React遍历current tree的时候,它会为这颗树上的每一个fiber node创建一个称之为alternate fiber node的节点。正是这种节点组成了workInProgress tree(译者注:也就是说,workInProgress tree就是alternate fiber node tree)。React通过利用render方法所返回的react element身上的数据来创建了alternate fiber node。一旦所有的更新请求(调用setState一次可以视为一次更新请求)都被处理完毕,所有的work也执行完毕的话,那么React就产出了一颗用于将所有变更flush到用户界面的alternate fiber node tree。在将这颗alternate fiber node tree映射到用户界面后,它就变成了current tree

React的核心准则之一是:一致性(consistency)。React总是一口气地完成DOM更新。这就意味着它不会向用户展示更新到一半的用户界面。workInProgress tree作为一个“draft”而被用于React的内部,用户是看不到的。所以,React能够先处理完所有的组件,最后,才把需要变更的东西flush到用户界面上。

在React的源码中,你会看到很多的函数实现都是从current treeworkInProgress tree同时读取它们的fiber node。下面,就是一个这样的函数的签名:

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

current treeworkInProgress tree上的对等的fiber node都会通过一个alternate字段值来保存着一个【指向其对等fiber node的】引用。(译者注:两者处于一种循环引用的状态,伪代码表示如下:)

 const currentNode = {};
 const workInProgressNode = {};
 
 currentNode.alternate = workInProgressNode;
 workInProgressNode.alternate = currentNode;
 

Side-effects

我们可以把React component理解成一个接受state和props作为输入,最终计算出一个UI representation的函数。所有其他的activity,例如:更改DOM结构,调用组件的生命周期方法等等我们都可以考虑将其称为“side-effect”,或者简称为“effect”。在React的这篇官方文档中也提到effect的定义:

你之前很有可能做过类似于data fetching, subscription或者手动更改DOM结构诸如此类的操作。因为这些操作会影响到其他组件,同时它们都不能在rendering期间(译者注:此处的“rendering期间”就是“render阶段”)去完成的。所以,我们将这些操作称之为“side effect”(或者简称为effect)

你将会看到大部分的state和props的更新都会导致side effect的产生。而又因为应用effect也是一种类型的work。所以,fiber node是一种很好的【,用于去追踪除了update之外的effect的】机制。每一个fiber node都可以带有effect。effect是通过fiber node的effectTag字段值来指示的。

所以可以这么说,一个fiber node被更新流程处理过后,它的effect基本上就定义这个fiber node【需要为对应组件实例所要做的】work。具体点说,对于host component(DOM element)而言,它们的work可以包含“增加DOM元素”,“修改DOM元素”和“删除DOM元素”等。而对于class component而言,它们的work可以包含“update refs”,“调用componentDidMount和componentDidUpdate生命周期函数”。对于其他类型的fiber node而言,还有其他的work存在。

Effects list

React处理更新流程的速度非常快。为了实现这个性能目标,React应用了几个有趣的技巧,其中之一就是:为了加快迭代的速度,React为那些带有effect的fiber node构建了一个linear list。其中的原因是因为迭代linear list比迭代一颗tree的速度要快得多。对于那些没带有effect的fiber node,我们更没有必要花时间去迭代它。

这个linear list的目标把需要进行DOM操作或者有其他effect相关联的fiber node标记出来。跟current treeworkInProgress tree中的fiber node是通过child字段将彼此链接一起不同,这个linear list中的fiber node通过自身的nextEffect字段来把彼此链接起来的。它是finishedWork tree的子集。

Dan Abramov 曾经对“effect list”做个一个类比。他把fiber node tree比喻成一棵圣诞树。而圣诞树上把小灯饰连接起来,缠绕着圣诞树的那些电线就是我们的“effect list”。为了可视化去理解它,让我们一起想象下面这颗fiber node tree中的颜色高亮的节点是带有work的。举个例子,我们的更新流程将会导致c2被插入打DOM中,d2c1将会改变自身的attribute,b2将会调用自身的生命周期方法等等。那么,这颗fiber node tree的effct list将会把这些节点连接到一块,这样在,React在遍历的时候能够做到跳过其他的fiber node:

从上面的图示,我们可以看到带有effect的fiber node是如何链接到一块的。当React遍历这些节点的时候,React会使用firstEffect指针来指示出list的第一个元素。所以,上面的图示可以精简为以下的图示:

Root of the fiber tree

每一个React应用都有一个或者多个DOM元素充当着container的角色。在本示例中,有着id值为“container”的div元素就是这种container。

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 node tree引用的地方。fiber root是通过currnet的属性值来保存这个引用的:

const hostRootFiberNode = fiberRoot.current

fiber node tree以一个特殊类型的fiber node开头。这个fiber node被称之为HostRoot。它是React内部创建的,被当做是你最顶层组件的父节点。同时,通过HostRootstateNode字段,我们可以回溯到FiberRoot身上来:

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

你可以通过访问最顶层的HostRoot来探索整一颗fiber node tree。又或者,你可以通过访问一个组件实例的_reactInternalFiber属性来访问某一个单独的fiber node:

const fiberNode = compInstance._reactInternalFiber

Fiber node structure

下面,让我们一起来看看,本示例中ClickCounter组件的fiber node是长什么样的吧:

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

和span DOM元素的fiber node:

{
    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 node这种数据结构身上还是挺多字段的。我已经在先前的章节中解释过alternateeffectTagnextEffect这几个字段的用途了。下面,我们来看看为啥还需要其他字段。

stateNode

这个字段保存着组件实例,DOM对象等react element type的引用。 一般情况下,我们可以说这个字段保存着fiber node所关联的local state。

type

这个字段定义了与当前fiber node相关的function或者class。对于class component而言,该字段值就是这个class的constructor函数。而对于DOM元素而言,该字段值就是这个DOM元素所对应的就是字符串类型的HTML标签名。我经常用这个字段去理解一个fiber node所关联的react element是什么样子的。

tag

这个字段定义了fiber node的类型。在reconciliation算法中,它被用于决定一个fiber node所需要完成的work是什么。早前我们提到过,work的具体内容是由react element的类型(也就是type字段)来决定。createFiberFromTypeAndProps函数负责将某个类型的react element转换为对应类型的fiber node。具体落实到本示例中,ClickCounterfiber node的tag值是“1”,这就代表着这个fiber node所对应的react element是ClassComponent类型 。对于span fier node而言,它的tag值是“5”。这就代表着它所对应的react element是HostComponent类型。

updateQueue

A queue of state updates, callbacks and DOM updates.

memoizedState

已经被用于创建output的fiber node state。如果我们当前是在处理流程中, 那么fiber node的该字段保存的就是那些已经被渲染到用户界面的状态。

memoizedProps

已经被用来在上一次渲染期间创建output的fiber node props。

pendingProps

当前渲染期间,已经被更新的了,等待被应用到child component或者DOM元素身上的fiber node props。fiber node props的更新是通过利用来自于react element中的数据来完成的。

key

唯一标识一个children列表中的每一个item。它被用于帮助React去计算出哪个item被更改了,哪个item是新添加过来,哪个item被移除了。React在这篇General algorithm文档中对它(指的是key字段)更加具体的作用作出了很好的阐述。

小结

你可以在这里找到fiber node完整数据结构的说明。在本文中,我已经跳过了很多的字段了。需要特别提到的是,本文中没有提及的,用于将各个fiber node链接成树状结构的childsiblingreturn字段,其实我已经在先前的这篇文章中说明过了。而归属于其他分类的字段,比如,expirationTimechildExpirationTimemode等字段都是跟Scheduler相关的。

General algorithm(通用算法)

React执行work的过程分为两个阶段:render阶段commit阶段。

在render阶段,当用户调用setState()或者React.render()方法的时候,React就会对组件实施更新操作,然后计算出整个用户界面中需要更新的地方。如果是组件初次挂载的render阶段,React会为每一个【从组件render方法返回的】react element创建与之对应的fiber node。在下一次的render阶段里面,如果某个fiber node所对应的react element还存在的话(译者注:在每一次更新中,都会调用render方法。拿返回的react element跟之前的react element判断,就知道该react element是否还存在),那么这个fiber node将会得到复用和更新。render阶段的最终目的是产出一颗标记好effect(是否带有effect,effect的类型是什么)的fiber node tree。一个fiber node的effec字段是用于描述在接下来的commit阶段,这个fiber node需要完成的work有哪些。在这个commit阶段,React接收一棵标记好effect的fiber node tree作为输入,然后将它应用到其对应的实例上。具体点来说,就是遍历effect list,根据相应的effect去执行相应的work:DOM更新或者其他结果对用户可见的操作。

需要明白的一点是,render阶段的work的执行可以是异步的。取决于可用的时间,React可以处理一个或者多个fiber node,需要让步给其他事情的时候,React就暂停处理,将手头上已经完成的work暂存起来。然后,从它上次中断的地方继续执行。也不总是如此,有时候React还是会放弃掉已经完成的work,从头(译者注:这个“头”就是fiber node tree的第一个节点)开始做起。之所以能够暂停执行work是因在render阶段执行的work并不会对用户产生可见的界面效果,比如说,DOM更新就会产生可见的界面效果。于此相反,在接下来的commit阶段总是同步执行的。那是因为这个阶段需要执行的work是会对用户产生可见的界面效果的,所以,React会一口气完成这个流程(指commit阶段)。

“调用生命周期方法”是React需要执行的一种work。其中,一部分的生命周期方法会在render阶段被调用,而另外一部分会在commit阶段调用。下面是render阶段会调用的生命周期方法:

  • [UNSAFE_]componentWillMount (deprecated)
  • [UNSAFE_]componentWillReceiveProps (deprecated)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate (deprecated)
  • render

正如你所看的那样,一些以“UNSAFE”为前缀的,已经被遗弃的(legacy)生命周期方法(Reactv16.3版本引入此更改)会在render阶段执行。现在,这些方法在React的官方文档中都被称之为“遗弃的生命周期方法”。这些方法将会在某个16.x的版本中被废弃(deprecated)。而它们的孪生方法,没有以“UNSAFE”为前缀的那些生命周期方法将会在17.0中被移除(removed)。你可以在这篇文档中看到这方面变动的介绍和API迁移方面的说明。

你是不是对这个变更背后的原因感到好奇呢?

好吧,在这里,我给你说道说道。正如我们前面所说的那样,因为render阶段,React不会产出(produce)effect,比如DOM更新之类的。同时,React能够对组件进行异步的更新(甚至有可能以一种多线程的方式去做)。然而,那些以“UNSAFE”为前缀的生命周期方法在实际生产中经常被误解和误用。开发者经常在这些生命周期方法里面放置一些有(side-)effect的代码。引入fiber架构后,React也为我们带来了异步渲染的方案。如果开发者继续这么做的话,程序是会出问题的。尽管他们的孪生方法(没有以“UNSAFE”为前缀的那些)最终会被移除掉,但是他们依然可能会在未来的Concurrent Mode(你也可以选择不开启Concurrent Mode)特性版本中产生问题。

下面是commit阶段会执行到的生命周期方法:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因为这些方法被执行的阶段是同步执行的,所以,它们可以包含side-effect代码和访问DOM元素。

好吧,讲到这里,我们已经能具备足够的知识储备去理解【被用于遍历fiber node tree和执行work的】generalized algorithm

Render phase

reconciliation总是从fiber node tree最顶端的HostRoot节点开始执行。这个开始动作发生在renderRoot函数里面。然而,React会跳过那些已经处理过的fiber node,只会处理那些有带有未完成work的节点。举个例子说,如果你在组件树的深层去调用setState方法的话,那么React虽然还是会从顶部的节点开始遍历,但是它会跳到前面所有的父节点,径直奔向那个调用了setState方法的子节点。

Main steps of the work loop

所有的fiber node都会在一次的work loop中得到处理。下面是work loop同步执行部分的代码实现:

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

在上面的代码中,nextUnitOfWork保存着一个指向workInProgress tree树上某个还有未完成work的fibe node的引用。当React在这颗fiber node tree上遍历的时候,它就是使用这个变量来判断是否还有别的fiber node需要处理。当前fiber node一旦被处理后,nextUnitOfWork`变量的值要么是指向下一个fiber node,要么就是null。一旦是null的话,React就会退出work loop,然后准备进入commit阶段。

有四个函数是用于遍历fiber node tree,初始化或者完成work的:

它们是如何被使用的呢?让我们看看下面React遍历fiber node tree时的动画。为了演示,我们使用了这些函数的精简版实现。这四个函数都接受一个fiber node作为输入。随着React对这颗树的往下遍历,你可以看到当前active的fiber node(橙色节点代表active的fiber node)在不断地更改。你从这个动画上清晰地看到这个算法是如何从fiber node tree的一个分支切换到另外一个分支的。具体点说就是,先处理完所有children的work再回溯到parent节点(译者注:其实就是深度优先遍历):

请注意,上图中,竖线是代表sibling关系,横折竖线代表着children关系。例如:b1就没有children,而 b1有一个children叫做c1

这里是一个相关的视频链接。在这个视频里面,你可以暂停和回放,然后仔细瞧瞧,当前的fibe node是谁,当前这个函数的状态是怎样的。理论上说,你可以把“begin”理解为“stepping into”一个组件,而“complete”就是从这个组件中“stepping out”。你也可以玩玩这个demo。在这个demo中,我解释了这几个函数具体都做了什么。这个demo的代码如下:

const a1 = {name: 'a1', child: null, sibling: null, return: null};
const b1 = {name: 'b1', child: null, sibling: null, return: null};
const b2 = {name: 'b2', child: null, sibling: null, return: null};
const b3 = {name: 'b3', child: null, sibling: null, return: null};
const c1 = {name: 'c1', child: null, sibling: null, return: null};
const c2 = {name: 'c2', child: null, sibling: null, return: null};
const d1 = {name: 'd1', child: null, sibling: null, return: null};
const d2 = {name: 'd2', child: null, sibling: null, return: null};

a1.child = b1;
b1.sibling = b2;
b2.sibling = b3;
b2.child = c1;
b3.child = c2;
c1.child = d1;
d1.sibling = d2;

b1.return = b2.return = b3.return = a1;
c1.return = b2;
d1.return = d2.return = c1;
c2.return = b3;

let nextUnitOfWork = a1;
workLoop();

function workLoop() {
    while (nextUnitOfWork !== null) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
}

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

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

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 returnFiber.
            workInProgress = returnFiber;
            continue;
        } else {
            // We've reached the root.
            return null;
        }
    }
}

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

function log(message) {
  let node = document.createElement('div');
  node.textContent = message;
  document.body.appendChild(node);
}

下面一起瞧瞧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 tree的fiber node作为输入,通过调用beginWork函数来开始执行work。React将会在beginWork函数里面开始去完成一个fiber node所有需要被完成的work。为了简化演示,我把所需要完成的work假设为:打印当前fiber node的名字。beginWork 函数要么返回一个指向下一个work loop需要处理的fiber node的引用,要么返回null。

如果有下一个待处理的child fiber node的话,那么这个fiber node就会在workLoop函数里面将它赋值给变量nextUnitOfWork。否则的话,React 知道已经触达了当前(fiber node tree)分支的叶子节点了。因此,React可以结束当前fiber node(的work)了。一旦一个fiber node被结束掉,React接着会执行它的sibling节点的work,在完成这个sibling的这个分支后,就会移步到下一个sibling节点.....以此类推。当所有的sibling节点被结束到,React才会回溯到parent节点。。这个过程是发生在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 is no more work in this returnFiber,
            // continue the loop to complete the parent.
            workInProgress = returnFiber;
            continue;
        } else {
            // We have reached the root.
            return null;
        }
    }
}

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

正如你所看到的那样,completeUnitOfWork的函数实现主体是一个大while循环。当workInProgress node没有children的时候,React的执行就会进入这个函数。在完成当前fiber node的work之后,它就紧接着去检查是否还有下一个sibling节点要处理。如果有的话,那么就把下一个sibling的引用返回出去,退出当前函数。所返回的sibling的引用会赋值给nextUnitOfWork变量,然后React就从这个sibling开始新一轮的遍历。值得强调的一点是,上面所提到的这个节骨眼上,React只是完成了先前sibling节点的work,它并没有完成parent节点的work。当一个fiber node自己和其所有子节点分支上的work都被完成了,我们才说这个fiber node的work完成了,然后才往回追溯。

正如你所看到的那样,performUnitOfWorkcompleteUnitOfWork主要是用于对fiber node tree进行迭代。迭代中具体需要干的事都是靠beginWorkcompleteWork去实现的。在本系列中接下里的文章中,我们会了解到随着React执行到beginWorkcompleteWork函数,ClickCounter组件和span组件到底发生了什么。

Commit phase

这个阶段以completeRoot函数开始。在这个函数里面,React完成了DOM更新和对pre-mutaion和post-mutation生命周期方法的调用。

进入commit阶段后,React需要跟三个数据结构对象打交道:两棵tree,一个list。两颗tree分别是指current treeworkInProgress tree(或者称为finishedWork tree),一个list是指effect listcurrent tree代表着当前已经渲染在用户界面的应用的状态。workInProgress tree是React在render阶段构建出来的,视为current tree的alternate。它代表着那些需要被flush到用户界面的应用状态。workInProgress treecurrent tree一样,也是通过fiber node的child和sibling字段将自己链接起来的。

effect list是finishedWork tree的一个子集。它是通过fiber node的nextEffect字段将自己链接起来的。再次提醒你,effect list是render阶段的产物。render阶段所做的一切都是为了计算出哪个节点需要被插入,哪个节点需要被更新,哪个节点需要被移除,哪个组件的生命周期方法需要被调用。这就是effect list能告诉我们的信息。effect list上面的fiber node才真正是commit阶段需要被遍历到的节点。

debug的时候,你可以通过fiber rootcurrent属性值来访问current tree。你可以通过HostFiber节点的alternate属性来访问finishedWork tree上对应的fiber node。详细可以查看Root of the fiber tree这一小节。

commit阶段的主要功能函数是commitRoot。可以这么说,它做了下面这些事:

  • 对带有snapshoteffect的fiber node,调用它的getSnapshotBeforeUpdate生命周期方法
  • 对带有Deletioneffect的fiber node,调用它的componentWillUnmount生命周期方法
  • 执行所有的DOM操作:节点插入,节点更新,节点删除
  • current指针指向finishedWork tree(在javascript里来说,就是引用传递)。
  • 对带有Placementeffect的fiber node,调用它的componentDidMount生命周期方法
  • 对带有Updateeffect的fiber node,调用它的componentDidUpdate生命周期方法

在调用完pre-mutation方法getSnapshotBeforeUpdate后,React会将树上所有的effect commit掉。这个操作又分为两步走。第一步是:执行所有的DOM节点的插入,更新,删除和ref的卸载。然后,React会把finishedWork tree赋值给FiberRoot节点的current属性,以此把finishedWork tree转换为current tree。This is done after the first pass of the commit phase, so that the previous tree is still current during componentWillUnmount, but before the second pass, so that the finished work is current during componentDidMount/Update. 第二步:React调用所有的其他生命周期方法和ref callback。因为这些方法都是在一个独立的步骤里面执行的。到这个时候,树上所有的placements,updates和deletionseffect都已经被应用过了。

下面是commitRoot函数中上面提到两个执行步骤:

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

所有的这些子函数都实现了对effect list的迭代。在迭代的过程中,它们都会检查当前fiber node的effect是否是本函数需要处理类型,如果是,则应用该effect。

Pre-mutation lifecycle methods

以下是一个小示例,里面的代码实现了对effect list的遍历,并且在遍历的过程中去检查当前的fiber node的effect type是否是Snapshot

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

对一个class component而言,“应用这个snapshot”effect“等同于“调用getSnapshotBeforeUpdate生命周期方法”;

DOM updates

React会在commitAllHostEffects 函数里面完成所有的DOM操作。这个函数罗列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方法的调用划分到deletion这一类别中,最终在commitDeletion函数中调用了它。

Post-mutation lifecycle methods

剩下还有componentDidUpdatecomponentDidMount这两个生命周期方法。它们会在commitAllLifecycles 里面被调用。

结束语

最终的最终,我们终于讲完了。如果你想说出你对这篇文章的见解又或者想问问题,欢迎评论评论。同时也欢迎查阅我的下一篇文章:深入react的state和props更新。在我打算写完的这一【深入xxx】系列中,我手头上还有很多正在写的文章。这些文章囊括了“scheduler”,“children reconciliation”和“effects list是如何构建起来的”等方面的主题。同时,我也打算基于本文所讲内容发布一个讲解何如debug的视频,欢迎翘首以盼。