React Fiber架构

2,855 阅读16分钟

介绍

对于一些react语境下的术语不翻译。原文

React Fiber是对React核心算法的重新实现。这是一个正在进行中的项目。到目前为止(指2016年),React团队已经对此进行了为期两年的研究和调研。

React Fiber的目标是增加React对动画,布局和手势等领域的是适配性(suitability)。React Fiber的头等特性是incremental rendering(增量渲染)。何为增量渲染?那就是一种将rendering work切割成chunks,然后将其分成多个帧。

其实比较关键的特性包括有暂停,中断和在更新流程中复用work;对各种不同类型的更新任务赋予不同的优先级(priority);为concurrency作铺垫。

关于本文档

React Fiber引入了几种新颖的概念。这些概念往往不能仅仅靠通过查阅源码就能理解的。 这个文档纯粹是我个人跟进React Fiber项目实现细节的过程中记下来的笔记。随着这种笔记记得越来越多,我意识到这些笔记可能对别人也会有用。

我打算用最简单,通俗易懂的语言来描述和表达,避免一上来就给你展示各种术语的艰涩难懂的定义。我也尽可能地给出一些比较重要的外部资源的链接。

注意,我不是React团队的成员,我的话并不具备权威性。因此,这不是官方的权威文档。不过,我已经咨询过几个React团队的成员,以确保所表达内容的准确性。

这份文档会随时变更,因为我感觉React Fiber项目在完成之前随时都会进行一些重大的重构。当然,我也会尝试记录它的设计的变更。任何关于该文档的优化和建议都是十分欢迎的。

我的目的是,在读完这边文档之后,你在跟进它已经实现的部分过程中,对React Fiber会有足够的了解。甚至最后,你能够反过来为React社区做出你自己的贡献。

前置的知识储备

在继续阅读前,我强烈建议你过目一下以下罗列出来的文章:

重温

请检查一下你是否已经阅读过“前置的知识储备”章节。如果没有,建议你去阅读一下。在我们深入接触新东西之前,我们不妨重温几个概念。

什么是reconciliation?

reconciliation

是一种react用来diff(比对)两颗节点(比如react element)树,从而决定哪一部分需要更新的一种算法。

update

导致React app重新渲染的数据更改。通常来说,setState就会导致一个更新。重新渲染作为更新的一个结果而存在。

React API的中心思想是这样的:一次update等同于整个app的重新渲染。这种设计有助于开发者用声明式的思维方式去理解app是如何高效地将状态转换的(A to B, B to C, C to A),而不是用命令式的思维方式去操心期间的细节。

不过,每一次数据的改变导致整个app的重新渲染只会发生在一些不太重要app上。在真实的世界里,一般不会这么做。因为这么做会导致很昂贵的性能消耗。React已经在呈现最新界面的同时帮我们做好了性能优化。大部分这些优化就是我们所提reconciliation算法的一部分。

大众所熟知的"virtual DOM"的背后就是reconciliation算法。一个高水平的描述是这样的:当你渲染一个react app的时候,那么react就会生成一颗用于描述该app的节点树(译者注:这个节点就是指react element),并保存在内存当中。然后,这个节点树会被flush到相应的渲染平台上-举个例子,对于浏览器应用来说,“节点树会被flush到相应的渲染平台上”具体点讲就是进行一系列的DOM操作。一旦app被更新过(通过调用setState),一个新的节点树就会被生成了。新的节点树会跟存在于内存当中的那颗旧的节点树进行比对,从而计算出更新整个app界面所需要的具体操作。

虽然,React Fiber是对reconciler的重写,但是依据React doc中对高层算法的描述,重写前后,reconciler还是有大量的相同处。比较关键的两点是:

  • 假设不同“组件类型”的组件会生成大体不同的的节点树。对于这种情况(不同“组件类型”的组件的更新),react不会对它们使用diff算法,而是简单粗暴地将老的节点树完全替换为新的节点树。
  • 使用key这个prop来diff列表。key应该是“稳定(译者注:也就是说不能用类似于Math.random()来生成key的prop值),可预测的和唯一的”。

