大家好,我是「柒八九」。
今天,又双叒叕yòu shuāng ruò zhuó开辟了一个新的领域--「前端框架」。
这是继
这些模块,又新增的知识体系。说起前端框架,大家肯定第一时间会联想到Vue/React
,其实前端框架范围很广,它不应该被局限在Vue/React
等主流库,还有很多在某些领域大放异彩的库和框架。例如
Lit
WebComponent
开发框架Svelte
RollupJs
的作者编写的「编译型框架」Vritual-DOM
进行页面构建所以,我们在这个系列中,不仅仅会讲大家在工作中接触比较多的框架Vue/React
,还有带着大家一起去探索前端其他领域比较新奇,并在后续工作中有用武之地的技术方案。
而,今天我们先简单描述一下React-Fiber
的实现原理。
天不早了,我们干点正事哇。
这里给大家贴一个很早之前,画的关于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 库。
这里有一个简单的应用程序,该例子将贯穿整篇文章。我们有一个按钮,可以简单地增加屏幕上显示的数字。
对应的代码如下:(这里我们用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
中有许多种类的元素。
Portals
(将子节点渲染成存在于父组件的DOM层次之外的DOM节点)「React
元素的类型是由 createElement
函数的第一个参数定义的」。这个函数一般在render
方法中使用,用于创建一个元素。而在React
开发中,我们一般都使用JSX
语法来定义元素(而JSX
是createElement
的语法糖),「JSX 标签的第一部分决定了React元素的类型」。例如,
JSX
标签是指一个「React组件」 <ClickCounter>
<button>
/<p-test>
关于JSX,可以参考官网的,它有详细的解释。
在我们开始探索Fiber
算法之前,首先让我们熟悉一下React
内部使用的数据结构。
❝
React
中的「每个组件都是一个UI表示」 ❞
这里是我们的 ClickCounter
组件的模板。
<button key="1" onClick={this.onClick}>
更新数字
</button>
<span key="2">
{this.state.count}
</span>
❝一旦模板通过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
}
❝在「调和过程」中,从
render
方法返回的「每个React元素的数据」都被合并到Fiber
节点的树中。 与React元素不同,fiber
「不会在每次渲染时重新创建」。这些是可变的数据结构mutable data structures,持有组件状态和DOM
信息 ❞
我们之前介绍过,根据「React元素的类型,React需要执行不同的操作」。在我们的示例应用程序中
ClickCounter
,它调用生命周期方法和渲染方法span
宿主组件(DOM节点),它执行DOM变异。因此,「每个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
属性构建成「链表」连接起来的。
❝「在第一次渲染之后,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树
的节点,反之亦然。 ❞
可以把React中的「组件看作是一个使用state和props来计算UI表现的函数」。
每一个操作,如「DOM的突变」或「调用生命周期方法」,都应该被视为一个「副作用」,或者简单地说,是一个效果effect。
❝从React组件中执行过「数据获取」、「事件订阅」或「手动改变DOM」。我们称这些操作为 "副作用"(或简称 "效果"),因为它们会影响其他组件,而且不能在渲染过程中进行。 ❞
你可以看到大多数state
和props
的更新都会导致副作用的产生。由于「应用效果是一种工作类型」,fiber节点
是一种方便的机制,除了更新之外,还可以跟踪效果。「每个fiber节点
都可以有与之相关的效果。它们被编码在 effectTag
字段中」。
所以「Fiber中的效果基本上定义了更新处理后需要对实例进行的工作」。
React
可能需要「更新Refs」并调用 componentDidMount
和 componentDidUpdate
生命周期方法。React处理更新的速度非常快,为了达到这种性能水平,它采用了一些有趣的技术。其中之一是「建立一个带有效果的fiber节点
的线性列表」,以便快速迭代。「迭代线性列表要比树形快得多」,而且不需要在没有副作用的节点上花费时间。
这个列表的目的是「标记有DOM更新或其他与之相关的效果的节点」。这个「列表是 workInProgress 树
的一个子集」,并且使用 nextEffect
属性链接,而不是current
和 workInProgress
树中使用的 child
属性。
Dan Abramov对效果清单做了一个比喻。「把 React
应用想象成一棵圣诞树,用 "圣诞灯 "把所有有效果的节点绑在一起」。为了形象化这一点,让我们想象有下面的fiber节点树
,并且做一些操作,c2
被「插入」到DOM中,d2
和c1
「改变了属性」,b2
「触发了一个生命周期方法」。效果列表将它们联系在一起,这样React就可以在以后跳过其他节点。
从上图中可以看到带有效果的节点是如何连接在一起的。当访问这些节点时,React
使用 firstEffect
指针来计算「列表的开始位置」,用 nextEffect
将拥有效果的节点连接起来。所以上图可以表示为这样的一个线性列表。
❝每个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树
。
你可以从一个组件实例中获得一个单独的fiber节点
。
compInstance._reactInternalFiber
现在让我们来看看为 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
}
❝保存对与
fiber
节点相关的组件、DOM节点或其他React元素类型的类实例的「引用」 ❞
这个属性是用来保存与 fiber
相关的「本地状态」。
❝定义了与该
fiber
相关的「函数或类」。
❞
「使用这个字段来了解一个fiber节点与什么元素有关」。
❝「定义了fiber的类型」。 它在调和算法中被用来确定需要做什么工作。 ❞
如前所述,「工作根据React元素的类型而不同」。函数 createFiberFromTypeAndProps
将一个React元素映射到相应的fiber节点
类型。
在上面的实例中,ClickCounter
组件的属性标签是 1,表示 ClassComponent
,对于 span
元素,它是 5,表示 HostComponent
。
❝「状态更新、回调和DOM更新的队列」 ❞
❝「用于创建输出的fiber的
state
」 当处理更新时,它反映了「当前屏幕上」呈现的状态。 ❞
❝在「上一次渲染过程」中用于创建输出的
fiber
的props
。 ❞
❝从React元素的「新数据」中更新的props,需要应用于子组件或DOM元素。 ❞
❝用于在一组子
item
中「唯一标识」子项的字段。 ❞
以帮助React弄清哪些item
已经改变,已经从列表中添加或删除。
❝React的工作主要分两个阶段进行:渲染Render和提交Commit。 ❞
在render
阶段,React
通过 setState
或 React.render
对预定的组件进行更新,并找出UI中需要更新的内容。
React
为render
方法返回的每个元素创建一个「新的」fiber节点
。React元素
的fiber
被「重新使用和更新」。该阶段的结果是「一棵标有副作用的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+
版本的类组件的生命周期方法。
❝「调和算法」总是使用
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
的相关处理操作。
❝函数
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) {
// 如果存在兄弟节点,将其返回并对其处理
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
函数中」。
该阶段从函数 completeRoot
开始。这是 React
更新 DOM 并调用「变动前后」生命周期方法的地方。
当 React
进入这个阶段时,它「有 2 棵树」。
finishedWork
或 workInProgress
,表示「需要」在屏幕上反映的状态。
该备用树通过child
指针和sibling
指针进行各个节点的连接。还有一个「效果列表」——来自finishedWork
树的节点「子集」,通过 nextEffect
指针链接。请记住,「效果列表是render
阶段的结果」。渲染的重点是确定哪些节点需要插入、更新或删除,哪些组件需要调用其生命周期方法。这就是效果列表告诉我们。「它正是在commit
阶段需要处理的节点集」。
在commit
阶段运行的主要函数是 commitRoot
。基本上,它执行以下操作:
Snapshot
效果的节点上调用 getSnapshotBeforeUpdate
生命周期方法Deletion
效果的节点上调用 componentWillUnmount
生命周期方法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
生命周期方法。
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
的函数。
「分享是一种态度」。
参考资料: