(译)深刻 React 协调算法 Reconciliation

原文引用:in-depth overview of the new reconciliation algorithm in React
做者:Max Koretskyijavascript


前言

React  是一个用于构建用户界面的 JavaScript  库。它的核心原理是追踪组件中的状态的改变,而后将这些被更新的状态自动刷新到屏幕上。在 React 中有一个过程被称之为 协调 reconciliation 。也就是当咱们调用  setState  方法,或是框架检测到 state or props变化后,便开始从新开始计算比对组件的先后状态,并渲染与之对应的改变的 UI。

React 官方文档提供了对其原理的高阶概述React Element ,生命周期方法,render方法,组件孩子节点的 diff算法的应用等 在 React中所扮演的角色。其中 render 方法所返回的 React elements Tree就是咱们经常提到的  虚拟DOM virtual DOM 。这个术语有助于前期向人们解释 React ,可是它也会形成一些困惑,由于任何 React 的官方文档中都没出现过它。因此在这篇文章中,我将坚决称它为  React elements 树。

除之外,React 还有另一棵内部实例树(组件实例,或 DOM 节点等),它被用来保存 state 状态。从 React16 开始 React 推出了新的内部实例树的实现 以及它的管理算法 统称为 Fiber。 
html

背景知识

下面是一个很是简单的小应用,接下来的整篇文章都将使用到它。一个按钮,点击数字加一,并渲染到屏幕上。java

example.gif

代码:

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 方法中返回了两个元素   button 和 span 。当你快速点击按钮的时候,组件的状态会在内部更新。这时 span 的文本内容也随之更新。node

在 React 协调过程当中有各类各样的工做须要被执行。以上面代码为例,在 React 第一次渲染和随后的 state 更新期间的作了一些事情,大体以下:react

  • 更新  ClickCounter  组件 state  的  count 属性。
  • 检索  ClickCounter  组件的孩子节点并对比他们的 props
  • 更新 span元素。

还有一些在协调阶段执行的工做,如:生命周期方法调用或 refs  更新等。全部这些活动在 Fiber 架构中被总称为 ‘work’‘work’ 的类型一般基于 React Elementtype。 例如,对类组件来讲须要 React 来建立实例,相对于函数组件来讲就没有必要了。众所周知,在 React 中有很的组件类型,类组件,函数组件(无状态组件),宿主组件(DOM),portals等。React Elementtype由 createElement 函数的第一个参数定义。这个函数一般在 render方法中被使用,用于建立上面提到的 React elementsgit

在咱们探索 ‘work’ 和  Fiber  架构算法以前,咱们先熟悉下一在 React 内部使用的数据结构。github

从 React Element 到 Fiber Node

React 中每个组件都有其对应的 UI 呈现,咱们称之为视图,或者也能够说是 render 方法返回的模板。例子 ClickCounter  组件的模板以下:算法

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

React Element

当一个模板被传入到 JSX 编译器,最终会生成 React Element。它其实就是 React 组件的 render 方法返回的实际内容,并非 HTML..,固然咱们也能够不使用 JSX 语法,也能够直接像下面代码示例中展现那样写组件 ,能接受的话,哈哈哈……:json

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