reconciliation vs rendering

DOM只是React能够适配的的渲染平台之一。其他主要的渲染平台还有IOS和安卓的视图层(通过React Native这个renderer来完成)。

react之所以能够适配到这么多的渲染平台,那是因为react是被设计成两个独立的阶段:reconciliation和rendering。reconciler通过比对节点树来计算出节点树中哪部分需要更改;renderer负责利用这些计算结果来做一些实际的界面更新动作。

这种分离的设计意味着React DOM和React Native能使用独立的renderer的同时公用相同的,由React core提供的reconciler。React Fiber重写了reconciler,但这事大致跟rendering无关。不过,无论怎么,众多renderer肯定是需要作出一些调整来整合新的架构。

Scheduling

Scheduling

决定何时(译者注:这是个关键词)应该执行work的那部分程序

work

需要被执行的所有计算。work(这个概念)一般意义下是作为一次更新的结果而存在的。

React的Design Principles文档在这个话题上阐述得非常好,因此,我在这里会引用它:

在当前实现中,React会递归地遍历节点树,相应地调用这颗已更新的节点树上的render方法。然而,在将来,React会延迟某些更新来避免界面更新的掉帧。

这是一个React设计中常见的主题。Some popular libraries implement the "push" approach where computations are performed when the new data is available. React, however, sticks to the "pull" approach where computations can be delayed until necessary.

React不是一个通用的数据处理类库。它是一个用于构建用户界面的类库。能够知道app哪些计算跟当前更新是相关的,哪些是不相关的,这种能力在构建用户界面方面有独到的优势。

如果某些东西用户通过屏幕看不到的话,我们可以将它所关联的逻辑延迟执行。如果数据的到达速度比帧率还要快的话,我们可以对数据进行合并,采用批量更新的策略 。我们可以把来自于用户界面的work(比如说,通过一个button的点击触发了某个动画)的优先级定得比一些跑在后台,不太重要的work(通过网络请求把一些内容渲染到屏幕上)的优先级要高。通过这么做,我们可以防止用户所看到的界面出现掉帧的现象。

上面给出的文档的关键点如下:

  • 在UI开发中,没有必要将每一个更新请求付诸实施。实际上,如果真的是这么做的话,那么界面就会出现掉帧的现象,这大大降低了用户体验。
  • 不同类型的更新请求应该由不同的优先级。比如,执行动画的优先级应该要比一个来自于data store的更新的优先级要高。
  • push-based的方案要求你(开发者)需要手动去调度work。而pull-based的方案则能够让框架(react)学得变聪明,然后帮你去做这些调度工作。

react当前(2016年)对scheduling的应用度还没有足够大。一次更新意味着(简单粗暴地)对整个节点树进行一个完整的重渲染(译者注:级联式的层层更新)。重写react的核心算法就是为了利用scheduling带来的优势。这应该就是React Fiber项目的初心。

现在,我们已经准备好深入到React Fiber的实现当中去了。下一节,我们将会提到越来越多的技术性东西。在继续阅读之前,请确保你对上面章节所提到的内容已经消化理解得差不多了。

什么是React Fiber?

我们将要讨论React Fiber的核心了。React Fiber这个抽象层级比你想象中的还要低。如果你发现你自己在尝试理解这个架构的过程中苦苦挣扎的话,不要气馁哈。不要放弃,继续尝试,你终将会弄明白它的(当你终于理解了React Fiber,麻烦给点关于完善这一小节内容的建议)。

我们在上面已经确认了React Fiber的首要目标是使得React能够整合scheduling。更具体地来说,是我们能做到一下几点:

  • 中断work和稍后恢复work
  • 对不同类型的work赋予相当的优先级
  • 复用之前已经完成的work
  • 如果一个work已经不需要继续了,中断它。

