「译」如何以及为何 React Fiber 使用链表遍历组件树

React调度器中工做循环的主要算法

工做循环配图,来自Lin Clark在ReactConf 2017精彩的 演讲

为了教育我本身和社区,我花了不少时间在Web技术逆向工程和写个人发现。在过去的一年里,我主要专一在Angular的源码,发布了网路上最大的Angular出版物—Angular-In-Depth如今我已经把主要精力投入到React中变化检测已经成为我在Angular的专长的主要领域,经过必定的耐心和大量的调试验证,我但愿能很快在React中达到这个水平。 在React中, 变化检测机制一般称为 "协调" 或 "渲染",而Fiber是其最新实现。归功于它的底层架构,它提供能力去实现许多有趣的特性,好比执行非阻塞渲染,根据优先级执行更新,在后台预渲染内容等。这些特性在并发React哲学中被称为时间分片html

除了解决应用程序开发者的实际问题以外,这些机制的内部实现从工程角度来看也具备普遍的吸引力。源码中有如此丰富的知识能够帮助咱们成长为更好的开发者。node

若是你今天谷歌搜索“React Fiber”,你会在搜索结果中看到不少文章。可是除了Andrew Clark的笔记,全部文章都是至关高层次的解读。在本文中,我将参考Andrew Clark的笔记,对Fiber中一些特别重要的概念进行详细说明。一旦咱们完成,你将有足够的知识来理解Lin Clark在ReactConf 2017上的一次很是精彩的演讲中的工做循环配图。这是你须要去看的一次演讲。可是,在你花了一点时间阅读本文以后,它对你来讲会更容易理解。react

这篇文章开启了一个React Fiber内部实现的系列文章。我大约有70%是经过内部实现了解的,此外还看了三篇关于协调和渲染机制的文章。git

让咱们开始吧!github

基础

Fiber的架构有两个主要阶段:协调/渲染和提交。在源码中,协调阶段一般被称为“渲染阶段”。这是React遍历组件树的阶段,而且:web

  • 更新状态和属性
  • 调用生命周期钩子
  • 获取组件的children
  • 将它们与以前的children进行对比
  • 并计算出须要执行的DOM更新

全部这些活动都被称为Fiber内部的工做。 须要完成的工做类型取决于React Element的类型。 例如,对于 Class Component React须要实例化一个类,然而对于Functional Component却不须要。若是有兴趣,在这里 你能够看到Fiber中的全部类型的工做目标。 这些活动正是Andrew在这里谈到的:算法

在处理UI时,问题是若是一次执行太多工做,可能会致使动画丢帧...数组

具体什么是一次执行太多?好吧,基本上,若是React要同步遍历整个组件树并为每一个组件执行任务,它可能会运行超过16毫秒,以便应用程序代码执行其逻辑。这将致使帧丢失,致使不畅的视觉效果。浏览器

那么有好的办法吗?markdown

较新的浏览器(和React Native)实现了有助于解决这个问题的API ...

他提到的新API是requestIdleCallback 全局函数,可用于对函数进行排队,这些函数会在浏览器空闲时被调用。如下是你将如何使用它:

requestIdleCallback((deadline)=>{
    console.log(deadline.timeRemaining(), deadline.didTimeout)
});
复制代码

若是我如今打开控制台并执行上面的代码,Chrome会打印49.9 false。 它基本上告诉我,我有49.9ms去作我须要作的任何工做,而且我尚未用完全部分配的时间,不然deadline.didTimeout 将会是true。请记住timeRemaining可能在浏览器被分配某些工做后当即更改,所以应该不断检查。

requestIdleCallback 实际上有点过于严格,而且执行频次不足以实现流畅的UI渲染,所以React团队必须实现本身的版本

如今,若是咱们将React对组件执行的全部活动放入函数performWork, 并使用requestIdleCallback来安排工做,咱们的代码可能以下所示:

requestIdleCallback((deadline) => {
    // 当咱们有时间时,为组件树的一部分执行工做 
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
        nextComponent = performWork(nextComponent);
    }
});
复制代码

咱们对一个组件执行工做,而后返回要处理的下一个组件的引用。若是不是由于如前面的协调算法实现中所示,你不能同步地处理整个组件树,这将有效。 这就是Andrew在这里谈到的问题:

为了使用这些API,你须要一种方法将渲染工做分解为增量单元

