React Fiber 架构简介 ——官方作者文章翻译

2,459 阅读13分钟

介绍

React Fiber是React核心算法的重新实现,这篇文章阐述了React团队在过去两年中的重点研究。

React Fiber的目标是增强它在动画、渲染和执行上的性能。它首要的特性就是逐渐增强的渲染能力:它可以将渲染工作切割成不同的块并且散布在多个帧上。

其他的关键特性包括更新时的暂停、废弃、以及重用;对不同类型的更新赋予权重;并发函数。

关于这篇文档

Fiber介绍了一些单靠看代码理解比较困难的新颖概念。这篇文档一开始只是我跟随Fiber在React中更新的的笔记的收集。当它越来越完备,我意识到这对于其他人来说也是一个很有用的资源。

我将尝试用最简单易懂的语言来阐述,并且通过定义关键术语来避免行话。必要时我也会尽量链接到内部资源。

请注意我不在React团队中,也不代表任何的权威发言。这不是一篇官方文档。我会请React团队的伙伴来验证准确性。

这也是一项进行中的工作。Fiber是一个正在进行并且在它被完成之前未来会有重大重构的项目,我的文章也会持续更新,欢迎大家给我提建议。

我的目标是当你阅读完这篇文档,你就像曾经开发过它一样了解它,并且最终可以给React反馈。

准备工作

我强烈建议你在继续阅读之前了解以下资料:

概览

请先阅读prerequisites部分。

在我们了解新的东西之前,我们先回顾一些概念。

什么是 Reconciliation ?

reconciliation

是React用来对比两个树然后决定树中的哪些部分需要被改变。

更新

数据中的改变用来渲染一个React app。通常是setState的结果。最终结果会触发一次重新渲染。

React的核心思想是考虑那些引起整个App重新渲染的更新,这让开发者们可以声明性地推断,而不是考虑如何高效地将App从一个状态更新到另一个状态。

事实上每一次更改都更新整个App只适用于那些小型项目。在现实世界的App中,是不能在执行上有过多消耗的。React在整个App的重新渲染上增加了很多的优化点来确保它有一个好的表现。这些优化点的其中一块就叫作reconciliation

Reconciliation是在著名的"virtual DOM"算法的背后,一种比较高阶的描述是像这样:当你渲染一个React应用的时候,这个应用的节点树会被继承和保存在缓存中。然后这棵树会刷新到渲染环境——例如,假如有一个浏览器应用,被翻译成了一系列的DOM操作。当App更新的时候(通常是setState操作),一个新的树就会被生成。这个树会与之前的树进行对比计算出在重绘app时哪些操作需要更新。

虽然Fiber是把reconciler重头开始写,但是这些在React文档中描述过的高阶算法会很大程度上相同。其中的关键点是:

  • 不同类型的组件实质上会生成不同的树。React不会尝试去更新它们,而是完全替换掉旧的树。
  • Diff算法是使用key属性值来执行的,key属性值是最“可靠,可预测和独特的。”

Reconciliation与渲染

DOM只是React渲染环境中的一员,其他的主要成员是原生iOS和Android版本的React Native。(这也是为什么“虚拟DOM是一个误称)。

React之所以能够支持这么多的目标群体是因为它在被设计的时候reconciliation和渲染是两个分开的阶段。reconciler用来计算出树中的哪些部分已经改变了,而renderer则拿着这些信息去更新app。

分离意味着React和React Native可以使用它们各自的renderer而共享React core中提供的reconciler。

Fiber重新实现了reconciler。这在原则上与渲染无关,虽然renderer需要这些改变去更新结构。

Scheduling

Scheduling

决定哪一个work将要被执行的过程。

work

哪一个计算必须要被执行,work通常是一次更新的结果。

React's Design Principles文档在这个话题上做了很好地阐释:

在当前更新的React中,它会递归地遍历整个树并且在一次tick中会调用整个更新后的树的渲染函数。然而在未来它也许会延迟一些更新来避免掉帧

