(译)Fiber 架构之于 React 的意义

1,827 阅读11分钟

原文地址

背景知识

Fiber 架构主要有两个阶段:reconciliation / render(协调/渲染) 和  commit(提交阶段)。在源代码中  reconciliation 阶段通常被划归为 render阶段。 这个阶段 React遍历组件树执行一下工作:

  • 更新 stateprops
  • 调用生命周期钩子
  • 父级组件中通过调用 render 方法,获取子组件 (类组件),函数组件则直接调用获取
  • 将得到的子组件与之前已经渲染的组件比较,也就是 diff
  • 计算出需要被更新的 DOM

上述所有活动都涉及到 Fiber 的内部工作机制。具体工作的区分执行则基于 React Element 的类型。例如,对一个类组件 Class Component 来说,React需要去实例化这个类(也就是 new Component(props)),然而对一个函数组件  Function Component 来说则没有必要。 如果感兴趣,在这里可以看到 Fiber中定义的所有工作目标类型。这些活动正是安德鲁在演讲中所提到了的。

When dealing with UIs, the problem is that if too much work is executed all at once, it can cause animations to drop frames… 当处理UIs时,问题是如果大量的工作一次性执行,那么就可能导致动画掉帧……

那么,关于 ‘all at once’ 的部分指的是什么呢?基本上可以这样认为,如果 React 同步遍历整个组件树,那么它需要为每个组件执行对应的数据渲染更新等工作。于是当组件树较大时就会造成这部分代码的执行时间超过 16ms ,进而导致浏览器画质渲染掉帧,肉眼可见的卡顿感。

那怎么办呢?

Newer browsers (and React Native) implement APIs that help address this exact problem… 相对较新的浏览器都实现了一个新的API ,它可以帮助解决这个问题……

他提到的这个新的 API 是一个名字叫  requestIdleCallback 的全局方法,它可以在浏览器空闲时间段内调用函数队列。可以这样使用它:

requestIdleCallback((deadline)=>{
    console.log(deadline.timeRemaining(), deadline.didTimeout)
});

如果你现在打开浏览器并还行上面的代码,Chrome  的日志将会打印  49.885000000000005 false 。基本上可以认为是浏览器告诉你,你现在有 49.885000000000005 ms 可以做任何你需要去做事情,并且时间还没有用完。反之deadline.didTimeout 则是 true 。请记住, 一旦浏览器开始一些工作后,timeRemaining 就随时被改变 。

requestIdleCallback is actually a bit too restrictive and is not executed often enough to implement smooth UI rendering, so React team had to implement their own version._ 实时上 requestIdleCallback 的限制太多,不能被多次连续的执行来实现流畅的UI渲染,所以 React 团队被迫实现一个自己的版本。

现在如果我们将 React 在一个组件上需要执行的所有活动都集中在  performWork函数中,那么如果使用   requestIdleCallback 处理调度工作的话,我们的代码应该是这样的:

requestIdleCallback((deadline) => {
    // while we have time, perform work for a part of the components tree
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
        nextComponent = performWork(nextComponent);
    }
});

我们在一个组件上执行相关工作,完成后会返回下一个将要被处理组件的引用。这也就是安德鲁之前讨论过的问题:

in order to use those APIs, you need a way to break rendering work into incremental units 为了使用这些APIs,你需要一种方式将渲染工作分割为一个个的增量单元。

为了解决这个问题,React 被迫重新实现了一个新的算法,从基于内部栈调用的同步递归模型转变为基于链表指针异步模型。关于这部分安德鲁曾写过:

If you rely only on the [built-in] call stack, it will keep doing work until the stack is empty…Wouldn’t it be great if we could interrupt the call stack at will and manipulate stack frames manually? That’s the purpose of React Fiber. Fiber is re-implementation of the stack, specialized for React components You can think of a single fiber as a virtual stack frame. 如果你依赖于内部调用栈,那么它将一直工作直到栈被清空……,如果我们可以暂停调用栈,并且去改变它,这样岂不是很棒。这其实就是 Fiber 的最终目的。Fiber 是一个重新实现的栈,主要针对 React 组件。你甚至可以吧单个 fiber 看做是一个栈的虚拟帧。

这将是我接下来要解释的。

关于栈的一些知识

我猜你们对栈概念应该不陌生。如果在代码中打上断点,在浏览器中运行,这时你们将看到它。这里有一段来自维基百科的相关描述:

In computer science, a call stack is a stack data structure that stores information about the active subroutines of a computer program… the main reason for having call stack is to keep track of the point to which each active subroutine should return control when it finishes executing… A call stack is composed of stack frames… Each stack frame corresponds to a call to a subroutine which has not yet terminated with a return. For example, if a subroutine named DrawLine is currently running, having been called by a subroutine DrawSquare, the top part of the call stack might be laid out like in the adjacent picture. 在计算机科学中,一个调用栈其实就是一个存储着计算机程序中的活动子程序相关信息的数据结构……,主要原因是调用栈可以追踪活动子程序在完成执行后返回的控制位置……,一个调用栈由多个栈数据帧组成……,每个数据帧对应一个还没有执行结束的子程序的调用。例如,一个叫 DrawLine 的子程序正在运行,它是之前被子程序DrawSquare 所调用,于是这个栈顶的结构类似于下面这张图片。