为了能够做到以上几点,我们需要将work拆分成一个个的单元。这个单元其实就是React Fiber。一个React Fiber代表着一个work单元

再继续阐述前,我们不妨重温一下这个概念:React components as functions of data,可以用通俗的方式来表达:

v = f(d) // view = f(data)

因此,我们可以做这样的等价的心智模型:渲染一个 React app 就等同于调用一个函数,然后这个函数体里面又包含了对其实其它函数的调用......与此类推。这种心智模型对理解React Fiber十分之有用。

计算机是通过call stack 来追踪程序的执行过程的。一个函数一旦被执行,它就会成为stack frame,加入到stack中。这一帧stack frame代表着这个函数所要完成的一个work。

问题就是,当跟UI打交道的的时候,一次性会有很多的work被执行。这会导致动画掉帧从而看起来不太流畅。还有就是,一些work如果会马上被后面的work所取代的话,那么马上执行这个work就显得没必要了。This is where the comparison between UI components and function breaks down, because components have more specific concerns than functions in general.

新加入的渲染平台(比如react native)实现了一些用于专门处理这个问题的API:requestIdleCallback和requestAnimationFrame。requestIdleCallback负责将一些低优先级的函数安排在空闲期间执行;requestAnimationFrame负责将一个高优先级的函数安排在下一个动画帧期间调用。现在的问题是,为了能够使用这种API,你必须找到一种方法将rendering work拆分成各种增量单元(incremental units)。如果我们仅仅依靠call stack,似乎是不行的。因为call stack会一直执行work单元,直到call stack清空为止。

如果能够通过自定义call stack的行为来优化UI渲染岂不是很棒吗?如果我们能手动地打断call stack和操作call stack的每一个帧岂不是很棒吗?

而这就是React Fiber的目的。React Fiber是对stack的重新实现,特别是为了React组件而作的实现。你可以把单独的一个Fiber理解为一个virtual stack frame。

对stack的重新实现的好处是,你能将stack frame保存在内存中,自己想什么时候,在哪里执行都可以。这对于我们实现引入scheduling时所设下的目标很重要。

重写stack,除了能实现scheduling之外,手动处理stack frames还能让实现一些新特性(比如:concurrency 和error boundaries)成为了可能。我们将会在未来的一些章节来讨论这些话题。

在下一节,我们更深入地看看一个Fiber的数据结构。

Fiber的数据结构

注意:随着越来越深入到技术细节,那么这些细节所面临更改的可能性也会随着增加。如果你发现了任何错误或者过期的信息,麻烦发个PR给我。

具体而言,一个fiber其实就是一个javascript对象。这个对象包含了一些关于组件的信息:这个组件的输入,这个组件的输出。

一个fiber对应于一帧stack frame,同时也对应于一个组件的实例。

以下是fiber对象的一些比较重要的字段(这个列表也不太详尽)。

type 和 key

fiber对象的type和key字段跟react element的type和key字段的含义是一致的。(实际上,fiber对象是从react element创建而来的,在创建的时候,这两个字段会被直接地copy过来)。

fiber对象的type字段描述的就是它所对应的组件。对于composite components来说,这个type字段值就是一个function或者class component(译者注:本质上也是一个function)。对于host components(比如,div,span等)而言,type字段的值就是字符串。

理论上说,type字段的值就是被stack frame追踪它的执行的那个函数(v = f(d ))。

跟type字段一样,key字段被用于reconciliation期间决定是否要复用该fiber。

child 和 sibling

这两个字段都是指向其它的fiber,共同组成了具有递归结构的fiber树。

child fiber就是组件的render方法的返回值。举个例子:

function Parent(){
    return <Child />
}

Parent的child fiber就是Child。

sibling字段存在于那些从render返回的多个children的身上(fiber的新特性):

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

上面所有的child fiber组成了一个单(向)链表。第一个child就是这个单链表的头节点。在这个示例中,Child1是Parent的child fiber,Child2是Child1的 sibling child。