这在React设计中是一个共同的主题,一些流行的库使用“推动”的方法,即有新的数据时计算就开始执行。然而React却采用“拉取”的方法,即只有必要时才会执行计算。

React不是一个通用的数据处理库,这是一个用来搭建用户层的库,我们认为在一个app中哪些计算应该被执行,哪些不应该有着独特的地位。

我们会延后处理那些不在当前屏幕上的逻辑,如果数据到来的速度比帧到来的速度快,我们会合并批量处理更新。我们也会将用户的动作提高优先级(比如一个由按钮点击引发的动画),而会将背后不那么重要的动作延后处理(比如渲染刚刚从客户端拿到的内容)去防止掉帧

重点是:

  • 在UI中,并不是每一个更新都要被立即执行:事实上,这样做是一种浪费,会造成掉帧,影响用户体验。
  • 不同的更新有不同的权重——一个动画的更新要比数据的更新更快一些
  • 一个依靠推动的app需要开发者来决定任务调度,一个以拉取为基础的框架,比如React,能够更加聪明地帮助你做这些决定。

当前,React并未充分利用调度的优势,更新的结果会在整个树中被重新渲染,彻底革新React的核心算法以利用调度是Fiber背后的驱动思想。


现在我们可以来探索Fiber的更新。下一部分会比我们之前探讨过得更加专业。在继续之前请确保你已经理解了之前的内容。

什么是Fiber?

我们将去探讨React Fiber架构的核心,Fiber其实比一般应用开发者们认为的抽象度要低得多。如果你在理解它的时候觉得吃力,不要觉得沮丧。持续努力并最终理解它。


我们已经了解了Fiber的基础目标是使React在调度中获利。特别是,我们需要能够:

  • 中断当前任务并且可以返回当前进度
  • 对不同的任务分配不同的权重
  • 复用之前的任务
  • 停止不需要的任务

为了完成这些,我们首先要把这些任务变成一个个小的单元。在一些意义上,什么叫做fiber,就是一个任务中的一个小单元。

为了深入,我们回到React组件作为数据函数的概念,通常被表达为

v = f(d)

它认为渲染一个React app事实上就是一个函数调用另一个函数的过程,这个比喻通常被用来理解fiber。

计算机用来跟踪程序执行的方法叫做调用栈。当一个函数被执行的时候,一个任务就被加入到了一个调用栈中,这个任务就是被那个函数所执行。

当处理UI的时候,问题是很多的任务会被同时执行,者会造成动画掉帧看起来很简陋。更重要的是当它被最近的一次更新取代的时候,这个任务就不那么重要了。这就是在比较UI组件和函数分解,因为组件会比函数有更确切的需求。

最新的浏览器开发的API追踪到了确切的问题:requestIdleCallback会为在空闲时间调用的函数赋予一个低权重,而requestAnimationFrame则会为下一帧的渲染赋予一个高权重。问题是,为了用这些API,你需要一种方法把这个渲染任务分解成更多的单元。如果你依靠堆栈调用,它会一直工作到栈空。

如果我们可以自定义调用堆栈的行为来优化呈现UI,那不是很好。如果我们可以打断栈的调用一会儿,并且手动操作栈碎片的执行不是更好?

这就是React Fiber的目标。Fiber就是一个重写的栈,尤其是对于React组件来说,你可以将一个单一的fiber视作虚拟的stack frame。

重新实现堆栈的好处就是你可以将stack frame保存在缓存中并且在任意时刻执行它。实现我们在调度上的目标是至关重要的。

除了调度,手动处理stack frame也给处理并发和错误的特性预留了空间。我们将在接下来的部分讨论这些话题。

在下一部分我们将着眼于fiber的结构。

fiber 的结构

注意:随着我们对实现细节的更加具体化,某些事情可能发生变化的可能性增加了。 如果发现任何错误或过时的信息,请提交PR。

准确来说,一个fiber就是包含一个组件信息的JavaScript对象,它的输入和输出。

fiber不仅和stack frame有对应关系,也和组件的实例有对应关系。

这里有一些fiber的重要属性

type 和 key