所以,为了解决这个问题,React必须从新实现遍历树的算法,从依赖于内置堆栈的同步递归模型,变为具备链表和指针的异步模型。这就是Andrew在这里写的:

若是你只依赖于[内置]调用堆栈,它将继续工做直到堆栈为空。。。 若是咱们能够随意中断调用堆栈并手动操做堆栈帧,那不是很好吗?这就是React Fiber的目的。 Fiber是堆栈的从新实现,专门用于React组件。 你能够将单个Fiber视为一个虚拟堆栈帧。

这就是我如今将要讲解的内容。

关于堆栈想说的

我假设大家都熟悉调用堆栈的概念。若是你在断点处暂停代码,则能够在浏览器的调试工具中看到这一点。如下是维基百科的一些相关引用和图表:

在计算机科学中,调用堆栈是一种堆栈数据结构,它存储有关计算机程序的活跃子程序的信息...调用堆栈存在的主要缘由是跟踪每一个活跃子程序在完成执行时应该返回控制的位置...调用堆栈由堆栈帧组成...每一个堆栈帧对应于一个还没有返回终止的子例程的调用。例如,若是由子程序DrawSquare调用的一个名为DrawLine的子程序当前正在运行,则调用堆栈的顶部可能会像在下面的图片中同样。

为何堆栈与React相关?

正如咱们在本文的第一部分中所定义的,React在协调/渲染阶段遍历组件树,并为组件执行一些工做。协调器的先前实现使用依赖于内置堆栈的同步递归模型来遍历树。关于协调的官方文档描述了这个过程,并谈了不少关于递归的内容:

默认状况下,当对DOM节点的子节点进行递归时,React会同时迭代两个子节点列表,并在出现差别时生成突变。

若是你考虑一下,每一个递归调用都会向堆栈添加一个帧。而且是同步的。假设咱们有如下组件树:

render函数表示为对象。你能够把它们想象成组件实例:

const a1 = {name: 'a1'};
const b1 = {name: 'b1'};
const b2 = {name: 'b2'};
const b3 = {name: 'b3'};
const c1 = {name: 'c1'};
const c2 = {name: 'c2'};
const d1 = {name: 'd1'};
const d2 = {name: 'd2'};

a1.render = () => [b1, b2, b3];
b1.render = () => [];
b2.render = () => [c1];
b3.render = () => [c2];
c1.render = () => [d1, d2];
c2.render = () => [];
d1.render = () => [];
d2.render = () => [];
复制代码

React须要迭代树并为每一个组件执行工做。为了简化,要作的工做是打印当前组件的名字和获取它的children。下面是咱们如何经过递归来完成它。

递归遍历

循环遍历树的主要函数称为walk,实现以下:

walk(a1);

function walk(instance) {
    doWork(instance);
    const children = instance.render();
    children.forEach(walk);
}

function doWork(o) {
    console.log(o.name);
}
复制代码

这里是个人获得的输出:

a1, b1, b2, c1, d1, d2, b3, c2

若是你对递归没有信心,请查看我关于递归的深刻文章

递归方法直观,很是适合遍历树。可是正如咱们发现的,它有局限性。最大的一点就是咱们没法分解工做为增量单元。咱们不能暂停特定组件的工做并在稍后恢复。经过这种方法,React只能不断迭代直到它处理完全部组件,而且堆栈为空。

那么React如何实现算法在没有递归的状况下遍历树?它使用单链表树遍历算法。它使暂停遍历并阻止堆栈增加成为可能。

链表遍历

我很幸运能找到SebastianMarkbåge在这里概述的算法要点。 要实现该算法,咱们须要一个包含3个字段的数据结构:

  • child — 第一个子节点的引用
  • sibling — 第一个兄弟节点的引用
  • return — 父节点的引用

在React新的协调算法的上下文中,包含这些字段的数据结构称为Fiber。在底层它是一个表明保持工做队列的React Element。更多内容见个人下一篇文章。

下图展现了经过链表连接的对象的层级结构和它们之间的链接类型:

咱们首先定义咱们的自定义节点的构造函数:

class Node {
    constructor(instance) {
        this.instance = instance;
        this.child = null;
        this.sibling = null;
        this.return = null;
    }
}
复制代码

以及获取节点数组并将它们连接在一块儿的函数。咱们将它用于连接render方法返回的子节点:

