人生乃是一场狂欢,快乐才是生活的真正目标,这背后的哲学就是,活在当下,热爱生活。
大家好,我是柒八九。
今天,{又双叒叕| yòu shuāng ruò zhuó}开辟了一个新的领域--前端框架。
这是继
这些模块,又新增的知识体系。说起前端框架,大家肯定第一时间会联想到Vue/React
,其实前端框架范围很广,它不应该被局限在Vue/React
等主流库,还有很多在某些领域大放异彩的库和框架。例如
Lit
- Google出的针对于
WebComponent
开发框架
Svelte
- 由
RollupJs
的作者编写的编译型框架 - 不再依赖
Vritual-DOM
进行页面构建
所以,我们在这个系列中,不仅仅会讲大家在工作中接触比较多的框架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元素树通常被称为{虚拟DOM| Virtual 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
标签是指一个React组件<ClickCounter>
- 以小写字母开头表示宿主组件或者自定义组件
<button>
/<p-test>
关于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需要执行不同的操作。在我们的示例应用程序中
- 对于类组件
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
属性构建成链表连接起来的。
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树
。
你可以从一个组件实例中获得一个单独的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 } 复制代码
::: block-1
stateNode
保存对与
fiber
节点相关的组件、DOM节点或其他React元素类型的类实例的引用
这个属性是用来保存与 fiber
相关的本地状态。 :::
::: block-1
type
定义了与该
fiber
相关的函数或类。
- 对于类组件,它指向构造函数
- 对于DOM元素,它指定了HTML标签
使用这个字段来了解一个fiber节点与什么元素有关。 :::
::: block-1
tag
定义了fiber的类型。
它在调和算法中被用来确定需要做什么工作。
如前所述,工作根据React元素的类型而不同。 函数 createFiberFromTypeAndProps
将一个React元素映射到相应的fiber节点
类型。
在上面的实例中,ClickCounter
组件的属性标签是 1,表示 ClassComponent
,对于 span
元素,它是 5,表示 HostComponent
。
:::
::: block-1
updateQueue
状态更新、回调和DOM更新的队列
:::
::: block-1
memoizedState
用于创建输出的fiber的
state
当处理更新时,它反映了当前屏幕上呈现的状态。
:::
::: block-1
memoizedProps
在上一次渲染过程中用于创建输出的
fiber
的props
。 :::
::: block-1
pendingProps
从React元素的新数据中更新的props,需要应用于子组件或DOM元素。
:::
::: block-1
key
用于在一组子
item
中唯一标识子项的字段。
以帮助React弄清哪些item
已经改变,已经从列表中添加或删除。
:::
5. 渲染算法
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+
版本的类组件的生命周期方法。
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
的相关处理操作。
函数
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
函数中。
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教程
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。