这里调用的  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](https://overreacted.io/why-do-react-elements-have-typeof-property/) 属性,它在后会用到,主要是用来甄别是否为合法有效的 Element。同时咱们可看到了typekey and props三个属性。这里咱们须要要注意一下 React 是如何表示 spanbutton 两个节点的文本内容的,还有 button 节点的 onClick 部分。

对于示例组件 **ClickCounter** 来讲,它自身的 React Element 并无添加任何属性 或者 key

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

Fiber nodes

协调其实是将从组件 render 方法返回的每一个 React Element 合并进入fiber node 树的过程。每个 React Element 都有一个对应的 fiber 节点。不一样于  React Elementfiber 并不会在每次渲染时候都从新建立。它们是可变的数据结构,保存着组件的状态,以及对应实体 DOM 节点的引用,这个下面会讲到。

咱们以前提到过,React 会基于 React Elementtype 属性执行不一样工做。在示例程序中, ClickCounter  组件将调用生命周期函数 和 render 方法,而宿主组件  span  将会改变 DOM 内容。每个 React Element  都会转换为它所对应的 Fiber Node,用于描述接下来要被执行的工做。

也就是说,能够认为 fiber 节点就是描述随后要执行工做的一种数据结构,能够类比于调用栈中的帧 ,换而言之,就是一个工做单元。fiber 架构同时还提供了便捷的方式去追踪调度暂停中断协调进程。

当一个 React Element 第一次被转换为 fiber 节点的时候,React 将会从 React Element 种提取数据子并在在 createFiberFromTypeAndProps 函数中建立一个新的 fiber。随后的更新过程当中 React会复用这个建立的 fiber 节点,并将对应 React Element  被改变数据更新到这个 fiber 节点上。React 也会移除一些 fiber节点,例如:当同层级上对应 key属性改变时,或 render 方法返回的  React Element 没有该 fiber 对应的 Element 对象时,该 fiber 就会被删除。

由于 React 会为每一个获得的 React Element 建立 fiber,这些 fiber 节点被链接起来组成 fiber tree。在咱们的例子中它是这样的:
 

fiberTree.png

全部的 fiber 节点都经过属性 childsibling and return 链接起来。

current 和 workInProgress 变量

在第一次渲染完成后,React 最终生成 fiber tree,它能够理解为渲染出的 UI 界面的在内存中的映射。fiber tree 的引用变量一般是 current。当 React 开始更新操做时,它会又会构建一棵被称为 workInProgress 的新树。workInProgress 接下来将会替换 current变量所引用的旧的 fiber tree,而后随之会被刷新到屏幕上。

全部执行相关更新,删除等操做的 fibers 都来自于  workInProgress 树。当 React 遍历  current 树的时候,会为每一个 fiber node 建立一个备用节点,这些备用节点最终组成整个  workInProgress 树。这些被新建立的 fibers 的数据来自于 render 方法返回的 React Element 。一旦更新操做的工做完成,React 将会拥有一颗随时即可刷新到屏幕的备用树。当  workInProgress 树被刷新到屏幕后,那么它就会变成  current 树。也就是说 current 变量会保存这颗新树的指针。

React  的一个核心原则就是一致性。因此 React 会一次性遍更新全部须要处理的 DOM,不会只显示部分结果。 workInProgress tree 对用户而言相似于一个不可见的草稿。因此 React 会先处理全部的组件内部状态的更新,而后再将它们改变了的部分从新刷新到屏幕上。

在源码中你会看到不少的函数从  current and workInProgress 树 中获取 fiber 节点。每个 fiber 节点在  alternate 域中保存它在另外一棵树中对应节点的引用。也就是说一个来自  current 树的节点会指向  workInProgress 树中相对应的节点,反之亦然。

反作用

咱们能够认为一个 React 组件,它其实就是一个使用 stateprops 来计算 UI 表现形式的函数。像 DOM 或 调用生命周期方法这样的活动都被认为是一个反作用。关于反作用的详情能够查看官方文档

平常开发中,其实不少时候 state  和 props 的更新都会致使反作用的产生。每种反作用的应用其实是一种指定类型的工做,而 fiber 节点是一种方便的机制去追踪反作用,从它被添加直到被应用。每个 fiber node 都有会有与之相关的反作用。它们都被编码在fiber 节点的 effectTag域上。

因此,Fiber中的反作用基本上能够理解为,定义了在更新进程完成后须要对实例执行的具体操做。对宿主组件而言,也就是 DOM ,的这些工做包括 element 的添加,更新,移除。对的类组件来讲可能须要更新 refs 以及调用生命周期方法等。

反作用列表

ReactDOM 更新速度很是快,为了实现这样的性能,它的实现上采用了一些有趣的技术,其中之一就是构建了一个能够高效迭代遍历的线性 fiber node 列表,该列表中全部 fiber node 都是有反作用须要被执行的,对于没有反作用的 fiber node  则没必要浪费宝贵的时间了。

这个列表的目的是标记那些须要执行各类反作用的节点,包括 DOM 更新,删除,生命周期函数调用等。它同时也是  finishedWork 树的子集,列表中的节点之间经过  nextEffect 属性链接。

Dan Abramov 对反作用列表作了一个有趣的比喻,他认为反作用列表像一颗圣诞树,全部的反作用节点被导线绑在一块儿。以下图:

effectslist.png


能够看到,有反作用的节点被都链接在一块儿。当遍历这些节点的时候, React 经过  firstEffect 指针获得列表的起点。因此上面的图也能够理解为这样:

effectsline.png


经过观察,能够发现 React 应用反作用的顺序是从子节点到父节点。

fiber tree 的根节点

每个 React 应用都有一个或多个 DOM 元素做为容器,一般开发中那个 idroot 的元素。示例程序中的容器是 id 为 containerdiv

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


React 会为每个容器建立一个 fiber root 对象。 进入这个地址能够看到它的具体实现。

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

fiber rootReact 保存  fiber tree 引用的地方。也就是 fiber root 节点上的属性  current 。

const hostRootFiberNode = fiberRoot.current
复制代码

fiber tree 起点是一个被称之为 HostRoot  的特殊类型的 fiber node。它在内部被建立,扮演着全部节点的祖先节点的角色。HostRoot 的属性 stateNode 反过来又指向 FiberRoot 。

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

你能够探索你本身应用的中的 fiber tree 经过进入顶层的  fiberRoot ,进而获得  HostRoot 。或者你也能够经过组件实例获得单个 fiber 节点。

compInstance._reactInternalFiber
复制代码

Fiber node 结构

如今让咱们瞥一眼建立的 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 元素

{
    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节点上有不少的域,alternateeffectTag 和 nextEffect  这几个域的用处在以前的部分已经讲解过了,如今让咱们开始研究剩下的这些。

stateNode

用于保存类组件的实例,宿主组件的 DOM 实例等。一般咱们也能够说这个属性是用来保存与该 fiber 相对应的的本地状态 。

type

定义了与该 fiber node 相对应的是一个函数组件仍是一个类组件。若是是一个类组件该属性指向这个类的构造函数。若是是一个 DOM 元素,该属性则是与之相对应的 HTML 标签。使用这个域很容易就能理解与该 fiber 节点相关联的元素是什么。

tag

定义了当前 fiber 的类型。协调算法用它来判断具体须要作什么工做。像以前说的这个工做的类型仍是基于 React Element 的类型。createFiberFromTypeAndProps 函数映射一个 React Element 到与之相对应的 fiber node 类型。在咱们的示例中, ClickCounter 组件的属性 tag 是 1,表示  ClassComponent , span 元素的 tag 是5 ,表示  HostComponent

updateQueue

一个状态更新队列,包括回调 和 DOM 更新。

memoizedState

已经被使用渲染的过的 fiber 状态。也就是当前屏幕上 UI  状态的映射。

memoizedProps

已经使用渲染过的 fiber 属性,也是构成当前屏幕 UI  状态映射的一部分。

pendingProps

保存着最近一次从 render 方法返回的 React Element 中拿到的数据,等待随后被应用到子组件或是 DOM 元素上。

key

相同层级孩子节点惟一标记,能够优化提高 React 对子节点更新,添加,删处的判断效率。与它具体功能相关的官方文档能够看这里。reactjs.org/docs/lists-…

通用算法

React 工做执行大体能够分为两个阶段:rendercommit

render阶段,React 经过调度  setState 或者 React.render 来实现组件更新,同时也会计算出须要被更新的那部分 UI 的状态。若是是是初始化渲染,React 会为 render方法返回的每个 React Element 建立一个 fiber 对象。在随后的更新中,若是当时建立 fiber时对应的 Reatc Element 还存在,那么该 fiber还会被复用,或者更新。这个阶段最后的成果就是在这颗 fiber  树上对标记了出了那些有反作用的 fiebr 节点。在接下来的 commit 阶段就是处理这些被标记节点反作用的节点 ,最后呈现为可视化的 UI。

有一件很重要的事情须要理解,那就是 render 阶段的执行能够是异步的。React 会处理一个或多个 fiber 节点 ,基于可利用的有效时间,若是有效时间用完它就会停下来,亦或者让位于优先级更高的事件,例如:用户点击操做等。随后再次找到它暂停的位置处继续它未完成的工做。但有时,则须要放弃已经执行过的工做,而后从头开始。这些暂停之因此成为多是由于它们执行的工做并不会形成用户可见的改变,像 DOM 更新之类的。相反,接下来的  commit  阶段则都是同步执行的。由于这个阶段全部执行的工做是反作用应用,最主要的DOM 更新,会致使用户可见的改变。这就是为何 React 须要一次性执行完全部反作用的缘由。

调用生命周期方法也是反作用的一部分。一些方法在 render 阶段被调用,剩下的固然在 commit 阶段被调用啦。下面列举了在 render 阶段调用的生命周期方法:

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

从列表中你能够看到,不少在 render 阶段被调用的旧的生命周期方法在 version 16.3  及后面的版本中都被标记上了 UNSAFE。如今在这篇文档中它们被统称为遗留生命周期方法。它们颇有可能会在将来的版本中被移除。

想知道缘由吗?

好吧,其实这其中的缘由与咱们上面学到的知识息息相关,首先咱们知道 render 阶段的更新不会有任何影响用户视觉上可见的的反作用产生,例如: DOM 更新之类的,甚至 React 还会异步的更新组件。然而这些被使用  UNSAFE 标记的生命周期方法常常会被开发者错误的使用,甚至滥用。开发者倾向于将包含反作用的代码放置到这些方法中,这样就可能致使新的异步渲染被触发,例如:在 componentWillUpdate函数中调用 setState 方法就会致使错误产生。甚至程序无限循环,直至奔溃。UNSAFE 标记也是提醒开发者慎重使用

接下来咱们讲讲 commit阶段被调用的生命周期方法:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

由于这些方法的执行的执行都是同步的,因此它们能够包含不少的反作用以及 DOM 操做。

ok,目前咱们已经掌握了足够的背景知识,接下来就能够潜入探究一番, React 用到的遍历和执行相关操做的算法。

Render phase

协调算法的执行老是从顶层的 HostRoot节点开始执行工做,经过调用  renderRoot 方法开始。然而 React 会跳过那些被处理过的 fiber 节点,直到找到还未被处理的节点。例如,若是你在组件树的某一处调用了 setStateReact 会从顶部开始遍历,可是会快速的跳过它的祖先节点,直到找到触发 setState 的组件为止。

循环工做的主要步骤

全部的 fiebr节点都在在循环遍历中被处理。咱们先看看循环算法实现的同步部分:

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

代码中的 nextUnitOfWork 变量保存着从 workInProgress 树中取出须要被处理的 fiebr 节点的引用。当 React 遍历这个 Fiber树的时候,它可使用这个变量的引用来不断检索遍历到剩下还未被处理的 fiber节点。在当前的 fiber 节点被处理后,这个变量随后会指向下一个要被处理的 fiber 节点的引用,存在的话。不然为 null

在遍历树和初始化的过程当中重要用的的 4 个方法:

为了说明他们是如何被使用的,请看下面的 fiber 树遍历的示意动画:

workloop.gif

注意:垂直方向上节点之间经过 siblings 属性链接,折线处表示链接孩子节点,经过 children 属性。

让咱们首先从 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

若是存在下一个孩子节点,那么它将在循环中被赋值给  nextUnitOfWork 变量。然而要是返回了 null,这时 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 就会进入这个函数。当完成当前遍历到的孩子节点的工做后,React 就检查是否有兄弟节点,若是有 React 会退出这个函数,并返回该节点兄弟节点的指针。它一样会被赋值给 nextUnitOfWork 变量,节点来 React 会重复上面的过程,直至整棵子树被遍历处理完成。当子节点以及子节点的孩子节点都被处理完成后,回溯至父节点,再重复循环父节点的兄弟节点,直至整棵树被遍历完成,最终返回到根节点  fiberRoot

Commit phase

这个阶段开始于  completeRoot  函数。在这个里 React会更新 DOM ,调用相关生命周期方法。

当 React 进入这个阶段,它有两棵树和反作用列表。第一棵树就是是当前已经刷新到屏幕上 UI 对应的状态。另外一颗备用树就是在 render 阶段构建的,在源码中它一般称之为finishedWork 或  workInProgress ,在接下来的 commit 阶段会替换以前的旧树,将新的状态刷新到屏幕上。

finishedWork 树的上经过 nextEffect 指针链接的 fiber节点构成反作用列表。_反作用列表能够看作是 render阶段运行产生的成果。_渲染的意义就是去决定节点的插入,更新,删除,或是组件生命周期函数的调用。这些就是反作用列表将要告诉咱们的,也是接下来提交阶段须要遍历的节点集合。

在提交阶段运行的主函数是 commitRoot,基本上它作了以下工做:

  • 在标记 Snapshot  反作用的节点上调用 getSnapshotBeforeUpdate 生命周期方法。
  • 在标记了 Deletion  反作用的节点上调用 componentWillUnmount 生命周期方法。
  • 执行全部的 DOM 插入,更新,删除操做。
  • 让 current指针指向  finishedWork 树。
  • 在标记了 Placement 反作用的组件节点上调用 componentDidMount 生命周期方法。
  • 在标记了 Update 反作用的组件节点上调用 componentDidUpdate 生命周期方法。

在 getSnapshotBeforeUpdate  调用后,React 会提交整棵树的全部反作用。整个过程分为两步。第一步执行 DOM 插入,更新,删除,ref 的卸载。接下来 ReactfinishedWork 赋值给 FiberRoot ,并标记 workInProgress 树为  current 树。这样作的缘由是,第一步至关因而 componentWillUnmount 阶段,current指向以前的树,而接下里的第二步则至关因而 componentDidMount/Update 阶段,current要指向新树。

上面描述的主要执行函数:

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 更新

React 执行DOM 更新使的是  commitAllHostEffects  函数。

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

很是有趣,在 commitDeletion  函数中 React 调用 componentWillUnmount  是方法做为删除处理的一部分。

剩余生命周期方法

commitAllLifecycles 函数中 React 调用了全部剩下的生命周期方法,componentDidUpdate , componentDidMount

总结

译者添加

协调过程实际就是遍历整个 Fiber 树的时候,经过从 React Element 中获取到的改变后的数据,而后将这些数据更新到其所对应的 fiber 节点上,不存在对应的fiber 节点时,则建立新的,而后经过数据计算判断出该 fiber 节点在 commit 阶段须要作的事情,添加上对应的 effectTag ,同时该节点也会被添加到反作用列表中。在遍历完成以后会生成一棵新Fiber 树,该树中的 fiber 节点一些是新建立的,一些则是复用 old fiber tree 中的,具体状况取决于返回的 React Element 。在 commit 阶段就是遍历反作用列表并执行 effectTag 标记的工做。

相关文章
相关标签/搜索