不妨回头看看我们之前用function做过的类比,你可以把child fiber理解为一个tail-called function

return

return fiber是指程序在处理完当前这个fiber所返回的那个fiber。概念上,它是等同于返回的stack frame的地址(It is conceptually the same as the return address of a stack frame)。我们也可以把它当作parent fiber(译者注:其实这种关系就是React以前说的 owner关系)。

如果一个fiber有多个child fiber,这些child fiber的return fiber就是它们的parent fiber(此处的return有点是“上游”的意思)。所以,在我们上面所提到的示例中, Child1和Child2的return fiber就是Parent。

pendingProps 和 memoizedProps

概念上说,props就是函数的参数。在函数刚开始执行的时候fiber的pendingProps就会被设置上,在函数执行完毕,fiber的memoizedProps也会被设置上。

当函数再次被执行,一个新的pendingProps会被计算出来,如果它与fiber的memoizedProps字段值相等的话,那么这就相当于告诉我们fiber之前的output可以复用,从而阻止了不必要的work。

pendingWorkPriority

一个用于指示优先级的数值。谁的优先级呢?fiber所代表的work的优先级。ReactPriorityLevel 模块列举出了所有work的优先级,并且也罗列了这些work所代表着什么。

除了“noWork”这个特例外(它的优先级数值是0),数值越大代表着优先级越低。举个例子,你可以用以下的函数去检查当前的fiber的优先级是否比给定的优先级高:

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

上面这个函数只是用于演示而已,它并不是来源于真实的react fiber源码。

scheduler就是通过消费fiber的priority字段来决定下一个需要执行的work单元是谁。这其中的算法将会在future section小节去讨论。

alternate

flush

当我们说flush一个fiber时,意思就是将这个fiber的output渲染到屏幕上。

work-in-progress

当一个fiber还没被完成时(has not yet completed),我们就说这个work是work-in-progress。概念上就是指某个stack frame还没被返回的时候。

在任何时候,一个组件实例最多对应着两个fiber:当前的,已经flushed的fiber和处在work-in-progress的fiber。

current fiber与work-in-progress的fiber会相互转化的(互生性)。current fiber最终会转化为work-in-progress的fiber,work-in-progress的fiber最终会转化为下一次work开始时的current fiber。

一个fiber的互生fiber是由一个叫cloneFiber的函数惰性地创建的。相比于总是创建一个新的对象,cloneFiber将会尝试复用它的互生fiber,如果它存在的话。这么做,能够减少内存分配。

alternate字段已然是react fiber的实现细节了。因为它在源码中的出现频率太高了,所有值得我们在这里讨论一下它。

output

host component

React app的叶子节点。它们代表的是具体的渲染平台(例如,对于浏览器app来说,DOM节点如“div”, “span”等就是 host component)。在JSX中,它们都是以小写的标签名出现的。

概念上说,一个函数的返回值就是一个fiber的output(这句话有歧义)。

每一个fiber最终都会有自己的output,但是这个output的创建只能在叶子节点由host component来创建。output创建后,会沿着节点树往上传递。

output最终会传递给renderer,然后应用会把最新状态渲染在屏幕上。定义output是如何创建的,又是如何被更新到屏幕的,这些事就是renderer的职责之所在了。

Future sections

这是到目前为止的所有内容了。但是,这是一篇未完待续的文档。未来的一些章节里面,我会阐述一个更新过程中自始至终所采用的算法。包含的主题如下:

  • scheduler是如何查找出下个要执行的work unit是谁?
  • fiber tree中,优先级是如何被追踪和传播的?
  • scheduler是如何知道什么时候暂停和恢复work的呢?
  • work是如何被flush掉,并且并标记为“已完成的”?
  • side-effect(比如生命周期方法)是如何运行的呢?
  • coroutine是什么?它是如何被用来实现某些特性(比如context和layout)的呢?

Related Videos