fiber上的type和key对于React元素来说有着同样的目标。(事实上,一个从元素中创建出来的fiber,这两个属性会被立刻拷贝下来)。

type属性阐述了它对应的组件。如果是混合型的组件,类型就会使函数或者类它本身。对于原生组件来说(类似div和span),type就是一个字符串。

从概念上讲,那些类型是函数的,都会被stack frame跟踪执行。

和type一起的是,key是在reconciliation中使用去决定fiber是否可以被复用。

child 和 sibling

这些领域指向其它fiber,用来阐述fiber的递归树结构。

child fiber映射到组件的渲染函数返回的值。如下面的例子:

function Parent() {
  return <Child />
}

Parent的child fiber 指向 Child。

兄弟字段说明了渲染返回多个子项的情况(Fiber中的一个新特性)

function Parent() {
  return [<Child1 />, <Child2 />]
}

child fiber在此形成了一个以第一个child为head的单链表,所以在这个例子中,child的Parent是child1, child1的sibling是child2。

回到我们的函数比喻,你可以认为child fiber是一个尾部调用函数

return

return fiber 是程序在处理完当前fiber时应该返回的fiber。从概念上讲它和stack frame返回的地址相同。它也可以被认为是parent fiber。

如果一个fiber有多种child fiber, 每一个child fiber的返回值都是parent fiber。 所以在前一个部分的例子中,Child1和Child2的返回值就是Parent。

pendingProps 和 memoizedProps

从概念上讲,props是一个函数的默认参数。一个fiber的 pendingProps 是在开始执行时就被设置,而 memoizedProps 则是在结束时被设置。

当传进来的 pendingProps 和 memoizedProps 相等时,这预示着当前fiber之前的输出可以被复用,避免了不必要的工作。

pendingWorkPriority

是一个用来标记 fiber 当前任务优先级的数字。 ReactPriorityLevel 模型列出了不同优先级和他们展现的方式。

除NoWork为0外,数字越大表示优先级越低。比如,你可以用下面的函数来检查一个fiber的权重是否跟给予的一样高。

function matchesPriority(fiber, priority) {
  return fiber.pendingWorkPriority !== 0 &&
         fiber.pendingWorkPriority <= priority
}

这个函数只是用来展示,并没有事实上在React Fiber的代码中

调度器通过权重来执行下一个任务单元。这个函数将会在下一部分中讨论。

alternate

flush

刷新一个fiber是指将它的输出放在屏幕上。

work-in-progress

fiber至今没有执行完,概念上来讲,一个stack frame没有返回。

无论何时,一个组件最多有两个fiber去映射它。当前的fiber和正在执行中的fiber。

当前fiber中的备份是正在进行中的fiber,正在进行中的fiber的备份是当前的fiber。

一个fiber的备份是被一个叫作cloneFiber的函数懒创建出来的,而不总是创建一个新的对象。cloneFiber会重复利用一个已经存在的fiber备份。并且最小化分配。

你应该将备份认为是一个更新细节,但是它经常在代码库中弹出,因此在这里进行讨论很有价值。

output

host component

React 应用的叶节点,它们特定于渲染环境(例如,在浏览器应用中,它们是divspan等),在JSX中,它们使用小写标签名称表示。

概念上讲,output是函数的返回。

每个fiber最终都有输出,但是输出仅由host component在叶节点上创建。 然后将输出传输到树上。

输出是最终提供给渲染器的输出,以便可以将更改刷新到渲染环境。 定义输出的创建和更新方式是渲染器的责任。

特性部分

目前仅此而已,但是本文档还远远不够完整。 以后的部分将描述在更新的整个生命周期中使用的算法。 涵盖的主题包括:

  • 调度器如何找到下一个任务单元去执行
  • fiber 树上优先级是如何跟踪和传播的
  • 调度程序如何知道何时暂停和继续工作。
  • 如何刷新工作并将其标记为完成。
  • 副作用(例如生命周期方法)如何工作。
  • 协程是什么以及如何用于实现上下文和布局等功能。

相关视频

What's Next for React (ReactNext 2016)