原文引用:In-depth explanation of state and props update in React
原文作者:Max Koretskyi
前言
本篇文章使用案例:
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>
]
}
}
案例效果效果如下:
在案例中我为
ClickCounter 组件添加了 componentDidUpdate 生命周期方法。用于说明 React如何在 commit阶段添加 effects(副作用),以及生命周期方法如何被调用。
在本篇文章中我将向你说明 React 是如何处理 state (状态) 更新,以及 effect list (副作用列表) 的建立。首先我们从一个较高的维度来认识 React 的 render和 commit阶段分别做了那些工作。
render phase(渲染阶段)
这里的渲染指的是接收到 state 或是 props 改变时,重新生成或是更新 Fiber Node 的过程。在React 中,渲染阶段开始于 completeWork 方法。以案例为例具体执行一下工作:
- 更新
ClickCounter组件的state的属性count - 调用
ClickCounter组件的render方法得到其孩子节点列表,即:children list也就是span和button,同时执行比较操作 - 更新
span元素的属性
commit phase(提交阶段)
提交阶段指的是将 commit阶段生成的新的 Fiber Node刷新到屏幕的过程。以案例为例具体工作如下:
- 更新
Dom节点span的textContent - 调用
componentDidUpdate
在这之前,我们先快速的浏览一下,当 handler 方法被触发调用 setState 时, 整个调度工作如何处理的。
Scheduling updates (调度更新)
当我们点击 button 时,click 事件会被触发,接下来 React 会调用我们传入 button 中的 callback 方法。它是一个简单的计数器:
class ClickCounter extends React.Component {
...
handleClick() {
this.setState((state) => {
return {count: state.count + 1};
});
}
}
每一个 React Component 都会关联一个 updater(更新器) , 它扮演着组件与 React 内核之间的桥梁。这样的意义在于stateState 可以被不同的平台来实现,如:ReactDOM,ReactNative ,server side rendering , 甚至测试工具等。
在这篇文章中我们将看到在 updater对象在 ReactDom中的实现。对于 ClickCounter 组件来说它的 updater实现类为 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 的回调方法,它将是渲染阶段的第一个被执行的更新操作。
ClickCounter Fiber node 更新进程
让我们假定从 setState方法被调用开始。React 将 setState 中的 callback 添加到 ClickCounter Fibe 节点的updateQueue同时开始执行调度工作。React 进入 render阶段。它开始从 顶层的 HostRoot Fiber 节点开始,使用 renderRoot 方法检索那些未完成工作的 Fiber Node,这时仅仅只有一个 Fiber nodes 没有完成工作,它就是 ClickCounter 组件对应的 Fibe 节点。
所有的操作都基于这个 fiber 节点一个副本,它被保存在检索到的 Fiber Node 的 alternate 属性上。如果alternate没有被创建,React 会在执行更新前通过 createWorkInProgress 函数来创建。于是我们现在可以假定 nextUnitOfWork 变量当前保存着这个 alternate 属性指向的 fiber 点。
render 渲染阶段
beginWork
首先我们的 Fiber 进入 beginWork 方法。
beginWork 基本上可以看做是一个大的 switch 语句,以 Fiber 节点上的 tag 作为判断依据,为 Fiber Node 选择对应的执行方法。例子中ClickCounter 是一个类组件,那么它的执行的分支如下所示:
function beginWork(current?1, workInProgress, ...) {
//...
switch (workInProgress.tag) {
//...
case FunctionalComponent: {...}
case ClassComponent:
{
//...
return updateClassComponent(current?1, workInProgress, ...);
}
case HostComponent: {...}
case //...
}
接下来我们进入 updateClassComponent 方法,基于是否是第一次渲染的组件,或复用,亦或是更新,React 要么创建一个新的实例并挂载它,或者直接复用更新:
function updateClassComponent(current, workInProgress, Component, ...) {
...
// stateNode Fiber Node对应的实例,这里指的就是ClickCounter的实例,即:new ClickCounter()
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, ...);
}
处理 ClickCounter Fiber 的更新
上面代码中可以看到我们已经拿到了 ClickCounter 组件的实例,接下来我们进入 updateClassInstance 函数中。类组件的大多数工作都集中在个方法上。下面列表中列举了最重要的执行工作,及相应的执行顺序:
- 调用
UNSAFE_componentWillReceiveProps()(deprecated) - 合并
updateQueue队列,生成新的state - 调用
getDerivedStateFromProps传入新的state,并得到返回的结果 - 调用
shouldComponentUpdate确保组件需要被更新。如果返回false,则条跳过整个的渲染阶段,反之继续执行更新操作 - 调用
UNSAFE_componentWillUpdate(deprecated)
尽管
componentDidUpdate在render 阶段被添加,但是这个方法的执行还是在 commit 阶段。
- 向触发器添加
componentDidUpdate生命周期钩子(副作用)
state 和 props 应该在实例调用 render 方法之前更新,因为 render 方法的输出基于 state 和 props ,如果没有在 render 方法调用前更新,那么就会导致每次都会得到相同的结果。
- 更新
state和props到组件实例上
这里有一个简单的 updateClassInstance 版本,移除了很多辅助性的代码:
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 都会检查这个方法是否被实现。请看下面例子:
if (typeof instance.componentDidUpdate === 'function') {
workInProgress.effectTag |= Update;
}
到此为止我们已经知道了,render 阶段 ClickCounter 的 Fiber Node 执行了哪些操作。让我们看看经过一系列的处理, ClickCounter 的 Fiber Node的值有那些变化。
刚开始的时:
{
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) => {…}
}
},
...
}
}
工作完成后我们得到了这样的结构:
{
effectTag: 4,
elementType: class ClickCounter,
firstEffect: null,
memoizedState: {count: 1},
type: class ClickCounter,
stateNode: {
state: {count: 1}
},
updateQueue: {
baseState: {count: 1},
firstUpdate: null,
...
}
}
通过对比可以看到,updateQueue中的memoizedState 和 baseState 的属性 count,被改变为 1。同时 React也更新了 ClickCounter 组件实例的 state。
这时,队列不再需要更新了,所以 firstUpdate 被设置为 null。更重要的是还修改了 effectTag 属性,它不再是0,而是被修改为 4。在二进制中为 100 , 意味着第三个比特位被设置用来表示一个更新操作,Update side-effect tag:
export const Update = 0b00000000100;
总结一下,在 render 阶段处理 ClickCounter 组件的 Fiber node 时,主要调用了一些前期的生命周期方法,更新了 state,同时定义了相关的副作用。
协调 ClickCounter Fiber 的 children 节点
此时React 会进入 finishClassComponent 方法。React会调用 ClickCounter 组件实例的 render 方法,同时应用diff算法在被返回的children节点上。具体算法的描述,相关部分:
当比较两个类型相同的React Dom 时,React 会去对比两者的属性,保留底层的Dom 节点,同时更新被改变的属性。
目前这里有两件重要的事情需要理解。第一,当 React 协调(reconciliation)孩子节点时,实际上是拿到 render方法返回的React Element 来更新或者生成对应新的的 Fiber Node,同时finishClassComponent 将返回第一个孩子节点的引用,并且赋值给 nextUnitOfWork 变量,并且继续执行随后的遍历操作。第二, 更新孩子节点上的Props 是作为父节点执行渲染工作的一部分。为此,React 需要使用 render方法返回的数据。
例如,这里有一个 spanelement 对应的 Fiber Node,它与ClickCounter fiber 执行协调之前的结构类似:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 0},
...
}
可以看到,memoizedProps 和 pendingProps 中的children 属性都是 0 。下面我们来看看 render方法返回的 span element 结构:
{
?typeof: Symbol(react.element)
key: "2"
props: {children: 1}
ref: null
type: "span"
}
通过以上两个结构对比,我们可以看到 props 上的区别。 createWorkInProgress 方法常常被用来创建 alternate 上的 Fiber Node,同时将spanElement 上已经被更新的属性复制进 alternate 指向的 Fiber Node。
所以,React 完成ClickCounter 组件的children 协调之后 , spanFiber 节点的 pendingProps属性会被更新。具体结构类似于如下示例代码:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 1},
...
}
随后,当React 执行 span Fiber Node 的渲染工作后,pendingProps 属性会被复制到 memoizedProps 属性上,并且添加 update副作用标记。
到此ClickCounter Fiber Node 在 render阶段的所有工作都已经完成。但是因为ClickCounter 组件的第一个孩子节点是button,它会被赋值给 nextUnitOfWork变量,但是button并没有什么更新需要完成,所以 React 随后又会移动到它的兄弟节点 ,也就是 spanFiber Node。
处理 Span fiber 的更新
基于上面的逻辑,我们知道 nextUnitOfWork 变量目前指向 span Fiber 节点的备用节点 alternate ,React 将从它开始更新,与 ClickCounter 执行渲染相似,还是从 beginWork 方法开始。
应为 span 节点的类型是 HostComponent ,所以这次switch语句中会进入如下分支:
function beginWork(current?1, workInProgress, ...) {
...
switch (workInProgress.tag) {
case FunctionalComponent: {...}
case ClassComponent: {...}
case HostComponent:
return updateHostComponent(current, workInProgress, ...);
case //...
}
最终结束子在 updateHostComponent 函数。在 ReactFiberBeginWork.js 文件中可以看到所有的分支方法。
协调 Span Fiber 的 children
在我们的案例中 span Fiber Node 在 updateHostComponent 中没有什么重要的事情需要去做。它没有孩子节点,textContent 不算。
完成 Span Fiber node 的工作
一旦beginWork 执行完成,这个节点将会进入beginWork 函数。但是在此之前 React 还需要更新 spanFiber Node 上的memoizedProps 属性。你或许还记的在协调 ClickCounter组件的children时,React 更新了spanFiber Node 的 pendingProps 属性:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 1},
...
}
所以当 在 span Fiber 执行完成时,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 更新
- 在
spanFiber 节点的updateQueue队列上添加要更新的数据 - 添加 DOM 更新的副作用标记
在执行上述操作前span Fiber node 是这样的:
{
stateNode: new HTMLSpanElement,
type: "span",
effectTag: 0
updateQueue: null
//...
}
在完成上述操作后,span Fiber node 是这样的:
{
stateNode: new HTMLSpanElement,
type: "span",
effectTag: 4,
updateQueue: ["children", "1"],
...
}
注意 effectTag 和updateQueue 两个属性,effectTag由0 变为 4 ,2 进制为 100,意味着第三个比特位被设置作为 update side-effect 的标记。updateQueue 属性则保存着将要被更新数据的引用。
当 React 处理完 ClickCounter 组件及其孩子节点后,意味着 render 阶段到此为止。接下来 React 会将完成更新的 alternate 树赋值给 FiberRoot(根节点)的 finishedWork属性。这是一棵将会被刷新到屏幕上的新树。
Effects list
在我们的案例中。因为 span节点 和 ClickCounter 组件都有副作用,React 将会链接 span Fiber node 至 HostFiber节点的 firstEffect 属性。
React 中副作用链表的建立使用由 compliteUnitOfWork 函数完成。在 Fiber Tree 中副作用链表如下图:
线性链表如下图所示:
Commit phase 提交阶段
这个阶段开始于 completeRoot 函数。在做任何工作之前,要先设置 FiberRoot(根节点)的 finishedWork 为 null。
root.finishedWork = null;
不同于 render 阶段,commit 阶段所有的操作都是同步执行的,所以可以安全的更新 FiberRoot,并指明提交工作开始。
在 commit 阶段 React 的主要工作是更新 DOM 节点,调用生命周期方法 componentDidUpdate。为了完成这些工作,React 需要遍历整个副作用列表。
在 render 阶段定义的相关副作用节点列表如下:
{ type: ClickCounter, effectTag: 5 }
{ type: 'span', effectTag: 4 }
effectTag等于5,对应的二进制为101 ,定义了 update 工作,对于ClickCounter而言也可以认为是调用实 ClickCounter 组件例的 componentDidUpdate 生命周期方法,最后一个比特位表示 ClickCounter的工作在 render 阶段已经全部完成。
span的 effectTag 等于4 ,二进制位 100,定义了 host component DOM 节点的更新工作。在案例中 React 需要更新spanelement 的内容 textContent。
Applying effects
让我们看看 React 如何来应用这些副作用。 在 commitRoot 函数中执行了副作用的处理,它由三个子函数组成:
function commitRoot(root, finishedWork) {
commitBeforeMutationLifecycles()
commitAllHostEffects();
root.current = finishedWork;
commitAllLifeCycles();
}
这三个子函数的实现中都会遍历整个副作用列表,然后找到与他们目标相关的副作用并执行。在我们的案例中,这个过程会调用 ClickCounter 组件的componentDidUpdate 生命周期方法,更新span的 textContent 。
第一个函数 commitBeforeMutationLifeCycles 会寻找 Snapshot effect ,并调用 getSnapshotBeforeUpdate 方法,但是我们并没有在 ClickCounter 组件上实现 这个方法,所以在 render 阶段也不会添加对应的副作用标记,所以这个函数我们的案例中实际上什么都没有做。
DOM updates
接下来React 会调用 commitAllHostEffects 函数,在这里 React 会将 span 的 textContent 由0 修改为 1,在这里ClickCounter 组件什么都不需要做,因为他没有任何实体 Dom。
这个方法的目标是选择正确的 effect 类型,然后执行对应的操作。我们的案例是更新 span的 textContent。所以会计入下面的分支:
function updateHostEffects() {
switch (primaryEffectTag) {
case Placement: {...}
case PlacementAndUpdate: {...}
case Update:
{
var current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
case Deletion: {...}
}
}
逐步执行到 commitWork 函数,我们最终将进入 updateDOMProperties 函数。他会从 spanFiber Node的updateQueue属性中取出在render节点就已经添加好的数据,更新到spanelement 上去。
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 tree 赋值给 HostRoot,也就是更新的备用树转正。
root.current = finishedWork;
调用最后的生命找周期钩子
还剩最后一个函数 commitAllLifecycles 。这里 React 会调用剩下的生命周期方法。在 render 阶段,React 为ClickCounter 添加了update effect 。所以这里 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;
}
}
接下来调用了 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 ...
}
这里我们还可以看到,组件第一次挂载成功的后会调用 componentDidUpdate 方法。