到react fiber内部一探究竟
在我之前文章Fiber内部:深度概述React新协调算法中,我铺设了基础内容,用于理解我这篇文章讲解的更新处理的技术细节。
我已经概述过将在这篇文章中用到的主要数据结构和概念,特别是Fiber节点、当前和工作过程树、副作用和作用列表,我也对主要的算法提供过大致的说明,且解释过**render
和commit
**阶段的不同。如果你还没有读过,那我建议你从那里还是
我也介绍过一个button的简单应用,在屏幕上渲染一个递增的树:
你可以在这里运行它,它实现了一个简单的组件,通过**render
方法返回两个子元素button
和span
。当你点击button时,组件的状态就会在处理方法中更新,span
**元素的文本更新结果:
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};
});
}
componentDidUpdate() {}
render() {
return [
<button key="1" onClick={this.handleClick}>Update counter</button>,
<span key="2">{this.state.count}</span>
]
}
}
这里我也在组件上添加了**componentDidUpdate
,这是为了演示React如何在commit
**阶段添加作用(effects)来调用这个方法。
这篇文章中,我想向你展示React如何处理状态更新以及构建作用列表,我们将带你去看看**render
和commit
**阶段中大致方法都做了些什么。
特别的是,我们将看到React在completeWork
中是如何:
- 更新**
ClickCounter
的state
中的count
**属性。 - 调用**
render
**方法来获取子节点列表,以及执行比较。 - 更新**
span
**元素的props
还有,React在commitRoot
:
- 更新**
span
元素的textCount
**属性。 - 调用**
componentDidUpdate
**生命周期方法。
在这之前,我们先看看,当我们在click处理方法中调用**setState
**时,工作(就是指的work loop中的work啦)如何如何调用的。
注意,你需要知道这里的一起来用React,这篇文章是关于React如何内部工作的。
调度更新(Scheduling updates)
当我们点击button时,**click
**事件被触发,React执行通过props传给button的回调方法,在我们的应用中,它简单的增加计数器,并更新状态:
class ClickCounter extends React.Component {
...
handleClick() {
this.setState((state) => {
return {count: state.count + 1};
});
}
}
每一个React组件都有相关联**updater
,它扮演组件与React内核的桥,这使得setState
**在ReactDOM、React Native、服务端渲染以及测试工具中有不同的实现。
这篇文章中,我们将看看ReactDOM中updater对象的实现,它使用了Fiber协调器。对于**ClickCounter
**组件,它是classComponentUpdater
,它负责获取Fiber实例、队列化更新以及调度工作。
当更新队列化了,它们就在Fiber节点上添加一个待处理的更新队列,在我们的例子中,负责**ClickCounter
**组件的Fiber节点有如下结构:
{
stateNode: new ClickCounter,
type: ClickCounter,
updateQueue: {
baseState: {count: 0}
firstUpdate: {
next: {
payload: (state) => { return {count: state.count + 1} }
}
},
...
},
...
}
正如你所见,**updateQueue.firstUpdate.next.payload
中的方法就是我们在ClickCounter
组件中传递给setState
的回调,它表示在render
**阶段需要处理的第一个更新。
处理ClickCounter Fiber节点的更新
我的前一篇文章的工作循环章节解释了**nextUnitOfWork
全局变量的角色,特别是,它持有了来自还有工作待做的workInProgress
**树中的Fiber节点。当React遍历Fiber树时,使用它来知道是否要有其他未完成工作的Fiber节点。
我们从假定**setState
方法被调开始,React在ClickCounter
上添加setState
的回调,且调用工作,React进入render
阶段。它使用renderRoot方法从顶层HostRoot
开始遍历,然后它会调用以及处理过的fiber节点,直到发现还没有完成工作的节点,在这一点上,这里只有一个fiber节点有工作做,就是ClickCounter
**Fiber节点。
所有工作都在fiber的副本上执行,这个副本保存在**alternate
字段中,如果这个alternate节点还没有创建,React会在处理更新之前在createWorkInProgress方法中创建这个副本。我们来假定这个nextUnitOfWork
遍历就持有这个副本ClickCounter
**Fiber节点的引用。
beginWork
首先,我们Fiber进入beginWork方法。
因为这个方法在树中的每个fiber节点上都会执行,所以如果你想在**
render
**阶段debug,那这是很好断点位置,我经常这样来检查Fiber节点类型,以便于确定我需要的那个节点。
**beginWork
基本上是一个大的switch
语句,根据tag来确定每个Fiber需要做的工作类型,然后执行各自的方法,在我们的CountClicks
**例子中,它是个类组件,所以这部分被执行:
function beginWork(current?1, workInProgress, ...) {
...
switch (workInProgress.tag) {
...
case FunctionalComponent: {...}
case ClassComponent:
{
...
return updateClassComponent(current?1, workInProgress, ...);
}
case HostComponent: {...}
case ...
}
我们进入updateClassComponent
方法,依赖于它是首次渲染、工作恢复继续(work不是可以异步打断的嘛),或者只是更新,React要么创建实例和挂载这个组件,要么仅仅更新它:
function updateClassComponent(current, workInProgress, Component, ...) {
...
const instance = workInProgress.stateNode;
let shouldUpdate;
if (instance === null) {
...
// In the initial pass we might need to construct the instance.
constructClassInstance(workInProgress, Component, ...);
mountClassInstance(workInProgress, Component, ...);
shouldUpdate = true;
} else if (current === null) {
// In a resume, we'll already have an instance we can reuse.
shouldUpdate = resumeMountClassInstance(workInProgress, Component, ...);
} else {
shouldUpdate = updateClassInstance(current, workInProgress, ...);
}
return finishClassComponent(current, workInProgress, Component, shouldUpdate, ...);
}
处理ClickConter Fiber的更新
我们已经有**ClickCounter
**组件的实例,所以我们进入updateClassInstance
,这是React处理类组件大部分工作的地方,方法中按顺序有最重要的几个操作:
- 调用
UNSAFE_componentWillReceiveProps
钩子 (弃用) - 执行 **
updateQueue
**中的更新,并生成新的state - 使用新的state调用**
getDerivedStateFromProps
**,并获得结果 - 调用**
shouldComponentUpdate
确保组件是否需要更新,如果不,则跳过整个render处理,包括该组件和其子组件的render
**调用,反之则用更新处理。 - 调用**
UNSAFE_componentWillUpdate
** (弃用) - 添加一个作用(effect)来触发**
componentDidUpdate
**生命周期钩子
尽管调用**
componentDidUpdate
的作用在render
阶段添加,但是这个方法将在commit
**阶段被执行
- 在组件实例上更新**
state
和props
**
state
和props
应该在组件实例的render
方法调用之前被更新,因为render
方法的输出通常依赖于state
和props
,如果我们不这样做,那它将总是返回同样的结果。
这是这个方法的简单版本:
function updateClassInstance(current, workInProgress, ctor, newProps, ...) {
const instance = workInProgress.stateNode;
const oldProps = workInProgress.memoizedProps;
instance.props = oldProps;
if (oldProps !== newProps) {
callComponentWillReceiveProps(workInProgress, instance, newProps, ...);
}
let updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
processUpdateQueue(workInProgress, updateQueue, ...);
newState = workInProgress.memoizedState;
}
applyDerivedStateFromProps(workInProgress, ...);
newState = workInProgress.memoizedState;
const shouldUpdate = checkShouldComponentUpdate(workInProgress, ctor, ...);
if (shouldUpdate) {
instance.componentWillUpdate(newProps, newState, nextContext);
workInProgress.effectTag |= Update;
workInProgress.effectTag |= Snapshot;
}
instance.props = newProps;
instance.state = newState;
return shouldUpdate;
}
在上面的代码片段中我已经移除掉一些辅助代码,对于实例,在调用生命周期方法或者添加触发它们的作用前,React使用typeof操作符检测组件是否实现了这个方法。例如,这里便是React检测**componentDidUpdate
**,在它这个作用添加之前:
if (typeof instance.componentDidUpdate === 'function') {
workInProgress.effectTag |= Update;
}
好,现在,我知道了**ClickCounter
在render阶段中有哪些操作需要执行,那我们来看看Fiber节点上这些操作改变的值。当React开始工作时,ClickCounter
**组件的fiber节点看起来是这样的:
{
effectTag: 0,
elementType: class ClickCounter,
firstEffect: null,
memoizedState: {count: 0},
type: class ClickCounter,
stateNode: {
state: {count: 0}
},
updateQueue: {
baseState: {count: 0},
firstUpdate: {
next: {
payload: (state, props) => {…}
}
},
...
}
}
工作结束之后,我们得到Fiber阶段结果看起来这样:
{
effectTag: 4,
elementType: class ClickCounter,
firstEffect: null,
memoizedState: {count: 1},
type: class ClickCounter,
stateNode: {
state: {count: 1}
},
updateQueue: {
baseState: {count: 1},
firstUpdate: null,
...
}
}
花点时间观察一下属性值的不同
在更新执行之后,count
属性的值在memoizedState
和updateQueue
中的baseState
上变成1
,React也更新了**ClickCounter
**组件实例中的state。
此刻,我们在队列中不在有更新,所有**firstUpdate
是null
,且重要的是,我们修改了effectTag
属性的值,它不在是0
,而是4
,二进制中为100
,这代表第三位被设,而这一位代表Update
**的副作用tag(side-effect tag):
export const Update = 0b00000000100;
综述,**ClickCounter
**Fiber节点的工作是,调用前置突变生命周期方法,更新state以及定义相关副作用。
ClickCounter Fiber的子协调
一旦那些完成,React进入finishClassComponent,这个方法中,React调用组件实例**render
**方法,且对组件返回的孩子执行diff算法,这个文档中有大致概述,这是相关的一部分:
当比较两个相同类型的React DOM元素时,React观察两者的属性,保留DOM节点中一致的,且只更新变化的属性。
然后如果我们再深入的话,我们可以知道它的确是比较Fiber节点和React元素,但是我现在就先不太详细的说明了,因为这个处理相当细致,我将会针对子协调单独的写文章分析。
如果你自己很好奇想知道这个细节,可以查阅reconcileChildrenArray方法,因为在我们的例子中,**
render
**方法返回React元素数组。
此刻,有两个重要事情需要理解,首先,当React进行子协调处理时,它创建或更新了子React元素的Fiber节点,这些子元素有**render
方法返回,finishClassComponent
返回了当前Fiber节点的第一个孩子的引用,它将会赋值给nextUnitOfWork
,便于在工作循环中之后处理;其次,React更新孩子的props是其父级上执行工作的一部分,所以为了做这个,它要使用render**方法返回的react元素上的数据。
例如,这里是**span
元素相关的Fiber节点在React协调ClickCounter
**fiber之前的样子:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 0},
...
}
正如你所见,memoizedProps
和pendingProps
上children
属性值都是0
,这是**span
元素的render
**方法返回的React元素结构:
{
?typeof: Symbol(react.element)
key: "2"
props: {children: 1}
ref: null
type: "span"
}
如你所见,Fiber节点和返回的React元素的props有点不同,在创建fiber节点副本的createWorkInProgress
方法中,React从React元素中拷贝了更新的属性到Fiber节点。
所以,在React完成**ClickCounter
组件上的子协调后,span
Fiber节点的pendingProps
将会更新,它们将会匹配span
**React元素中的值:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 1},
...
}
然后,当React将要执行**span
Fiber节点上的工作,它将拷贝它们到memoizedProps
**,并添加作用(effects)来更新DOM。
嗯,这就是在render阶段中,React在**ClickCounter
Fiber节点上执行的所有工作,因为Button是ClickCounter
组件上的第一孩子,它将赋值给nextUnitOfWork
变量,由于无事可做,所以React将会转移到它的兄弟span
Fiber节点上,根据这里描述的算法,这发生在completeUnitOfWork
**方法中。
Span Fiber的更新处理
所以,**nextUnitOfWork
变量现在指向span
副本,且React在它上面还是工作,类似于在ClickCounte
**上的步骤,我们开始于beginWork
方法。
由于我们的**span
节点是HostComponent
**类型,这次在switch语句中,React执行这个部分:
function beginWork(current?1, workInProgress, ...) {
...
switch (workInProgress.tag) {
case FunctionalComponent: {...}
case ClassComponent: {...}
case HostComponent:
return updateHostComponent(current, workInProgress, ...);
case ...
}
走到updateHostComponent
方法,你可以对比看类组件调用的**updateClassComponent
方法,方法组件的updateFunctionComponent
**,以及其他,你能在ReactFiberBeginWork.js
文件中找到所有这些方法。
span fiber的子协调
在我们的例子中,在**span
节点在updateHostComponent
**没有什么重要事情发生。
span fiber节点的完成工作
一旦**beginWork
完成,该节点就进入completeWork
方法,但是在此之前,React需要更新span fiber上的memoizedProps
,你可能记得,当ClickCounter
组件上子协调时,React更新span
Fiber节点上的pendingProps
**:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 1},
...
}
所以,一旦**span
Fiber上的beginWork
完成,React就是更新pendingProps
到memoizedProps
**上:
function performUnitOfWork(workInProgress) {
...
next = beginWork(current?1, workInProgress, nextRenderExpirationTime);
workInProgress.memoizedProps = workInProgress.pendingProps;
...
}
然后,它调用**completeWork
方法,这个基本上是一个大的switch语句,类似于我们在beginWork
**中看到的:
function completeWork(current, workInProgress, ...) {
...
switch (workInProgress.tag) {
case FunctionComponent: {...}
case ClassComponent: {...}
case HostComponent: {
...
updateHostComponent(current, workInProgress, ...);
}
case ...
}
}
因为我们的**span
Fiber节点是HostComponent
**,所以它执行updateHostComponent
方法,在这个方法中,React基本上如下操作:
- 准备DOM更新
- 把它们添加到**
span
fiber的updateQueue
** - 添加更新DOM的作用
在操作执行之前,**span
**Fiber节点看起来像:
{
stateNode: new HTMLSpanElement,
type: "span",
effectTag: 0
updateQueue: null
...
}
当工作完成之后,它看起来像:
{
stateNode: new HTMLSpanElement,
type: "span",
effectTag: 4,
updateQueue: ["children", "1"],
...
}
注意,effectTag
和updateQueue
字段的不同,它不再是0
,而是**4
,二进制为100
,这代表第三位被设,第三位代表着Update
的副作用tag,这是React在接下来的commit阶段唯一需要做的工作,而updateQueue
**字段持有的负载(payload)将会在更新时用到。
一旦,React处理完**ClickCounter
和它们的孩子,它就完成了render
阶段,它现在就能把完成成的副本(或者叫替代-alternate)树赋值给FiberRoot
上的finishedWork
属性。这是一颗新的需要刷新在屏幕上的树,它可以在render
**阶段之后立即处理,或者挂起等浏览器给React空闲时间。
作用列表(Effects list)
在我们的例子中,因为span
节点和ClickCounter
组件都有副作用,React会把HostFiber
上的firstEffect
指向span
Fiber节点。
React在compliteUnitOfWork
方法中构建作用列表,这里是带有作用的Fiber树,这些作用是更新**span
节点文本,调用ClickCounter
**的钩子:
这里是作用节点的线性列表:
Commit 阶段
这个阶段开始于completeRoot方法,在它做任何工作之前,它把**FiberRoot
上的finishedWork
属性设为null
**:
root.finishedWork = null;
不像**render
阶段,commit
阶段总是同步的,所以它可以安全的更新HostRoot
**来指示提交工作开始了。
**commit
阶段就是React更新DOM以及调动后置突变生命周期方法componentDidUpdate
的地方,为了这样,它迭代在render
**阶段创建的作用列表,并应用它。
我们在**render
阶段中,对span
和ClickCounter
**节点有如下作用:
{ type: ClickCounter, effectTag: 5 }
{ type: 'span', effectTag: 4 }
ClickCounter
的作用标签值是5
或者二进制的101
,定义的**更新
工作被认为是调用类组件的componentDidUpdate
生命周期方法。最低位也被设值,它代表这个Fiber节点在render
**阶段的所有工作都已完成。
span
的作用标签是4
或者二进制100
,定义的**更新
工作是host组件的DOM更新,在我们例子中的span
元素,React将需要更新元素的textContent
**。
应用作用(Applying effects)
我们来看React是如何应用这些作用的,commitRoot
方法,用于应用这些作用,有三个子方法组成:
function commitRoot(root, finishedWork) {
commitBeforeMutationLifecycles()
commitAllHostEffects();
root.current = finishedWork;
commitAllLifeCycles();
}
每个子方法都会用循环来迭代作用列表,并检查其中作用类型,当找到有关方面目的的作用时,就应用它。在我们的例子中,它会调用**ClickCounter
组件的componentDidUpdate
生命周期方法,以及更新span
**元素上的文本内容。
第一个放commitBeforeMutationLifeCycles寻找**Snapshot
作用,且调用getSnapshotBeforeUpdate
方法,但是,因为我们在ClickCounter
组件中没有实现这个方法,所以React不会在render
**阶段添加这个作用,所以在我们的例子中,这个方法啥也没做。
DOM更新
接着,React执行到commitAllHostEffects
方法,这里,React就会把**span
元素的文本内容从0
修改到1
,而ClickCounter
**fiber上啥也不做,因为类组件的节点没有任何DOM更新。
这个方法大致是,选择正确作用类型,并应用相关的操作。在我的例子中,我们需要更新**span
元素的文本,所以我们走Update
**部分:
function updateHostEffects() {
switch (primaryEffectTag) {
case Placement: {...}
case PlacementAndUpdate: {...}
case Update:
{
var current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
case Deletion: {...}
}
}
往下走到**commitWork
,我们将进入updateDOMProperties
方法,它会取出在render
阶段添加在Fiber节点上的updateQueue
的负载(payload),并将其更新在span
元素的textContent
**属性上:
function updateDOMProperties(domElement, updatePayload, ...) {
for (let i = 0; i < updatePayload.length; i += 2) {
const propKey = updatePayload[i];
const propValue = updatePayload[i + 1];
if (propKey === STYLE) { ...}
else if (propKey === DANGEROUSLY_SET_INNER_HTML) {...}
else if (propKey === CHILDREN) {
setTextContent(domElement, propValue);
} else {...}
}
}
在DOM更新被应用之后,React把**finishedWork
树赋值给HostRoot
**,即把替换(alternate)树设置为当前树:
root.current = finishedWork;
调用后置突变生命周期钩子
最后一个方法是commitAllLifecycles
方法,这里React会调用后置突变生命周期方法。在**render
阶段,React在ClickCounter
组件上添加Update
作用,这是commitAllLifecycles
方法寻找的作用之一,然后调用componentDidUpdate
**方法:
function commitAllLifeCycles(finishedRoot, ...) {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
if (effectTag & (Update | Callback)) {
const current = nextEffect.alternate;
commitLifeCycles(finishedRoot, current, nextEffect, ...);
}
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
这个方法也更新refs,但是我们没有使用这个功能,所以commitLifeCycles方法中调用的方法是:
function commitLifeCycles(finishedRoot, current, ...) {
...
switch (finishedWork.tag) {
case FunctionComponent: {...}
case ClassComponent: {
const instance = finishedWork.stateNode;
if (finishedWork.effectTag & Update) {
if (current === null) {
instance.componentDidMount();
} else {
...
instance.componentDidUpdate(prevProps, prevState, ...);
}
}
}
case HostComponent: {...}
case ...
}
你可以在这个方法中看到,React为第一次渲染到组件调用**componentDidMount
**方法。
额……讲完啦。