介绍
React Fiber是React核心算法的重新实现,这篇文章阐述了React团队在过去两年中的重点研究。
React Fiber的目标是增强它在动画、渲染和执行上的性能。它首要的特性就是逐渐增强的渲染能力:它可以将渲染工作切割成不同的块并且散布在多个帧上。
其他的关键特性包括更新时的暂停、废弃、以及重用;对不同类型的更新赋予权重;并发函数。
关于这篇文档
Fiber介绍了一些单靠看代码理解比较困难的新颖概念。这篇文档一开始只是我跟随Fiber在React中更新的的笔记的收集。当它越来越完备,我意识到这对于其他人来说也是一个很有用的资源。
我将尝试用最简单易懂的语言来阐述,并且通过定义关键术语来避免行话。必要时我也会尽量链接到内部资源。
请注意我不在React团队中,也不代表任何的权威发言。这不是一篇官方文档。我会请React团队的伙伴来验证准确性。
这也是一项进行中的工作。Fiber是一个正在进行并且在它被完成之前未来会有重大重构的项目,我的文章也会持续更新,欢迎大家给我提建议。
我的目标是当你阅读完这篇文档,你就像曾经开发过它一样了解它,并且最终可以给React反馈。
准备工作
我强烈建议你在继续阅读之前了解以下资料:
- React Components, Elements, and Instances-"Component"是一个关键术语,掌握这类术语十分重要。
- Reconciliation-详细地阐述了Reconciliation算法。
- React Basic Theoretical Concepts-阐述了一个没有实施负担的React概念模型。
- React Design Principles-这篇文章详细讲述了React的技术规划,很好地解释了为什么要用React Fiber。
概览
请先阅读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 应用的叶节点,它们特定于渲染环境(例如,在浏览器应用中,它们是div
,span
等),在JSX中,它们使用小写标签名称表示。
概念上讲,output是函数的返回。
每个fiber最终都有输出,但是输出仅由host component在叶节点上创建。 然后将输出传输到树上。
输出是最终提供给渲染器的输出,以便可以将更改刷新到渲染环境。 定义输出的创建和更新方式是渲染器的责任。
特性部分
目前仅此而已,但是本文档还远远不够完整。 以后的部分将描述在更新的整个生命周期中使用的算法。 涵盖的主题包括:
- 调度器如何找到下一个任务单元去执行
- fiber 树上优先级是如何跟踪和传播的
- 调度程序如何知道何时暂停和继续工作。
- 如何刷新工作并将其标记为完成。
- 副作用(例如生命周期方法)如何工作。
- 协程是什么以及如何用于实现上下文和布局等功能。