【React】594- React Fiber:深入理解 React reconciliation 算法
译文:Leiy
https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/
React 是一个用于构建用户交互界面的 JavaScript 库,其核心机制
就是跟踪组件的状态变化,并将更新的状态映射到到新的界面。在 React 中,我们将此过程称之为协调
。我们调用setState
方法来改变状态,而框架本身会去检查state
或 props
是否已经更改来决定是否重新渲染组件。
React 的官方文档对
协调机制
进行了良好的抽象描述:React 元素、生命周期、 render 方法,以及应用于组件子元素的diffing
算法综合起到的作用,就是协调。
从render
方法返回的不可变的 React 元素通常称为虚拟 DOM
。这个术语有助于早期向人们解释 React,但它也引起了混乱,并且不再用于 React 文档。在本文中,我将坚持称它为 React 元素的树。
除了 React 元素的树之外,框架总是在内部维护一个实例来持有状态(如组件、 DOM 节点等)。从版本 16 开始, React 推出了内部实例树的新的实现方法,以及被称之为Fiber
的算法。
下文中,我们将结合 ClickCounter
组件展开说明。我们有一个按钮,点击它将会使屏幕上渲染的数字加1
:
代码如下:
class ClickCounter extends React.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
按钮时,组件将更新处理程序,进而使span
元素的文本进行更新。
React 在协调(reconciliation) 期间执行各种活动。例如,以下是 React 在我们的ClickCounter
组件中的第一次渲染和状态更新之后执行的高级操作:
更新
ClickCounter
组件中state
的conut
属性。检索并比较
ClickCounter
的子组件及其props
。更新
span
元素的props
。
在协调(reconciliation) 期间执行了其他活动,包括调用生命周期方法
或更新refs
。所有这些活动在 Fiber 架构中统称为 work。 work
类型通常取决于 React 元素的类型。
例如,对于class
组件,React 需要创建实例,而functional
组件则不需要执行此操作。正如我们所了解的,React 中有许多元素类型,例如class
和functional
组件,host
组件(DOM节点)等。React 元素的类型由createElement
函数的第一个参数决定,此函数通常用于创建元素的render
方法中。
在我们开始探索活动细节和主要的fiber
算法之前,让我们先熟悉 React 内部使用的数据结构。
React 中的每个组件都有一个UI
表示,我们可以称之为从render
方法返回的一个视图或模板。这是ClickCounter
组件的模板:
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
处理程序如何成为button
元素的props
的一部分,以及 React 元素上的其他字段,比如ref
字段,超出了本文的范围。
ClickCounter
的 React 元素没有任何props
或key
:
{
$$typeof: Symbol(react.element),
key: null,
props: {},
ref: null,
type: ClickCounter
}
Fiber nodes
在协调(reconciliation) 过程中,render
方法返回的每个 React 元素的数据将被合并到Fiber
节点树中,每个 React 元素都有一个对应的Fiber
节点。
与 React 元素不同,Fiber
不是在每此渲染上都重新创建的,它们是保存组件状态和DOM
的可变数据结构。
我们之前讨论过,根据 React 元素的类型,框架需要执行不同的活动。在我们的示例中,对于类组件ClickCounter
,它调用生命周期方法
方法和render
方法,而对于span host 组件
(dom节点),它执行DOM
修改。因此,每个 React 元素都被转换成相应类型的Fiber
节点,用于描述需要完成的工作。
您可以将Fiber
视为一种数据结构,它表示一些要做的工作,或者一个工作单元。Fiber
的架构还提供了一种方便的方式来跟踪、调度、暂停和中止工作。
当react元素第一次转换为Fiber
节点时,React 使用元素中的数据在createFiberFromTypeAndProps
函数中创建一个Fiber
。在随后的更新中,React 重用这个Fiber
节点,并使用来自相应的 React 元素的数据更新必要的属性。
如果不再从render
方法返回相应的 React 元素,React 可能还需要根据key
属性来移动或删除层级结构中的节点。
检查
”ChildReconciler
函数,查看所有活动的列表以及 React 为现有Fiber
节点执行的相应函数。
因为 React 为每个 React 元素创建一个Fiber
,而且我们有一个这些元素组成的树,所以我们可以得到一个Fiber
节点树。对于我们的示例,如下所示:
所有fiber
节点通过链接列表进行连接:child
、sibling
和return
。
Current 树以及 workInProgress 树
在第一次呈现之后,React 最终得到一个Fiber
树,它反映了用于渲染UI
的应用程序的状态。这棵树通常被称为current
树。当 React 开始处理更新时,它会构建一个所谓的workInProgress
树,反映要刷新到屏幕的未来状态。
所有的工作都是在工作进度workInProgress
树的fibler
上进行的。当 React 遍历当前树时,它为每个现有的fiber
节点创建一个备用节点,该节点构成workInProgress
树。此节点是使用render
方法返回的 React 元素中的数据创建的。
一旦处理了更新并完成了所有相关工作,React 将有一个备用树准备刷新到屏幕上。在屏幕上呈现此工作进度树后,它将成为current
树。
React 的核心原则之一是一致性。 React总是一次性更新DOM
(它不会显示部分结果)。workInProgress
树用作用户看不到的"草稿"
,因此 React 可以先处理所有组件,然后将其更改刷新到屏幕上。
在源代码中,您将看到许多函数从current
树和workInProgress
树中获取fiber
节点。下面是这样一个函数:
function updateHostComponent(current, workInProgress, renderExpirationTime) {...}
每个fiber
节点都保存对备用字段中另一棵树的对应节点的引用。current
树中的节点指向WorkInProgress
树中的节点,反之亦然。
副作用
我们可以把 React 中的一个组件看作是一个使用state
和props
来计算UI
呈现的函数,任何其他活动,比如改变DOM
或调用生命周期
方法,都应该被认为是一种副作用,或者简单地说,是一种效果。https://reactjs.org/docs/hooks-overview.html#%EF%B8%8F-effect-hook
中也提到了影响:
你之前可能已经在 React 组件中执行过数据获取、订阅或者手动修改过 DOM。我们统一把这些操作称为“副作用”,或者简称为“作用”。(因为它们会影响其他组件,并且在渲染期间无法完成。)
”
您可以看到大多数state
和props
更新将如何导致副作用。由于"作用"
是work
的一种,所以除了更新之外,fiber
节点是跟踪"作用"的一种方便机制。每个fiber
节点都可以具有与其相关联的效果。它们在effectTag
字段中编码。
因此,fiber
中的"作用"
基本上定义了在处理更新后实例需要完成的工作:
对于
host
宿主组件(dom元素),包括添加、更新或删除元素。对于类组件,React 可能需要更新
refs
并调用componentDidMount
和componentDiddUpdate
生命周期方法。还有其他与其他
fiber
相对应的效应。
副作用列表
React 进程更新非常快,为了达到这个性能水平,它使用了一些有趣的技术。其中之一是建立一个具有快速迭代效果的fiber
节点线性列表。迭代线性列表比树快得多,不需要花时间在没有副作用的节点上。
此列表的目标是标记具有DOM
更新或与其相关联的其他作用的节点。此列表是finishedWork
树的子集,使用nextEfect
属性而不是current
树和workInProgress
树中使用的子属性进行链接。
这里有一个作用列表的类比,把它想象成一棵圣诞树,"圣诞灯"把所有有效的节点绑在一起。为了将其可视化,让我们想象一下下面的fiber
节点树,其中突出显示的节点有一些工作要做,例如,我们的更新导致C2
插入到DOM
中,D2
和C1
更改属性,B2
触发生命周期方法。效果列表将它们链接在一起,以便 React 可以稍后跳过其他节点:
可以看到具有副作用的节点是如何链接在一起的。当遍历节点时,React 使用firstEffect
指针来确定列表的起始位置。所以上面的图表可以表示为这样的线性列表:
如您所见,React 按照从子到父的顺序应用副作用。
Fiber 的根节点
每个 React 应用程序都有一个或多个充当容器的DOM
元素。在我们的例子中它是带有id
为container
的div
元素。
const domContainer = document.querySelector('#container');
ReactDOM.render(React.createElement(ClickCounter), domContainer);
React为每个容器创建一个fiber
根对象。您可以使用对DOM
元素的引用来访问它:
const fiberRoot = query('#container')._reactRootContainer._internalRoot
这个Fiber
根节点是 React 保存对fibler
树的引用的地方。它存储在fiber
根对象的currrent
属性中:
const hostRootFiberNode = fiberRoot.current
这个Fiber
树以一个特殊类型的Fiber
节点HostRoot
开始。它在内部创建的,并充当最顶层组件的父级。HostRoot
节点可通过stateNode
属性返回到FiberRoot
:
fiberRoot.current.stateNode === fiberRoot; // true
您可以通过fiber
根访问最顶层的hostRoot fiber
节点来浏览fiber
树。或者可以从组件实例中获取单个fiber
节点,如下所示:
compInstance._reactInternalFiber
Fiber 节点结构
现在让我们看看为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
DOM 元素:
{
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
和nextEfect
的用途。现在让我们看看为什么我们需要其他的字段。
stateNode
保存组件的类实例、DOM
节点或与Fiber
节点关联的其他 React 元素类型的引用。总的来说,我们可以认为该属性用于保持与一个Fiber
节点相关联的局部状态。
type
定义与此fiber
关联的函数或类。
对于类组件,它指向构造函数;对于DOM
元素,它指定HTML
标记。(使用这个字段来了解fiber
节点与什么元素相关。)
tag
定义fiber
的类型,它在reconciliation(协调)
算法中确定需要做什么工作。
如前所述,工作取决于 React 元素的类型。函数createFiberFromTypeAndProps
将 React 元素映射到相应的fiber
节点类型。在我们的案例中,ClickCounter
组件的tag
是1
,表示classComponent
;而对于span
元素,属性tag
是5
,表示hostComponent
。
updateQueue
状态更新、回调和DOM
更新的队列。
memoizedState
用于创建输出的fiber
的状态,处理更新时,它会反映当前在屏幕上呈现的状态。
memoizedProps
在前一次渲染期间用于创建输出的fiber
的props
。
pendingProps
已从 React 元素中的新数据更新并且需要应用于子组件或DOM
元素的props
。
key
唯一标识符,当具有一组子元素的时候,可帮助 React 确定哪些项发生了更改、添加或删除。
在上文中省略了一些字段:特别是数据结构指针
”child
、sibling
、return
。以及一类特定于调度器的字段,如expirationTime
、childExpirationTime
和mode
。
通用算法
React 在两个主要阶段执行工作:render
和commit
。
在第一个render
阶段,React 通过setUpdate
或React.render
计划性的更新组件,并确定需要在UI
中更新的内容。
如果是初始渲染,React 会为render
方法返回的每个元素创建一个新的Fiber
节点。在后续更新中,现有 React 元素的Fiber
节点将被重复使用和更新。这一阶段是为了得到标记了副作用的Fiber
节点树。
副作用描述了在下一个commit
阶段需要完成的工作。在当前阶段,React 持有标记了副作用的Fiber
树并将其应用于实例。它遍历副作用列表、执行 DOM
更新和用户可见的其他更改。
我们需要重点理解的是,第一个render
阶段的工作是可以异步执行的。React 可以根据可用时间片来处理一个或多个Fiber
节点,然后停下来暂存已完成的工作,并转而去处理某些事件,接着它再从它停止的地方继续执行。但有时候,它可能需要丢弃完成的工作并再次从顶部开始。
由于在此阶段执行的工作不会导致任何用户可见的更改(如 DOM
更新),因此暂停行为才有了意义。
与之相反的是,后续commit
阶段始终是同步的。这是因为在此阶段执行的工作会导致用户可见的变化,例如DOM
更新。这就是为什么 React 需要在一次单一过程中完成这些更新。
React 要做的一种工作就是调用生命周期方法。一些方法是在render
阶段调用的,而另一些方法则是在commit
阶段调用。
这是在第一个render
阶段调用的生命周期列表:
[UNSAFE_] componentWillMount
(弃用)
[UNSAFE_] componentWillReceiveProps
(弃用)
getDerivedStateFromProps
shouldComponentUpdate
[UNSAFE_] componentWillUpdate
(弃用)
render
正如你所看到的,从版本 16.3 开始,在render
阶段执行的一些保留的生命周期方法被标记为UNSAFE
,它们现在在文档中被称为遗留生命周期。它们将在未来的16.x
发布版本中弃用,而没有UNSAFE
前缀的方法将在17.0
中移除。
那么这么做的目的是什么呢?
好吧,我们刚刚了解到,因为render
阶段不会产生像DOM
更新这样的副作用,所以 React 可以异步处理组件的异步更新(甚至可能在多个线程中执行)。
但是,标有UNSAFE
的生命周期经常被误解和滥用。开发人员倾向于将带有副作用的代码放在这些方法中,这可能会导致新的异步渲染方法出现问题。虽然只有没有UNSAFE
前缀的对应方法将被删除,但它们仍可能在即将出现的并发模式(您可以选择退出)中引起问题。
接下来罗列的生命周期方法是在第二个 commit
阶段执行的:
getSnapshotBeforeUpdate
componentDidMount
componentDidUpdate
componentWillUnmount
因为这些方法都在同步的commit
阶段执行,他们可能会包含副作用,并对DOM
进行一些操作。
至此,我们已经有了充分的背景知识,下面我们可以看下用来遍历树和执行一些工作的通用算法。
Render 阶段
协调算法始终使用renderRoot
函数从最顶层的HostRoot
节点开始。不过,React 会略过已经处理过的Fiber
节点,直到找到未完成工作的节点。例如,如果在组件树中的深层组件中调用setState
方法,则 React 将从顶部开始,但会快速跳过各个父项,直到它到达调用了setState
方法的组件。
工作循环的主要步骤
所有的Fiber
节点都会在工作循环
中进行处理。如下是该循环的同步部分的实现:
function workLoop(isYieldy) {
if (!isYieldy) {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
} else {...}
}
在上面的代码中,nextUnitOfWork
持有workInProgress
树中的Fiber
节点的引用,这个树有一些工作要做:当 React 遍历Fiber
树时,它会使用这个变量来知晓是否有任何其他Fiber
节点具有未完成的工作。处理过当前Fiber
后,变量将持有树中下一个Fiber
节点的引用或null
。在这种情况下,React 退出工作循环并准备好提交更改。
遍历树、初始化或完成工作主要用到4
个函数:
performUnitOfWork
beginWork
completeUnitOfWork
completeWork
为了演示他们的使用方法,我们可以看看如下展示的遍历Fiber
树的动画。我已经在演示中使用了这些函数的简化实现。每个函数都需要对一个Fiber
节点进行处理,当 React 从树上下来时,您可以看到当前活动的Fiber
节点发生了变化。从GIF
中我们可以清楚地看到算法如何从一个分支转到另一个分支。它首先完成子节点的工作,然后才转移到父节点进行处理。
注意,垂直方向的连线表示同层关系,而折线连接表示父子关系,例如,b1 没有子节点,而 b2 有一个子节点 c1。
从概念上讲,你可以将开始
视为进入
一个组件,并将完成
视为离开
它。
我们首先开始研究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
执行工作所需要的活动。出于演示的目的,我们只log
出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;
}
你可以看到函数的核心就是一个大的while
的循环。当workInProgress
节点没有子节点时,React 会进入此函数。完成当前 Fiber 节点的工作后,它就会检查是否有同层节点。
如果找的到,React 退出该函数并返回指向该同层节点的指针。它将被赋值给 nextUnitOfWork
变量,React将从这个节点开始执行分支的工作。
我们需要着重理解的是,在当前节点上,React 只完成了前面的同层节点的工作。它尚未完成父节点的工作。只有在完成以子节点开始的所有分支后,才能完成父节点和回溯的工作。
从实现中可以看出,performUnitOfWork
和completeUnitOfWork
主要用于迭代目的,而主要活动则在beginWork
和completeWork
函数中进行。
commit 阶段
这一阶段从函数completeRoot
开始。在这个阶段,React 更新DOM
并调用变更生命周期之前及之后方法的地方。
当 React 进入这个阶段时,它有2
棵树和副作用列表。第一个树表示当前在屏幕上渲染的状态,然后在render
阶段会构建一个备用树。它在源代码中称为finishedWork
或workInProgress
,表示需要映射到屏幕上的状态。此备用树会用类似的方法通过child
和sibling
指针链接到current
树。
然后,有一个副作用列表(它是finishedWork
树的节点子集),通过nextEffect
指针进行链接。需要记住的是,副作用列表是运行render
阶段的结果。渲染的重点就是确定需要插入、更新或删除的节点,以及哪些组件需要调用其生命周期方法。这就是副作用列表告诉我们的内容,它页正是在 commit 阶段迭代的节点集合。
出于调试目的,可以通过Fiber
根的属性current
访问current
树。可以通过 current
树中HostFiber
节点的alternate
属性访问finishedWork
树。
在commit
阶段运行的主要函数是commitRoot
。它执行如下下操作:
在标记为
Snapshot
副作用的节点上调用getSnapshotBeforeUpdate
生命周期。在标记为
Deletion
副作用的节点上调用componentWillUnmount
生命周期。执行所有
DOM
插入、更新、删除操作。将
finishedWork
树设置为current
。在标记为
Placement
副作用的节点上调用componentDidMount
生命周期。在标记为
Update
副作用的节点上调用componentDidUpdate
生命周期。
在调用变更前方法getSnapshotBeforeUpdate
之后,React 会在树中提交所有副作用,这会通过两波操作来完成。
第一波执行所有 DOM(宿主)插入、更新、删除和 ref 卸载。然后 React 将finishedWork
树赋值给FiberRoot
,将 workInProgress
树标记为current
树。这是在提交阶段的第一波之后、第二波之前完成的,因此在componentWillUnmount
中前一个树仍然是current
,在componentDidMount/Update
期间已完成工作是current
。
在第二波,React 调用所有其他生命周期方法和引用回调。这些方法单独传递执行,从而保证整个树中的所有放置、更新和删除能够被触发执行。
以下是运行上述步骤的函数的要点:
function commitRoot(root, finishedWork) {
commitBeforeMutationLifecycles()
commitAllHostEffects();
root.current = finishedWork;
commitAllLifeCycles();
}
这些子函数中都实现了一个循环,该循环遍历副作用列表并检查副作用的类型。当它找到与函数目的相关的副作用时,就会执行。
更新前的生命周期方法
例如,这是在副作用树上遍历并检查节点是否具有Snapshot
副作用的代码:
function commitBeforeMutationLifecycles() {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
if (effectTag & Snapshot) {
const current = nextEffect.alternate;
commitBeforeMutationLifeCycles(current, nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
对于一个类组件,这一副作用意味着会调用getSnapshotBeforeUpdate
生命周期方法。
DOM 更新
commitAllHostEffects
是 React 执行DOM
更新的函数。该函数基本上定义了节点需要完成的操作类型,并执行这些操作:
function 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
函数中删除过程的一部分。
更新后的生命周期方法
commitAllLifecycles
是 React 调用所有剩余生命周期方法的函数。在 React 的当前实现中,唯一会调用的变更方法就是componentDidUpdate
。