function link(parent, elements) {
    if (elements === null) elements = [];

    parent.child = elements.reduceRight((previous, current) => {
        const node = new Node(current);
        node.return = parent;
        node.sibling = previous;
        return node;
    }, null);

    return parent.child;
}
复制代码

该函数从最后一个节点开始往前遍历节点数组,将它们连接在一个单独的链表中。它返回第一个兄弟节点的引用。 这是一个如何工做的简单演示:

const children = [{name: 'b1'}, {name: 'b2'}];
const parent = new Node({name: 'a1'});
const child = link(parent, children);

// 下面两行代码的执行结果为true
console.log(child.instance.name === 'b1');
console.log(child.sibling.instance === children[1]);
复制代码

咱们还将实现一个辅助函数,为节点执行一些工做。在咱们的状况是,它将打印组件的名字。但除此以外,它也获取组件的children并将它们连接在一块儿:

function doWork(node) {
    console.log(node.instance.name);
    const children = node.instance.render();
    return link(node, children);
}
复制代码

好的,如今咱们已经准备好实现主要遍历算法了。这是父节点优先,深度优先的实现。这是包含有用注释的代码:

function walk(o) {
    let root = o;
    let current = o;

    while (true) {
        // 为节点执行工做,获取并链接它的children
        let child = doWork(current);

        // 若是child不为空, 将它设置为当前活跃节点
        if (child) {
            current = child;
            continue;
        }

        // 若是咱们回到了根节点,退出函数
        if (current === root) {
            return;
        }

        // 遍历直到咱们发现兄弟节点
        while (!current.sibling) {

            // 若是咱们回到了根节点,退出函数
            if (!current.return || current.return === root) {
                return;
            }

            // 设置父节点为当前活跃节点
            current = current.return;
        }

        // 若是发现兄弟节点,设置兄弟节点为当前活跃节点
        current = current.sibling;
    }
}
复制代码

虽然代码实现并非特别难以理解,但你可能须要稍微运行一下代码才能理解它。在这里作。 思路是保持对当前节点的引用,并在向下遍历树时从新给它赋值,直到咱们到达分支的末尾。而后咱们使用return指针返回根节点。

若是咱们如今检查这个实现的调用堆栈,下图是咱们将会看到的:

正如你所看到的,当咱们向下遍历树时,堆栈不会增加。但若是如今放调试器到doWork函数并打印节点名称,咱们将看到下图:

**它看起来像浏览器中的一个调用堆栈。**因此使用这个算法,咱们就是用咱们的实现有效地替换浏览器的调用堆栈的实现。这就是Andrew在这里所描述的:

Fiber是堆栈的从新实现,专门用于React组件。你能够将单个Fiber视为一个虚拟堆栈帧。

所以咱们如今经过保持对充当顶部堆栈帧的节点的引用来控制堆栈:

function walk(o) {
    let root = o;
    let current = o;

    while (true) {
            ...

            current = child;
            ...

            current = current.return;
            ...

            current = current.sibling;
    }
}
复制代码

咱们能够随时中止遍历并稍后恢复。这正是咱们想要实现的可以使用新的requestIdleCallback API的状况。

React中的工做循环

这是在React中实现工做循环的代码

function workLoop(isYieldy) {
    if (!isYieldy) {
        // Flush work without yielding
        while (nextUnitOfWork !== null) {
            nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        }
    } else {
        // Flush asynchronous work until the deadline runs out of time.
        while (nextUnitOfWork !== null && !shouldYield()) {
            nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        }
    }
}
复制代码

如你所见,它很好地映射到我上面提到的算法。nextUnitOfWork变量做为顶部帧,保留对当前Fiber节点的引用。

该算法能够同步地遍历组件树,并为树中的每一个Fiber点执行工做(nextUnitOfWork)。 这一般是由UI事件(点击,输入等)引发的所谓交互式更新的状况。或者它能够异步地遍历组件树,检查在执行Fiber节点工做后是否还剩下时间。 函数shouldYield返回基于deadlineDidExpiredeadline变量的结果,这些变量在React为Fiber节点执行工做时不停的更新。

这里深刻介绍了peformUnitOfWork函数。


我正在写一系列深刻探讨React中Fiber变化检测算法实现细节的文章。

请继续在TwitterMedium上关注我,我会在文章准备好后当即发tweet。

谢谢阅读!若是你喜欢这篇文章,请点击下面的点赞按钮👏。这对我来讲意义重大,而且能够帮助其余人看到这篇文章。

相关文章
相关标签/搜索