_

stack.png


栈 和 React 到底什么关系

像我们文章之前提到了,React 在遍历整个组件树期间需要为组件执行相关更新对比等操作。React 之前所实现的协调器使用的是基于浏览器内部栈同步递归模型。这里有官方提供的文档对这部分的描述以及递归相关的讨论:

默认情况下,当递归一个 DOM 节点的孩子节点的时候,React 将会同时遍历两个孩子列表,当有任何不同的时候就会生成新的改变后的孩子节点。

仔细想想,每一次的递归调用都会添加一个数据帧到栈中。而且这整个过程都是同步的。假如我们有下面的组件树:

componentTree.png

使用对象来代替 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 需要迭代这个组件树,并且为每个组件执行一些工作。为了简单起见,这部分工作被设计在日志中打印当前组件的名称和检索它的孩子组件。

递归遍历

迭代这个这棵树的主函数叫 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 会遍历整个组件树直到栈被清空为止。

so,那么 React 在不使用递归的情况下如何实现遍历算法呢?实际上它使用了一种单链表遍历算法,它让遍历暂停成为一种可能。

链表遍历

为了实现这个算法,我们需要一种数据结构,它包含一下 3 个  fields

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

React 中新的协调算法的上下文就是一种被称为 Fiber 的数据结构,它必须包含上面提到的 3 个 fields。它的底层实现是通过 React Element 提供的数据来创建一个一个的 Fiber 节点。

下面的图表展示了 fiebr node 连接起来的层次结构,通过链表和之间的属性相互连接:

fiber.png


现在我们开始定义自己的 Node 结构。

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

下面这个函数是拿到一个 nodes 数组,然后将他们连接起来。我们要用它去连接被 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;
}

这个函数会从数组的最后一个元素开始迭代并将他们连接起来形成一个单链表。它会返回列表中第一个节点的引用。下面的 demo 用来演示它是如何工作的:

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

// the following two statements are true
console.log(child.instance.name === 'b1');
console.log(child.sibling.instance === children[1]);


这里我们还要实现一个工具函数,它可协助节点执行一些工作。在我们的例子中,它将在日志中打印输出组件的名字。除此之外它还会取出该组件的孩子组件并将它们 连接起来:

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

ok,现在我们可以实现核心遍历算法了。它本质上是一个深度优先算法:

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

    while (true) {
        // 为当前的 node 执行一些工作,并将它与其孩子节点连接起来,并返回第一个孩子节点引用
        let child = doWork(current);

        // 如果孩子节点存在,则将它设置为接下来 被 doWork 方法处理的 node
        if (child) {
            current = child;
            continue;
        }

        // 如果已经返回到更节点说明遍历完成,则直接退出
        if (current === root) {
            return;
        }

        // 当前节点的兄弟节点不存在的时候,返回到父节点,继续寻找父节点的兄弟节点,以此类推
        while (!current.sibling) {

            // 如果已经返回到更节点说明遍历完成,则直接退出
            if (!current.return || current.return === root) {
                return;
            }

            // current 指向父节点
            current = current.return;
        }
        
		// current 指向兄弟节点
        current = current.sibling;
    }
}

如果我们查看上面的算法的实现在调用栈中的情况,类似于下图:

callstack.gif

想必你也看到了,遍历整个组件树的时候我栈并没有叠加。但是如果我们在 doWork 函数中 加上 debugger  的时候,我们会看到下面的过程:

stacklog.gif

它几乎和浏览起得调用栈相同。所以使用我们自己实现的这个算法可以有效的代替浏览器实现的调用栈。这其实就是安德鲁所描述的:

对 React 组件来说 ,Fiber 是栈的重新实现。你甚至可以认为单个 Fiber 就是一个虚拟栈 frame。

我们通过保留 node 的引用(栈顶),来控制整个栈。

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);
        }
    }
}

你可以看到,它的实现很好的对应了上面讲到的算法。它总是将 current Fiber Node 的引用保存到 nextUnitOfWork 变量中,用来扮演栈顶。

通常在出现交互式 UI 更新的情况下, 如(click,input,等等)时,该算法会同步遍历整个组件树,为每个 Fiber Node 执行相关工作。当然它也可以异步遍历,正在执行 Fiber Node 完成后,会检查是否还有时间剩余。shouldYield 函数基于 deadlineDidExpire 和 deadline 这两个变量计算并返回 true or false ,用于决定是否暂停遍历。这两个变量在 React 遍历执行工作过程中也是不断被更新改变的。

总结

译者添加

Fiber 架构对于 React 简单的来说可以理解为让 React 摆脱了浏览器栈的约束,可以根据浏览器的空闲状态选择是否执行渲染工作,就整体渲染时间来说并没有缩短,反而是拉长了,整个渲染工作被划分为多个过程,这些过程都分散在浏览器的各个空闲时间段内,就 UI 而言,对用户来说视觉上会更加流畅。