原文地址:
如何以及为何从React组件到Fiber节点的一切内容
React使用一个构建用户界面的JavaScript库,它的核心是跟踪组件状态的的变化,然后将更新的状态投影在屏幕上。在React中,我们把这个过程称为协调。我们调用setState方法后,框架会检测state和prop是否发生变化,并重新渲染UI组件。
React文档关于这个机制提供了: React元素的角色,生命周期方法,render
方法,以及应用于子组件的diff算法。由render
方法返回的不可变的React元素普遍被认为是React的“虚拟DOM”。那个术语早期帮助React解释给人们,但它也造成了一些困惑,也不再在React文档中使用,这篇文章中,我会继续称之为React元素的树。
除了React元素的树,框架总还有用于保留状态的内部实例(组件,DOM节点等)的一棵树。从16版本开始,React推出了内部实例树的新实现,以及管理它的算法(代码上称为Fiber)。想要得知Fiber架构带来的好处,可以参见。
这篇文章花费了我很多时间,而且要是没有 ! ?的个帮助,也不会讲解地如此全面。
这是给你讲解React内部架构系列的第一篇文章。这篇文章中,我想提供算法中重要概念和数据结构的深度概述。一旦我们有足够的背景,我们就可以探索这个算法以及用于遍历和操作fiber树的主要方法。系列中的下一篇文章将示范React如何使用这个算法来初始render以及操作state和props的更新,从那里我们将了解到调度(scheduler)的细节、子协调(child reconciliation)操作以及构建更新链表(effect list)。
这里我将给你讲述相当高级的内容,我保证你阅读后可以理解到并发(Concurrent)React内部工作背后的神奇。如果你想成为React的贡献者的话,这个系列的文章也可以作为你的向导。我一个(就是喜欢死磕源码),所以这里有很多关于React@16.6.0的资源链接。
这确实牵扯很多内容,所以如果你没有马上理解也不必有很大压力,一切都值得花时间。需要注意的是你不必了解这些来使用React,这篇文章是关于React如何内部工作的。
设置一个背景
这里有个我们在整个系列中都会使用到的简单应用。我们有个button,简单的增加数字,然后渲染到屏幕上。
这是实现:
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 [ , {this.state.count} ] }}复制代码
你可以在去执行它。正如你看到的,它是一个简单组件,通过render
方法返回button
和span
两个子组件。只要你点击button,组件的状态就会在处理器中更新,这继而导致span元素中的text的更新。
React在**协调(reconciliation)**期间有执行很多活动,例如,React在第一次render时执行的操作,以及在我们这个简单的应用中状态更新之后:
- 更新
ClickCounter
的state
中的count
参数 - 获取和比较
ClickCounter
的子组件以及他们的props
- 更新
span
元素的props
在协调期间还执行其他活动,像声明或者更新。所有这些活动在Fiber架构中统一起来被定义为一个“工作(work)”。工作的类型通常取决于React元素(element)的类型,例如,对于一个类组件(class component),React需要创建实例,而对于方法组件(function component)则不需要这样。正如你所知,React中有很多种元素,如类组件、方法组件、host组件(DOM节点)以及Portal等。元素的类型被定义在方法中的第一个参数,这个方法通常用在render
方法中来场景一个元素。
在我们探索这些执行的活动以及主要的Fiber算法时,我们先来对React内部使用的数据结构有个认识。
从React元素到Fiber节点
React中每个组件是一个UI表示,我们可以叫它视图(view)或者模板(template),它由render
方法返回。这里便是我们ClickCounter
的模板:
{this.state.count}复制代码
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在这些对象上添加当作React元素的来唯一标示,且我们还有type
、key
和props
来描述这个元素,这些值由你传给了React.createElement
方法。这里注意,React是如何把文本内容表达成span
和button
节点的孩子,click处理如何成为button
元素props的一部分,这里还有React元素上其他一些字段如ref
已经超出了本文的范畴。
React元素ClickCounter
没有任何props或者key:
{ $$typeof: Symbol(react.element), key: null, props: {}, ref: null, type: ClickCounter}复制代码
Fiber节点
在**协调(reconciliation)**期间,由render
方法返回的每个React元素都将合并到fiber节点的树中,每个React元素都有相对应的fiber节点,不像React元素,fiber不会在每次render时重新创建。这些可变的数据结构带有组件的状态以及DOM。
我们之前讨论的是框架根据React元素的类型来执行不同的活动,在我们简单的应用中,对于类组件ClickCounter
,它调用生命周期和render
方法,而span
host组件(DOM节点)则执行DOM变化,所以每个React元素相对应的Fiber节点,这些类型描述了需要完成的工作。
当React元素首次被转化成fiber节点时,React在方法中使用这个元素中的数据创建fiber,在之后发生的更新中,React重用这个fiber节点,且通过相对应的React元素中数据更新必要的属性。
React也可能需要基于key
属性在层级中移动节点,或者如果对应的React元素不再由render
方法返回时,则删除掉它。
找出方法,你可以看到所有活动的列表,以及React在当前存在fiber节点上执行的对应方法。
因为React为每个React元素都创建了一个fiber,所以只要我们有这些元素的一棵树,那我们就会有fiber节点的一棵树。在我们简单应用案例中,它看起来如下:
所有fiber节点通过一个链表链接起来,这个链表使用了fiber节点中属性:child
、sibling
和return
。关于如何和为何这种方式,可以查阅我的文章。
当前和正在执行的树(Current and work in progress trees)
在第一次渲染(render)之后,React最后得到了一颗fiber树,它反映了用于渲染UI的应用的状态,这颗树被当作current。当React开始处理更新时,它构建所谓的workInProgress
树来反映将来刷新屏幕的状态。
所有工作都在来自workInProgress
树的fiber上执行。当React经过当前树时,对于每一个先存在的fiber节点,它都会创建一个替代(alternate)节点,这些节点组成了workInProgress
树。这个节点是使用render
方法返回的React元素的数据创建的。一旦更新处理完以及所有相关工作完成,React就有一颗替代树来准备刷新屏幕。一旦这颗workInProgress
树渲染(render)在屏幕上,它便成了当前树。
React的设计原则之一是连贯性。React总是一次性更新DOM,而不是只显示部分结果。这颗workInProgress
树为当做是‘草稿’,它对用户是不可见的,以至于React可以先处理所有组件,然后再刷新他们的改变到屏幕上。
在这个代码中,可以看到很多方法,这些方法持有来着current
和workInProgress
树的fiber节点,这是一个这样方法的签名:
function updateHostComponent(current, workInProgress, renderExpirationTime) {...}复制代码
每个fiber节点中的alternate字段持有它的一个副本,这个副本节点表示current
树指向workInProgress
树的,反之亦然,代码如下:
function createWorkInProgress(current, ...) { let workInProgress = current.alternate; if (workInProgress === null) { workInProgress = createFiber(...); } ... workInProgress.alternate = current; current.alternate = workInProgress; ... return workInProgress;}复制代码
副作用(side-effects)
我们可以认为React中组件是使用state和props的方法,用于计算UI展示。每个其他活动,像DOM变化或者调用生命周期方法,应当认为是一个副作用,或者一个简单的作用。作用(Effects)也在中提及。
你以前可能做过请求数据,订阅,或者在React组件中手动修改DOM,我们把这些操作叫做副作用(或者简说作用),因为它们会影响其他组件,且不能在渲染时完成。
你可以看到很多state和props是如何造成副作用的,
既然应用副作用是一个工作的类型,那一个fiber节点就是除了更新之外还用于跟踪作用的简明机制。每一个fiber节点可以有很多关联的作用,它们被编码到effectTag
字段中(effectTag使用位运算的妙处啦)。
所以Fiber中的作用(effects)基本上定义了一个组件实例在其更新操作之后需要完成的,对于host组件(DOM元素),这个工作包括更新、添加和删除元素;对于类组件,React可能需要更新refs
,以及调用componentDidMount
和componentDidUpdate
生命周期方法。这里当然还有其他一些与fiber类型相对应的作用。
作用列表(Effects list)
React处理更新很快,为了实现这个层次的性能,它采用了个别有趣的技巧,比如,将含有作用的fiber节点用线性列表表示,从而可以快速迭代。迭代线性列表比树要快,且可以不必花时间在没有副作用的节点上。
这个列表的目的是用于标记一些节点,这些节点有DOM更新或者与其关联的副作用。这个列表是finishedWork
的子集,且通过nextEffect
属性链接起来,而不是current
和workInProgress
树中使用的child
属性。
对作用列表作了一个类比,就像一颗圣诞树中通过“圣诞灯”来把所有作用节点连接起来。为了虚拟化它,我们设想以下这颗fiber树,其中点亮的节点有一些工作要做,例如,我们更新使得c2
插入DOM中、d2
和c1
改变属性,以及d2
触发生命周期方法,那作用列表就讲它们连接起来,以至于React之后可以滤过其他节点:
你可以看到含有作用的节点和如何连接起来。当要遍历这些节点时,React使用firstEffect
得出列表从哪里开始,那上述的示意图可以用线性列表如下表示:
正如你所见,React执行作用的顺序是从子向上到父的。
Fiber树的根节点(Root of the fiber tree)
每个React应用有一个或多个DOM元素作为容器,在我们的例子中,它是ID是container的div
元素:
const domContainer = document.querySelector('#container');ReactDOM.render(React.createElement(ClickCounter), domContainer);复制代码
React为这些容器的每个创建一个,你可以通过DOM元素的引用访问它:
const fiberRoot = query('#container')._reactRootContainer._internalRoot复制代码
这个fiber根节点就是React持有fiber树引用的地方,它保存在fiber根节点的current
属性上:
const hostRootFiberNode = fiberRoot.current复制代码
fiber树开始于HostRoot
的fiber节点的,它由内部创建并将顶层组件作为父节点。这里有一个通过从stateNode
属性从HostRoot
fiber节点返回到FiberRoot
的连接:
fiberRoot.current.stateNode === fiberRoot; // true复制代码
你可以通过fiber根节点获取HostFiber
节点来探索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
和nextEffect
的目的,现在我看看为何还需要其他字段。
stateNote
持有类组件实例、DOM节点或者其他与这个fiber节点关联的React元素类型的引用,一般来说,我们可以说这个属性用来持有与fiber相关的本地状态。
type
定义与这个fiber关联的方法或者类,对于类组件,它指向类的构造方法;对于DOM元素,它具体为HTML标签;我经常用这个字段来理解fiber节点关联的是什么元素。
tag
定义了,这个用来在协调算法中定义那些工作需要完成。如之前所说,工作的不同取决于React元素类型,方法映射了一个React元素到相对应fiber节点类型。在我们的例子应用中,ClickCounter
组件的tag
属性值为1,代表了ClassComponent
,以及span
组件的是5,代表了HostComponent
。
updateQueue
一个包括状态更新、callbacks以及DOM更新的队列。
memoizedState
fiber中用于创建输出的状态,当处理更新时,它反映了当前已经渲染在屏幕上的状态。
memoizedProps
fiber中在前一次渲染时用于创建输出的props。
pendingProps
由React元素中的新数据而来的已经更新过的props,且需要应用于子组件或者DOM元素。
key
一组子组件的唯一标示,用于React得出列表中哪个改变了、添加了或者删除了。这个与React中在描述的的“列表与key”的功能相关。
你可以从得到fiber节点的整个数据结构。我滤过了一些上面解释过的字段。特别是我跳过了**child
、sibling
和return
,这些在我中介绍过了。还有一类字段像expirationTime
、childExpirationTime
以及mode
特定用于调度(Scheduler)**。
整体算法
React执行工作主要有两个阶段:render 和 commit。
在第一个render
阶段,React执行由setState
或者React.render
调度的组件上的更新,且得出在UI上哪些需要被更新,如果它是初始渲染,那React会为每个有render
方法返回的元素创建一个新的fiber节点,在后续的更新中,当前存在React元素的fiber会被重用和更新。这个阶段的结果是一颗fiber节点被标记副作用的树。这些作用被描述为在接下来的commit
阶段中需要完成的工作,在这个阶段,React取标记作用的fiber树并把它们应用到实例上,遍历作用列表且执行DOM更新以及其他用户可见的变化。
理解在第一个render阶段中执行的工作可以是异步的很重要。React在可用的时间内能处理一个或多个fiber节点,然后停止来保存完成的工作并妥协于一些事件(比如优先级高的UI事件),它之后可以在之前离开的方法在继续执行,然而有时可能会丢弃已完成的工作,并从顶层重来。由于这个阶段的执行的工作不会导致用户可见的变化(如DOM更新),所以这个暂停是可行的。不同的是,接下来的commit
阶段总是同步的,因为这个阶段的执行的工作会导致用户可见的变化,这也是为什么React一把完成它们的原因。
调用生命周期方法是React执行的一种工作类型,一些方法执行在render
阶段,一些执行在commit
阶段,下面是在render
阶段中执行的生命周期方法列表:
- [UNSAFE_]componentWillMount (deprecated)
- [UNSAFE_]componentWillReceiveProps (deprecated)
- getDerivedStateFromProps
- shouldComponentUpdate
- [UNSAFE_]componentWillUpdate (deprecated)
- render
正如你所见,一些在render阶段
中被遗留的方法从16.3版本开始被标记为UNSAFE
,它们在16.x的release版本中被弃用掉了,而它们不带UNSAFE
前缀的副本在17.0中将被移除,你可以在阅读更多关于这些改变,以及迁移建议的内容。
你好奇这个的原因吗?
那,我们已经得知**render
阶段不会造成副作用(如DOM更新),且React可以对组件异步处理更新(且在的说,甚至可以在多线程中执行)。然后被标记了UNSAFE
的声明周期总是被误解或者不易察觉的误用,开发者倾向于把有副作用的逻辑放在这些方法中,这在新的异步渲染策略中可能会导致一些问题。尽管只有他们未标记UNSAFE
**前缀的副本被移除掉了,但它们仍然可能在未来的并发模型(Concurrent Mode)中造成问题,当然这个模式你可以不启用。
这里是**commit
**阶段执行的生命周期方法列表:
- getSnapshotBeforeUpdate
- componentDidMount
- componentDidUpdate
- componentWillUnmount
因为这些方法在同步的**commit
**阶段执行,所有它们可以包含副作用以及触控DOM。
好,我们现在已经有了一定的基础去看看用于遍历树和执行工作的算法。
Render阶段
这个协调算法总是开始于顶层的HostRoot
fiber节点,这个节点由方法创建,然而React能够跳过已经处理过的fiber节点,直到它找到尚未完成工作的节点,例如,如果你在一个组件树深处调用setState
,React将从顶部开始,但是很快就跳过一些节点,找到调用setState
方法的组件。
工作循环(work loop)中的主要步骤
所有的fiber节点都会在做处理,这里是这个循环的同步部分的实现:
function workLoop(isYieldy) { if (!isYieldy) { while (nextUnitOfWork !== null) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork); } } else {...}}复制代码
在上面代码中,nextUnitOfWork
持有一个fiber节点,这个节点来自还有一些工作需要做的workInProgress
树,正如React遍历fiber树一样,它使用这个变量来知道是否还有其他未完成工作的fiber节点,当前fiber节点处理之后,这个遍历将要么获取下一个fiber节点的引用,要么为null
,在**null
**的情况下,React将退出工作循环,并准备提交更新。
这里有4个主要的方法,用于遍历树,以及初始化或者完成工作:
为了示例它们是怎么用的,看看下面遍历fiber树的动画。我以及用demo把这些方法做了个简单是的实现,每个方法都会取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知道到达了树枝的末尾,所有就可以完成(complete)当前这个节点。一个节点只要完成了,它就会需要从兄弟和父级节点继续执行工作,这在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
循环,React当workInProgress
节点没有孩子时就进入这个方法。在完成当前fiber的工作后,它检查是否有还有兄弟,如果有,React退出这个方法,并返回指向兄弟的指针,它将会赋值给nextUnitOfWork
**变量,然后React将通过兄弟节点在新树枝上开始执行工作。重要的是明白这种情况中React只有是前面的兄弟节点完成了工作,而它还没有完成父节点的工作,只有所有开始于子节点的树枝上的工作完成了,它才算是为父节点完成了工作,然后原路返回。
正如你从实现中所见,**performUnitOfWork
和completeUnitOfWork
方法的目的几乎是迭代,而主要活动发生在beginWork
和completeWork
方法中。在这个系列接下来的文章中,我们将知道,当React进入beginWork
和completeWork
方法中时,ClickCounter
组件和span
**节点会发生什么。
Commit阶段
这个阶段开始于方法,这里React便会更新DOM,以及调用前前后置突变生命周期方法。
当React进入这个阶段时,它有两颗树和一个作用列表,第一颗树表示了当前渲染在屏幕上的状态,而这里还有在**render
阶段构建的一颗替代树,它调用代码中finishedWork
和workInProgress
,表示需要在屏幕上反应出来的状态,这颗替代树链接方式类似当前树,通过child
和sibling
**指针链接。
还有作用列表——通过**nextEffect
连接起来的finishedWork
树的节点子集。记住作用列表是在render
**阶段生成,整个渲染(rendering)的要点就是得出哪些节点需要插入、更新、删除,以及哪些组件需要执行它们的生命周期方法,这便是作用列表要告诉我们的,这是会在commit阶段中被迭代的节点集合。
为了debugging,当前树可以通过fiber根节点**
current
属性方法,finishedWork
树可以通过当前树上的HostFiber
节点的alternate
**来访问。
主要运行在commit阶段的方法是,大致如下操作:
- 标记了**
Snapshot
作用的节点执行getSnapshotBeforeUpdate
**生命周期方法。 - 标记了**
Deletion
作用的节点执行componentWillUnmount
**生命周期方法。 - 执行所有DOM的插入、更新和删除。
- 把**
finishedWork
**树置为当前树。 - 标记了**
Placement
作用的节点执行componentDidMount
**生命周期方法。 - 标记了**
Update
作用的节点执行componentDidUpdate
**生命周期方法。
调用前置突变方法**getSnapshotBeforeUpdate
之后,React提交了树中所有副作用。它以两个步骤来做,第一步是执行所有DOM(host)的插入、更新和删除以及ref的卸载,然后React把finishedWork
树赋值给FiberRoot
,即当workInProgress
树为current
树,这在commit阶段的第一步和第二步之间执行,便于之前的树在componentWillUnmount
是还是当前树,而在componentDidMount/Update
**时,完成树(finished work)为当前树。在第二步中,React调用其他所有生命周期方法和ref回调,这些方法在单独步骤中执行,以致整个树中所有的替换、更新和删除已经被调用。
这里运行上述描述方法的大意:
function commitRoot(root, finishedWork) { commitBeforeMutationLifecycles() commitAllHostEffects(); root.current = finishedWork; commitAllLifeCycles();}复制代码
每个子方法都实现了一个循环来迭代作用列表以及检查作用类型,当发现和这个方法目的有关的作用,就应用它。
前置突变(Pre-mutation)生命周期方法
例如这里的一个代码,迭代作用树,并检查一个节点是否是**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更新
是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在删除操作中,把**commitDeletion
方法中调用componentWillUnmount
**方法当作其中一部分。
后置突变(Post-mutation)生命周期方法
是React调用所有剩余生命周期方法**componentDidUpdate
和componentDidMount
**的方法。
这里我们就讲完了。