大家好,我是「柒八九」。
今天,又双叒叕yòu shuāng ruò zhuó开辟了一个新的领域--「前端框架」。
这是继
这些模块,又新增的知识体系。说起前端框架,大家肯定第一时间会联想到Vue/React
,其实前端框架范围很广,它不应该被局限在Vue/React
等主流库,还有很多在某些领域大放异彩的库和框架。例如
所以,我们在这个系列中,不仅仅会讲大家在工作中接触比较多的框架Vue/React
,还有带着大家一起去探索前端其他领域比较新奇,并在后续工作中有用武之地的技术方案。
而,今天我们先简单描述一下React-Fiber
的实现原理。
天不早了,我们干点正事哇。
这里给大家贴一个很早之前,画的关于Fiber
的结构图。
(如果,看不清,可私聊,索要原图)
文章概要
- 背景介绍
- 从 React 元素React Element 到 Fiber 节点Fiber Node
- 副作用Side-effects
- Fiber 树的根节点
- 渲染算法
前言
React
是一个用于「构建用户界面」的 JavaScript
库。
❝它的「核心」是「跟踪组件状态的变化」并将更新的状态投射到屏幕上。 在
React
中,我们把这个过程称为调和Reconciliation。我们调用setState
方法,框架会检查状态state或属性props是否发生了变化,并在用户界面上重新显示一个组件。 ❞
React的文档对该机制提供了一个很好的概述:React元素的作用,生命周期方法和渲染方法,以及应用于组件子代的 diffing
算法。从渲染方法返回的不可变immutable的「React元素树」通常被称为虚拟DOMVirtual DOM 。这个术语有助于在早期向人们解释React,但它也造成了混乱,在React文档中已不再使用。在这篇文章中,我将坚持称它为React元素树Tree of React elements。
❝除了「React元素树」,该框架有一棵「内部实例树」(组件、DOM节点等),「用来保持状态」。 从「16版」开始,React推出了一个「新的」内部实例树的实现,以及管理它的算法,代号为
Fiber
。 ❞
「请注意」,这篇文章是关于 React
内部如何工作的,可能下面讲的东西,不会在实际工作中产生任何帮助。
正如「亚里士多德」把知识分为三类
❝
- 第一类是「经验」,会做但不知道为什么这么做是对的;
- 第二类是知其然又知其所以然的「技术」,它来源于经验,是通过对经验的总结和归纳所形成的一般化理论;
- 第三类是没有用的、自己为自己而存在的知识,就是科学
❞
而我们平时在开发过程中,能够熟练使用React
来构建UI,这是一种经验,而我们却很少对React
内部实现原理深入了解,说明我们还未达到对React
这个技术的更深层次的掌握。只有,对技术细节有一定的了解,才可以在后续的技术升级或者技术改造中游刃有余。更甚者,能够自己撸一个低级版的 UI 库。
1. 背景介绍
这里有一个简单的应用程序,该例子将贯穿整篇文章。我们有一个按钮,可以简单地增加屏幕上显示的数字。
对应的代码如下:(这里我们用Component
实现)
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}>
更新数字
</button>,
<span key="2">
{this.state.count}
</span>
]
}
}
这是一个简单的组件,从渲染方法(render
)中返回两个子元素 button
和 span
。一旦你点击了按钮,组件的状态就会在处理程序中被更新。这反过来又会导致 span
元素的文本更新。
在「调和」过程中,React
会执行各种操作。例如,以下是 React
在我们构建的应用中,在「第一次渲染」和「状态更新后」所执行的操作。
- 更新
ClickCounter
的状态中的count
属性 -
「检索和比较」
ClickCounter
的子元素和它们的props
- 更新
span
元素的props
在「调和」过程中还有其他操作,如「调用生命周期方法」或更新ref
。「所有这些操作在 Fiber
架构中都被统称为」 工作Work。「工作的类型通常取决于React
元素的类型」。例如,对于一个类组件,React
需要创建一个实例,而对于一个函数组件,它不需要这样做。
如你所知,我们在 React
中有许多种类的元素。
- 类组件(React.Component)
- 函数组件
- 宿主组件(DOM节点)
-
Portals
(将子节点渲染成存在于父组件的DOM层次之外的DOM节点)
「React
元素的类型是由 createElement
函数的第一个参数定义的」。这个函数一般在render
方法中使用,用于创建一个元素。而在React
开发中,我们一般都使用JSX
语法来定义元素(而JSX
是createElement
的语法糖),「JSX 标签的第一部分决定了React元素的类型」。例如,
关于JSX,可以参考官网的,它有详细的解释。
在我们开始探索Fiber
算法之前,首先让我们熟悉一下React
内部使用的数据结构。
2. 从 React 元素React Element 到 Fiber 节点Fiber Node
❝
React
中的「每个组件都是一个UI表示」 ❞
这里是我们的 ClickCounter
组件的模板。
<button key="1" onClick={this.onClick}>
更新数字
</button>
<span key="2">
{this.state.count}
</span>
React 元素React Element
❝一旦模板通过JSX编译器JSX compiler,你最终会得到一堆「React元素」。「这就是真正从
React
组件的渲染方法中返回的东西,而不是HTML」。 ❞
如果不需要使用 JSX
语法,ClickCounter
组件的渲染方法可以重写如下方式。
class ClickCounter {
...
render() {
return [
React.createElement(
'button',
{
key: '1',
onClick: this.onClick
},
'更新数字'
),
React.createElement(
'span',
{
key: '2'
},
this.state.count
)
]
}
}
render
方法中对 React.createElement
的调用将「创建」这样的两个数据结构
[
{
$$typeof: Symbol(react.element),
type: 'button',
key: "1",
props: {
children: '更新数字',
onClick: () => { ... }
}
},
{
$$typeof: Symbol(react.element),
type: 'span',
key: "2",
props: {
children: 0
}
}
]
你可以看到 React
给这些对象添加了$$typeof
属性,可以「标识它们是React元素」。然后还有「描述元素的属性」 type
、key
和 props
,这些值取自你传递给React.createElement
函数的内容。
而 ClickCounter
的「React元素」没有任何props
或key
。
{
$$typeof: Symbol(react.element),
key: null,
props: {},
ref: null,
type: ClickCounter
}
Fiber 节点Fiber Node
❝在「调和过程」中,从
render
方法返回的「每个React元素的数据」都被合并到Fiber
节点的树中。 与React元素不同,fiber
「不会在每次渲染时重新创建」。这些是可变的数据结构mutable data structures,持有组件状态和DOM
信息 ❞
我们之前介绍过,根据「React元素的类型,React需要执行不同的操作」。在我们的示例应用程序中
因此,「每个React元素都被转换为相应类型的Fiber节点」,描述需要完成的工作。
❝可以把
fiber
看作是一个「数据结构」,它代表了一些要做的工作,或者说,「一个工作单位」。Fiber
的架构还提供了一种方便的方式来「跟踪、安排、暂停和中止」工作。 ❞
当一个「React元素」第一次被转换成一个「Fiber节点」时,React
使用该元素的数据在 createFiberFromTypeAndProps
函数中创建一个fiber
。在随后的更新中,React
「重用」Fiber节点,只是「使用来自相应 React元素 的数据更新必要的属性」。如果相应的React元素不再从渲染方法中返回,React可能还需要根据关键props
在层次结构中移动节点或删除它。
因为「React为每个React元素创建了一个fiber
节点」,由于我们有一个由元素组成的element 树
,所以我们也将有一个由fiber
节点组成的fiber树
。在我们的示例应用程序中,它看起来像这样。
所有的「Fiber节点」都是通过child
、sibling
和return
属性构建成「链表」连接起来的。
Current Tree 和 workInProgress Tree
❝「在第一次渲染之后,React 最终会有一个 Fiber 树,它反映了用来渲染 UI 的应用程序的状态」。这个树通常被称为当前树Current Tree。 当React开始「状态更新」时,它建立了一个所谓的workInProgress 树workInProgress Tree,反映了「未来」将被刷新到屏幕上的状态。 ❞
「所有的工作都在workInProgress树
的 fiber
上进行」。当React穿过current树
时,「对于每个现有的fiber节点,它创建一个备用节点,构成 workInProgress树」。这个节点是「使用render
方法返回的React元素的数据创建」的。一旦更新处理完毕,所有相关的工作都完成了,React
就会有一个备用的树,准备刷新到屏幕上。「一旦这个workInProgress树
被渲染到屏幕上,它就成为current
树」。
React
的核心原则之一是「一致性」。React
总是「一次性地更新DOM--它不会显示部分结果」。workInProgress树
作为一个用户不可见的草稿draft,这样 React
可以「先处理所有的组件,然后将它们的变化刷新到屏幕上」。
在源代码中,你会看到很多函数从current树
和WorkInProgress树
中获取fiber节点
。下面是一个这样的函数的签名。
function updateHostComponent(
current,
workInProgress,
) {...}
❝每个
fiber节点
通过alternate
属性保存着对「另一棵树」上的对应节点的引用。current树
的一个节点指向workInProgress树
的节点,反之亦然。 ❞
3. 副作用Side-effects
可以把React中的「组件看作是一个使用state和props来计算UI表现的函数」。
每一个操作,如「DOM的突变」或「调用生命周期方法」,都应该被视为一个「副作用」,或者简单地说,是一个效果effect。
❝从React组件中执行过「数据获取」、「事件订阅」或「手动改变DOM」。我们称这些操作为 "副作用"(或简称 "效果"),因为它们会影响其他组件,而且不能在渲染过程中进行。 ❞
你可以看到大多数state
和props
的更新都会导致副作用的产生。由于「应用效果是一种工作类型」,fiber节点
是一种方便的机制,除了更新之外,还可以跟踪效果。「每个fiber节点
都可以有与之相关的效果。它们被编码在 effectTag
字段中」。
所以「Fiber中的效果基本上定义了更新处理后需要对实例进行的工作」。
- 对于宿主组件(DOM元素),工作包括添加、更新或删除元素。
- 对于类组件,
React
可能需要「更新Refs」并调用componentDidMount
和componentDidUpdate
生命周期方法。
效果清单Effects list
React处理更新的速度非常快,为了达到这种性能水平,它采用了一些有趣的技术。其中之一是「建立一个带有效果的fiber节点
的线性列表」,以便快速迭代。「迭代线性列表要比树形快得多」,而且不需要在没有副作用的节点上花费时间。
这个列表的目的是「标记有DOM更新或其他与之相关的效果的节点」。这个「列表是 workInProgress 树
的一个子集」,并且使用 nextEffect
属性链接,而不是current
和 workInProgress
树中使用的 child
属性。
Dan Abramov对效果清单做了一个比喻。「把 React
应用想象成一棵圣诞树,用 "圣诞灯 "把所有有效果的节点绑在一起」。为了形象化这一点,让我们想象有下面的fiber节点树
,并且做一些操作,c2
被「插入」到DOM中,d2
和c1
「改变了属性」,b2
「触发了一个生命周期方法」。效果列表将它们联系在一起,这样React就可以在以后跳过其他节点。
从上图中可以看到带有效果的节点是如何连接在一起的。当访问这些节点时,React
使用 firstEffect
指针来计算「列表的开始位置」,用 nextEffect
将拥有效果的节点连接起来。所以上图可以表示为这样的一个线性列表。
4. Fiber 树的根节点
❝每个React应用程序都有一个或多个DOM元素,作为容器。 ❞
在我们的例子中,它是ID为容器的div元素。
const domContainer = document.querySelector('#container');
ReactDOM.render(
React.createElement(ClickCounter),
domContainer
);
「React为每个容器创建一个fiber-root
对象」。你可以使用DOM元素的引用来访问它。
const fiberRoot = query('#container')
._reactRootContainer
._internalRoot
「这个fiber-root
是React保存对fiber树
的引用的地方」。它被存储在fiber-root
的current
属性中。
const hostRootFiberNode = fiberRoot.current
fiber树
以一种特殊类型的fiber节点
开始,它就是 HostRoot
。它是在内部创建的,作为最上面的组件的父节点。通过 stateNode
属性,可以从 HostRoot
fiber节点访问到 FiberRoot
。
fiberRoot.current.stateNode === fiberRoot; // true
你可以通过fiberRoot
访问最上面的 HostRoot
fiber节点来访问fiber树
。
compInstance._reactInternalFiber
Fiber-Node的数据结构
现在让我们来看看为 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 元素的fiber节点
的结构。
{
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
}
stateNode
❝保存对与
fiber
节点相关的组件、DOM节点或其他React元素类型的类实例的「引用」 ❞
这个属性是用来保存与 fiber
相关的「本地状态」。
type
❝定义了与该
fiber
相关的「函数或类」。
❞
「使用这个字段来了解一个fiber节点与什么元素有关」。
tag
❝「定义了fiber的类型」。 它在调和算法中被用来确定需要做什么工作。 ❞
如前所述,「工作根据React元素的类型而不同」。函数 createFiberFromTypeAndProps
将一个React元素映射到相应的fiber节点
类型。
在上面的实例中,ClickCounter
组件的属性标签是 1,表示 ClassComponent
,对于 span
元素,它是 5,表示 HostComponent
。
updateQueue
❝「状态更新、回调和DOM更新的队列」 ❞
memoizedState
❝「用于创建输出的fiber的
state
」 当处理更新时,它反映了「当前屏幕上」呈现的状态。 ❞
memoizedProps
❝在「上一次渲染过程」中用于创建输出的
fiber
的props
。 ❞
pendingProps
❝从React元素的「新数据」中更新的props,需要应用于子组件或DOM元素。 ❞
key
❝用于在一组子
item
中「唯一标识」子项的字段。 ❞
以帮助React弄清哪些item
已经改变,已经从列表中添加或删除。
5. 渲染算法
❝React的工作主要分两个阶段进行:渲染Render和提交Commit。 ❞
在render
阶段,React
通过 setState
或 React.render
对预定的组件进行更新,并找出UI中需要更新的内容。
该阶段的结果是「一棵标有副作用的fiber节点树」。这些效果描述了在接下来的「提交阶段」需要做的工作。在commit
阶段,React
遍历标有效果的fiber树
,并将效果应用于实例。它遍历effect列表
,执行DOM更新和其他用户可见的变化。
重要的是,render
阶段的工作可以「异步进行」。React
可以根据「可用的时间」来处理一个或多个fiber节点
,然后停下来,把「已经完成的工作储存起来,并将处理fiber
的操作」暂停yield。然后从上次离开的地方继续。但有时,可能需要丢弃已完成的工作并从头开始。针对在这个阶段执行的工作的暂停操作「不会导致任何用户可见的UI变化」,如DOM更新。相比之下,接下来的「提交阶段总是同步的」。这是因为在这个阶段进行的工作会导致用户可见的变化,例如DOM更新。这就是为什么React需要一次性完成这些工作。
调用生命周期的方法是React执行的一种工作类型。有些方法是在render
阶段调用的,有些是在commit
阶段调用的。下面是在「render阶段工作时调用的生命周期的列表」。
-
[UNSAFE_]componentwillMount
(废弃) -
[UNSAFE_]componentwillReceiveProps
(废弃) static getDerivedStateFromProps
shouldComponentUpdate
-
[UNSAFE_]componentwillUpdate
(废弃) render
正如你所看到的,从16.3版本开始,一些在渲染阶段执行的传统生命周期方法被标记为 UNSAFE
。它们现在在文档中被称为「遗留生命周期」。它们将在未来的16.x
版本中被废弃。
我们来简单解释下,为什么会有生命周期会被遗弃。
由于render
阶段不会产生像DOM更新那样的副作用,React可以异步处理组件的更新(甚至有可能在多个线程中进行)。然而,标有 UNSAFE
的生命周期经常被滥用。开发者倾向于将有副作用的代码放在这些方法中,这可能会「给新的异步渲染方法带来问题」。
下面是在commit
阶段执行的生命周期方法的列表。
getSnapshotBeforeUpdate
componentDidMount
componentDidUpdate
componentwillUnmount
因为这些方法在「同步提交阶段执行」,它们可能包含副作用并触及DOM。
这里我们贴一个针对react-16.4+
版本的类组件的生命周期方法。
Render 阶段
❝「调和算法」总是使用
renderRoot
函数从最上面的HostRoot
fiber节点开始。然而,React会跳过已经处理过的fiber节点
,直到「找到工作未完成的节点」。 ❞
例如,如果你在组件树的深处调用 setState
,React会从顶部开始,但迅速跳过父节点,直到它到达调用了setState方法的组件。
workLoop
主要流程
❝「所有」fiber节点都在
workLoop
中被处理 ❞
下面是该循环的同步部分的实现。
function workLoop(isYieldy) {
if (!isYieldy) {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performunitOfWork(nextUnitOfWork);
}
} else {...}
}
在上面的代码中,nextUnitOfWork
持有对来自 workInProgress 树
的fiber节点
的引用,该节点有一些工作要做。当 React
遍历 Fiber 树
时,它「使用这个变量来了解是否还有其他未完成工作的 Fiber 节点」。处理current fiber
后,该变量将包含对树中下一个fiber节点
的引用或为空。
有 4 个主要函数用于遍历树并启动或完成工作:
performunitOfWork
beginWork
completeUnitOfWork
completeWork
为了演示如何使用它们,请查看以下遍历fiber树
的动图。每个函数都接收一个fiber节点
并对其处理,当 React
沿着树向下移动时,您可以看到当前活动的fiber节点
发生了变化。「它先完成孩子节点的处理,再转向其父节点」
❝请注意,「垂直连接」表示兄弟节点,而「水平连接」表示子节点, 例如
b1
没有孩子,而b2
有一个孩子c1
。 ❞
可以将begin
视为「进入」组件,将complete
视为「退出」组件。
我们简单的分析「开始阶段的函数」 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
的相关处理操作。
「节点完成后,需要为兄弟姐妹执行处理,然后回溯到父节点」。这些操作是在 completeUnitOfWork
函数中完成的:
function completeUnitOfWork(workInProgress) {
while (true) {
let returnFiber = workInProgress.return;
let siblingFiber = workInProgress.sibling;
nextUnitOfWork = completeWork(workInProgress);
if (siblingFiber !== null) {
// 如果存在兄弟节点,将其返回并对其处理
return siblingFiber;
} else if (returnFiber !== null) {
// 兄弟节点不存在,父节点存在,返回父节点
workInProgress = returnFiber;
continue;
} else {
// 到达该分支的尾端
return null;
}
}
}
function completeWork(workInProgress) {
console.log('work completed for ' + workInProgress.name);
return null;
}
从代码中可以看到该函数有一个「很大的 while
循环」。当 workInProgress
节点没有子节点时,React
会进入此函数。在完成current fiber
的工作后,它会检查是否有兄弟姐妹。如果找到,React
退出函数并「返回指向兄弟的指针」。它将被分配给 nextUnitOfWork
变量,React
将从这个兄弟节点开始执行分支的工作。重要的是要理解,「此时 React
只完成了前面的兄弟姐妹的工作」。它还没有完成父节点的工作。「只有从子节点开始的所有分支都完成后,它才能执行回溯操作并完成父节点的工作」。
从代码实现中可以看出,performunitOfWork
和 completeUnitOfWork
都主要用于「迭代」,而「主要操作发生在 beginWork
和 completeWork
函数中」。
Commit 阶段
该阶段从函数 completeRoot
开始。这是 React
更新 DOM 并调用「变动前后」生命周期方法的地方。
当 React
进入这个阶段时,它「有 2 棵树」。
- 「第一个树」代表当前在屏幕上呈现的状态。
-
「第二个树」是在「render阶段」构建了一个备用树alternate tree。
它在源代码中称为
finishedWork
或workInProgress
,表示「需要」在屏幕上反映的状态。 该备用树通过child
指针和sibling
指针进行各个节点的连接。
还有一个「效果列表」——来自finishedWork
树的节点「子集」,通过 nextEffect
指针链接。请记住,「效果列表是render
阶段的结果」。渲染的重点是确定哪些节点需要插入、更新或删除,哪些组件需要调用其生命周期方法。这就是效果列表告诉我们。「它正是在commit
阶段需要处理的节点集」。
在commit
阶段运行的主要函数是 commitRoot
。基本上,它执行以下操作:
- 在标记有
Snapshot
效果的节点上调用getSnapshotBeforeUpdate
生命周期方法 - 在标记有
Deletion
效果的节点上调用componentwillUnmount
生命周期方法 - 执行所有 DOM 插入、更新和删除
- 将
finishedWork
设置为current
- 在标记有
Placement
效果的节点上调用componentDidMount
生命周期方法 - 在标记有
Update
效果的节点上调用componentDidUpdate
生命周期方法
在调用方法 getSnapshotBeforeUpdate
之后,React
将提交树中的所有副作用。它「分两次完成」。
-
「第一遍」执行所有
DOM
插入、更新、删除和ref
卸载。 然后React
将finishedWork
树分配给FiberRoot
,并将workInProgress 树
标记为current 树
。 - 在「第二遍中」,
React
调用所有生命周期方法和ref
回调。
以下是运行上述步骤的函数的要点:
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);
...
}
}
}
突变后的生命周期方法
commitAllLifecycles
是 React
调用「所有剩余生命周期方法」 componentDidUpdate
和 componentDidMount
的函数。
后记
「分享是一种态度」。
参考资料:
- React-Fiber
- React官网
- 拉钩教育-React教