前言
React16提出了Fiber结构,其能够将任务分片,划分优先级,同时能够实现类似于操作系统中对线程的抢占式调度,非常强大。
正文
React是一个用于构建UI的JavaScript库,其核心是跟踪组件状态变化并将更新到view上。在React中,我们将此过程视为reconciliation。在调用setState方法后,框架会检查state或props是否已更改并在UI上重新呈现组件。
React的文档提供了一种更高层次的对这种机制的描述:包含React元素的作用,生命周期方法和渲染方法,以及应用于组件子元素的diffing算法等相关内容。从render方法返回的不可变React元素树通常称为“Virtual DOM”。这个术语有助于早期向人们解释React,但它也引起了歧义,并且不再在React文档中使用。在本文中,将坚持称它为React元素的树。
除了React元素的树之外,框架总是有一个用于保持状态的内部实例树(internal instances)(组件,DOM节点等),与之相对的是跟具体平台有关的public instance,也被称为Host instance 。从React 16开始,React推出了该内部实例树的新实现以及负责操作树的算法,被称为Fiber。接下来我们将了解fiber架构带来的优势,了解React在fiber中使用链表的方式和原因。
这是本系列的第一篇文章,旨在教你React的内部架构。在本文中,我想提供其中的重要概念和以及与算法相关的数据结构。一旦我们有足够的背景,我们将探索用于遍历和处理fiber tree的算法和主要功能。本系列的下一篇文章将演示React如何使用该算法执行初始渲染和处理state以及props更新。从那里我们将继续讨论scheduler的详细信息,子协调过程以及构建effect list的机制。
背景
首先写一个非常简单的程序:
import React, { Component } from 'react';
export default class ClickCounter extends Component{
constructor(props) {
super(props);
this.state = {count: 0};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState((state) => {
return {count: state.count + 1};
});
}
render() {
return [
<button key="1" onClick={this.handleClick}>Update counter</button>,
<span key="2">{this.state.count}</span>
]
}
}
可以查看在线实例。可以看到,它是一个非常简单的组件,它从render方法返回两个子元素button和span。单击button后,组件的state将在处理程序内更新,这会导致span元素的文本更新。
React会在reconciliation期间执行各种活动。例如,以下是React在上面这个程序中第一次渲染和状态更新之后执行的高级操作:
- 更新state中的count属性
- 检索并比较ClickCounter子组件以及props
- 更新span元素的props
同时,在reconciliation期间,还会执行其他活动包括调用生命周期方法或更新引用。所有这些活动在fiber架构中统称为“work”。work类型通常取决于React元素的类型。例如,对于class组件,React需要创建实例,而不是为function组件执行此操作。并且,React中有许多元素,例如:class和function组件,Host组件(DOM节点),protals等. React元素的类型由createElement函数的第一个参数定义,此函数通常在render方法中用于创建元素。总结起来,就是更新组件的内部状态,触发side-effects执行。
在我们开始探索fiber算法之前,让我们首先熟悉React内部使用的数据结构。
从 React Elements到Fiber nodes
React中的每个组件都有一个UI表示,这个UI可以通过调用一个view或一个从render方法返回。这是ClickCounter组件的模板:
<button key="1" onClick={this.onClick}>Update counter</button>
<span key="2">{this.state.count}</span>
React Elements
一旦模板通过JSX编译器编译,就会得到一堆React元素。这是从React组件的render方法返回的,而不是HTML。由于我们不需要使用JSX,因此我们的ClickCounter组件的render方法可以像这样重写:
class ClickCounter {
...
render() {
return [
React.createElement(
'button',
{
key: '1',
onClick: this.onClick
},
'Update counter'
),
React.createElement(
'span',
{
key: '2'
},
this.state.count
)
]
}
}
在render方法中调用React.createElement会创建两个数据结构:
[
{
$$typeof: Symbol(react.element),
type: 'button',
key: "1",
props: {
children: 'Update counter',
onClick: () => { ... }
}
},
{
$$typeof: Symbol(react.element),
type: 'span',
key: "2",
props: {
children: 0
}
}
]
可以看到React将$$typeof属性添加到这些对象,以将它们唯一地标识为React元素。然后我们可以通过type,key和props属性来描述元素。这些值取自传递给React.createElement函数的值。请注意React如何将文本内容表示为span和button节点的子项,以及click处理程序如何成为按钮元素props的一部分。 React元素上还有其他字段,如ref字段,超出了本文的范围,不再阐述。
同时ClickCouter元素没有任何的props或者key:
{
$$typeof: Symbol(react.element),
key: null,
props: {},
ref: null,
type: ClickCounter
}
Fiber nodes
在reconciliation期间,来自render方法返回的每个React元素的数据被合并到fiber node树中,每个React元素都有一个相应的fiber node。与React元素不同,每次渲染过程,不会再重新创建fiber。这些可变的数据包含组件state和DOM。 我们之前讨论过,根据React元素的类型,框架需要执行不同的活动。在我们的示例应用程序中,对于class组件ClickCounter,它调用生命周期方法和render方法,而对于span Host 组件(DOM节点),它执行DOM更新。因此,每个React元素都会转换为相应类型的Fiber节点,用于描述需要完成的工作。
可以这样认为:fiber作为一种数据结构,用于代表某些worker,换句话说,就是一个work单元,通过Fiber的架构,提供了一种跟踪,调度,暂停和中止工作的便捷方式。
当React元素第一次转换为fiber节点时,React使用createElement返回的数据来创建fiber,这段代码在createFiberFromTypeAndProps 函数中。在随后的更新中,React重用fiber节点,并使用来自相应React元素的数据来更新必要的属性。如果不再从render方法返回相应的React元素,React可能还需要根据key来移动层次结构中的节点或删除它。
可以查看ChildReconciler函数的实现,来了解React为现有fiber节点执行的所有活动和相应函数的列表。因为React为每个React元素创建了一个fiber node,并且因为我们有一个这些元素的树,所以我们将拥有一个fiber node tree。对于我们的示例应用程序,它看起来像这样:
所有fiber节点都通过使用fiber节点上的以下属性:child,sibling和return来构成一个fiber node的linked list(后面我们称之为链表)。有关它为什么以这种方式工作的更多详细信息,可以查看这篇文章。
Current and work in progress trees
在第一次渲染之后,React最终得到一个fiber tree,它反映了用于渲染UI的应用程序的状态。这棵树通常被称为current tree。当React开始处理更新时,它会构建一个所谓的workInProgress tree,它反映了要刷新到屏幕的未来状态。
所有work都在workInProgress tree中的fiber上执行。当React遍历current tree时,对于每个现有fiber节点,它会使用render方法返回的React元素中的数据创建一个备用(alternate)fiber节点,这些节点用于构成workInProgress tree(备用tree)。处理完更新并完成所有相关工作后,React将备用tree刷新到屏幕。一旦这个workInProgress tree在屏幕上呈现,它就会变成current tree。
React的核心原则之一是一致性。 React总是一次更新DOM - 它不会显示部分结果。 workInProgress tree对用户不可见,因此React可以先处理完所有组件,然后将其更改刷新到屏幕。
在源代码中,可以看到很多函数从current tree和workInProgress tree中获取fiber节点:
function updateHostComponent(current, workInProgress, renderExpirationTime) {...}
每个fiber节点都会通过alternate字段保持对另一个树的对应节点的引用。current tree中的节点指向workInProgress tree中的备用节点,反之亦然。
Side-effects
我们可以将React中的一个组件视为一个使用state和props来计算UI的函数。每个其他活动,如改变DOM或调用生命周期方法,都应该被认为是side-effects,react文档中是这样描述的side-effects的:
You’ve likely performed data fetching, subscriptions, or manually changing the DOM 的 from React components before. We call these operations “side effects” (or “effects” for short) because they can affect other components and can’t be done during rendering.
可以看到大多数state和props更新将side-effects。由于应用effects是一种work,fiber节点是一种方便的机制,可以跟踪除更新之外的effects。每个fiber节点都可以具有与之相关的effects, 通过fiber节点中的effectTag字段表示。
因此,Fiber中的effects基本上定义了处理更新后需要为实例完成的工作,对于Host组件(DOM元素),工作包括添加,更新或删除元素。对于class组件,React可能需要更新ref并调用componentDidMount和componentDidUpdate生命周期方法,还存在与其他类型的fiber相对应的其他effects。
Effects list
React能够非常快速地更新,并且为了实现高性能,它采用了一些有趣的技术。其中之一是构建带有side-effects的fiber节点的线性列表,其具有快速迭代的效果。迭代线性列表比树快得多,并且没有必要在没有side effects的节点上花费时间。
此列表的目标是标记具有DOM更新或与其关联的其他effects的节点,此列表是finishedWork tree的子集,并使用nextEffect属性,而不是current和workInProgress树中使用的child属性进行链接。
Dan Abramove为effecs list提供了一个类比: 他喜欢将它想象成一棵圣诞树,“圣诞灯”将所有带有effects的节点绑定在一起。为了使这个effects list可视化,让我们想象下面的fiber node tree,其中橙色的节点都有一些effects需要处理。例如,我们的更新导致c2被插入到DOM中,d2和c1被用于更改属性,而b2被用于激活生命周期方法。effects list将它们链接在一起,以便React可以在以后跳过其他节点:
你可以看到带有effects的节点是如何链接在一起的,当遍历节点时,React使用firstEffect指针来确定effects list的开始位置。所以上图可以表示为这样的线性列表
Root of the fiber tree
每个React应用程序都有一个或多个作为container的DOM元素。在我们的例子中,它是带有id为“container”的div元素。
const domContainer = document.querySelector('#container');
ReactDOM.render(React.createElement(ClickCounter), domContainer);
React为每个container创建一个fiber root 对象,可以使用对DOM元素的引用来访问它:
const fiberRoot = query('#container')._reactRootContainer._internalRoot
这个fiber root 是React保存对fiber tree引用的地方。它存储在fiber tree的current属性中:
const hostRootFiberNode = fiberRoot.current
fiber tree以特殊类型的fiber节点(HostRoot)开始。它是在内部创建的,并充当最顶层组件的父级,HostRoot fiber节点通过stateNode属性指向FiberRoot:
fiberRoot.current.stateNode === fiberRoot; // true
可以通过fiber root访问最顶端的HostRoot的fiber node来探索fiber tree。或者,可以从组件实例中获取单个fiber节点,如下所示:
compInstance._reactInternalFiber
Fiber node structure
现在让我们看一下为ClickCounter组件创建的fiber节点的结构:
{
stateNode: new ClickCounter,
type: ClickCounter,
alternate: null,
key: null,
updateQueue: null,
memoizedState: {count: 0},
pendingProps: {},
memoizedProps: {},
tag: 1,
effectTag: 0,
nextEffect: null
}
以及span节点:
{
stateNode: new HTMLSpanElement,
type: "span",
alternate: null,
key: "2",
updateQueue: null,
memoizedState: null,
pendingProps: {children: 0},
memoizedProps: {children: 0},
tag: 5,
effectTag: 0,
nextEffect: null
}
fiber节点上有很多字段,我在前面的部分中描述了alternate字段,effectTag和nextEffect的用途。现在让我们看看为什么我们需要其他字段:
- stateNode:保存对组件的类实例,DOM节点或与fiber节点关联的其他React元素类型的引用。一般来说,可以认为这个属性用于保存与fiber相关的本地状态。
- type:定义与此fiber关联的功能或类。对于类组件,它指向构造函数;对于DOM元素,它指定HTML tag。可以使用这个字段来理解fiber节点与哪个元素相关。
- tag:定义fiber的类型。它在reconcile算法中用于确定需要完成的工作。如前所述,工作取决于React元素的类型,函数createFiberFromTypeAndProps将React元素映射到相应的fiber节点类型。在我们的应用程序中,ClickCounter组件的属性标记是1,表示ClassComponent,而span元素的属性标记是5,表示Host Component。
- updateQueue:用于状态更新,回调函数,DOM更新的队列
- memoizedState:用于创建输出的fiber状态。处理更新时,它会反映当前在屏幕上呈现的状态。
- memoizedProps:在前一次渲染期间用于创建输出的props
- pendingProps:已从React元素中的新数据更新,并且需要应用于子组件或DOM元素的props
- key:具有一组children的唯一标识符,可帮助React确定哪些项已更改,已添加或从列表中删除。它与此处描述的React的“list and key”功能有关。
可以在此处找到fiber节点的完整结构。在上面的解释中省略了一堆字段,尤其跳过了child,sibling和return,组成了树数据结构。以及特定于Scheduler的expirationTime,childExpirationTime和mode等字段类别。
General algorithm
React把一次渲染分为两个阶段:render和commit。
在render阶段时,React通过setState或React.render来执行组件的更新,并确定需要在UI中更新的内容。如果是第一次渲染,React会为render方法返回的每个元素,创建一个新的fiber节点。在接下来的更新中,将重用和更新现有React元素的fiber 节点。render阶段的结果是生成一个部分节点标记了side effects的fiber节点树,side effects描述了在下一个commit阶段需要完成的工作。在此阶段,React采用标有side effects的fiber树并将其应用于实例。它遍历side effects列表并执行DOM更新和用户可见的其他更改。
一个很重要的点是,render阶段可以异步执行。 React可以根据可用时间来处理一个或多个fiber节点,然后停止已完成的工作,并让出调度权来处理某些事件。然后它从它停止的地方继续。但有时候,它可能需要丢弃完成的工作并再次从头。由于在render阶段执行的工作不会导致任何用户可见的更改(如DOM更新),因此这些暂停是不会有问题的。相反,在接下来的commit阶段始终是同步的,这是因为在此阶段执行的工作,将会生成用户可见的变化,例如, DOM更新,这就是React需要一次完成它们的原因。
调用生命周期方法是React执行的一种工作。在render阶段调用某些方法,在commit阶段调用其他方法。在render阶段时调用的生命周期列表如下:
- [UNSAFE_]componentWillMount (已废弃)
- [UNSAFE_]componentWillReceiveProps (已废弃)
- getDerivedStateFromProps
- shouldComponentUpdate
- [UNSAFE_]componentWillUpdate (已废弃)
- render
可以看到,在render阶段执行的一些遗留生命周期方法在react 16.3中标记为UNSAFE。它们现在在文档中称为遗留生命周期,将在未来的16.x版本中弃用,而没有UNSAFE前缀的版本将在17.0中删除。可以在此处详细了解这些更改以及建议的迁移路径。
为什么会废弃这些声明周期函数呢呢?
因为在render阶段不会产生像DOM更新这样的副作用,所以React可以异步处理与组件异步的更新(甚至可能在多个线程中执行)。然而,标有UNSAFE的生命周期经常被误解和滥用,开发人员倾向于将带有副作用的代码放在这些方法中,这可能会导致新的异步渲染方法出现问题。虽然只有没有UNSAFE前缀的副本会被删除,但它们仍然可能在即将出现的concurrent模式中引起问题。
以下是commit阶段执行的生命周期方法列表:
- getSnapshotBeforeUpdate
- componentDidMount
- componentDidUpdate
- componentWillUnmount
因为这些方法在同步commit阶段执行,所以它们可能包含副作用并获取DOM。
Render阶段
reconciliation算法始终使用renderRoot函数从最顶端的HostRoot fiber节点开始。但是,React会跳过已经处理过的fiber节点,直到找到未完成工作的节点。例如,如果在组件树中调用setState,则React将从顶部开始,但会快速跳过父节点,直到它到达调用了setState方法的组件。
Main steps of the work loop
所有fiber节点都在work loop中处理。这是循环的同步部分的实现
function workLoop(isYieldy) {
if (!isYieldy) {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
} else {...}
}
在上面的代码中,nextUnitOfWork从workInProgress树中保存对fiber节点(这些节点有部分任务要处理)的引用。当React遍历Fibers树时,它使用此变量来知道是否有任何其他fiber节点具有未完成的工作。处理当前fiber后,变量将包含对树中下一个fiber节点的引用或null。在这种情况下,React退出工作循环并准备提交更改.
有4个主要功能用于遍历树并启动或完成工作:
要演示如何使用它们,请查看以下遍历fiber树的动画。已经在演示中使用了这些函数的简化实现。每个函数都需要一个fiber节点进行处理,当React从树上下来时,可以看到当前活动的fiber节点发生了变化,可以清楚地看到算法如何从一个分支转到另一个分支。它首先完成child 节点的工作,然后转移到parent身边.
注意,垂直连接表示sibling,而弯曲的连接表示child,例如b1没有child,而b2有一个childc1.
这是视频的链接,您可以在其中暂停播放并检查当前节点和功能状态,可以简单的看到,这里适用的树遍历算法是深度优先搜索(DFS)
让我们从前两个函数performUnitOfWork和beginWork开始:
function performUnitOfWork(workInProgress) {
let next = beginWork(workInProgress);
if (next === null) {
next = completeUnitOfWork(workInProgress);
}
return next;
}
function beginWork(workInProgress) {
console.log('work performed for ' + workInProgress.name);
return workInProgress.child;
}
performUnitOfWork函数从workInProgress树接收fiber节点,并通过调用beginWork函数启动工作,即通过这个函数启动fiber需要执行的所有活动。出于演示的目的,我们只需记录fiber的名称即可表示已完成工作。beginWork函数始终返回要在循环中处理的下一个子节点的指针或null.
如果有下一个子节点,它将被赋值给workLoop函数中的nextUnitOfWork变量。但是,如果没有子节点,React知道它到达了分支的末尾,因此它就完成当前节点。一旦节点完成,它将需要为兄弟节点执行工作并在此之后回溯到父节点。这是在completeUnitOfWork函数中完成的.
function completeUnitOfWork(workInProgress) {
while (true) {
let returnFiber = workInProgress.return;
let siblingFiber = workInProgress.sibling;
nextUnitOfWork = completeWork(workInProgress);
if (siblingFiber !== null) {
// If there is a sibling, return it
// to perform work for this sibling
return siblingFiber;
} else if (returnFiber !== null) {
// If there's no more work in this returnFiber,
// continue the loop to complete the parent.
workInProgress = returnFiber;
continue;
} else {
// We've reached the root.
return null;
}
}
}
function completeWork(workInProgress) {
console.log('work completed for ' + workInProgress.name);
return null;
}
可以看到函数的重点是一个很大的循环。当workInProgress节点没有子节点时,React会进入此函数。完成当前fiber的工作后,它会检查是否有兄弟节点;如果找到,React退出该函数并返回指向兄弟节点的指针。它将被赋值给nextUnitOfWork变量,React将从这个兄弟开始执行分支的工作。重要的是要理解,在这一点上,React只完成了前面兄弟姐妹的工作。它尚未完成父节点的工作,只有在完成所有子节点工作后,才能完成父节点和回溯的工作.
从实现中可以看出,performUnitOfWork和completeUnitOfWork主要用于迭代目的,而主要活动则在beginWork和completeWork函数中进行。在后面的部分,我们将了解当React进入beginWork和completeWork函数时,ClickCounter组件和span节点会发生什么.
Commit phase
该阶段以completeRoot函数开始,这是React更新DOM并调用mutation生命周期方法的地方。
当React进入这个阶段时,它有2棵树和effects list。第一棵树是current tree, 表示当前在屏幕上呈现的状态,然后是在渲染阶段构建了一个备用树,它在源代码中称为finishedWork或workInProgress,表示需要在屏幕上反映的状态。此备用树通过子节点和兄弟节点指针来与current树类似地链接。
然后,有一个effects list - 通过nextEffect指针链接的,finishedWork树中节点的子集。请记住,effects list 是render阶段运行的结果。render阶段的重点是确定需要插入,更新或删除哪些节点,以及哪些组件需要调用其生命周期方法,其最终生成了effects list,也正是在提交阶段迭代的节点集。
出于调试目的,可以通过fiber root的current属性访current tree,可以通过current tree中HostFiber节点的alternate属性访问finishedWork树。
在提交阶段运行的主要功能是commitRoot。它会执行以下操作:
- 在标记了Snapshot effect的节点上使用getSnapshotBeforeUpdate生命周期方法
- 在标记了Deletion effect的节点上调用componentWillUnmount生命周期方法
- 执行所有DOM插入,更新和删除
- 将finishedWork树设置为current树
- 在标记了Placement effect的节点上调用componentDidMount生命周期方法
- 在标记了Update effect的节点上调用componentDidUpdate生命周期方法
在调用pre-mutation方法getSnapshotBeforeUpdate之后,React会在树中提交所有side-effects。它通过两个部分:第一部分执行所有DOM(Host)插入,更新,删除和ref卸载,然后,React将finishedWork树分配给FiberRoot,将workInProgress树标记为current树。前面这些都是在commit阶段的第一部分完成的,因此在componentWillUnmount中指向的仍然是前一个树,但在第二部分之前,因此在componentDidMount / Update中指向的是最新的树。在第二部分中,React调用所有其他生命周期方法和ref callback, 这些方法将会单独执行,因此已经调用了整个树中的所有放置(placement),更新和删除.
下面这段代码运行上述步骤的函数的要点,其中root.current=finishWork及以前为第一部分,其之后为第二部分.
function commitRoot(root, finishedWork) {
commitBeforeMutationLifecycles()
commitAllHostEffects();
root.current = finishedWork;
commitAllLifeCycles();
}
这些子函数中的每一个都实现了一个循环,该循环遍历effects list并检查effect的类型, 当它找到与函数功能相关的effects时,就会执行它.
Pre-mutation lifecycle methods
例如,这是在effect tree上迭代并检查节点是否具有Snapshot effect的代码:
function commitBeforeMutationLifecycles() {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
if (effectTag & Snapshot) {
const current = nextEffect.alternate;
commitBeforeMutationLifeCycles(current, nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
对于类组件,该effect意味着调用getSnapshotBeforeUpdate生命周期方法.
DOM updates
commitAllHostEffects是React执行DOM更新的函数。该函数基本上定义了需要为节点完成并执行它的操作类型.
unction commitAllHostEffects() {
switch (primaryEffectTag) {
case Placement: {
commitPlacement(nextEffect);
...
}
case PlacementAndUpdate: {
commitPlacement(nextEffect);
commitWork(current, nextEffect);
...
}
case Update: {
commitWork(current, nextEffect);
...
}
case Deletion: {
commitDeletion(nextEffect);
...
}
}
}
有趣的是,React调用componentWillUnmount方法作为commitDeletion函数中删除过程的一部分.
Post-mutation lifecycle methods
commitAllLifecycles是React调用所有剩余生命周期方法componentDidUpdate和componentDidMount的函数.