深入了解React中state和props的更新

前言

在本文中我们会看到React如何处理state的更新。以及如何构建effects list。我们会详细介绍render(渲染)阶段以及commit(提交)阶段发生的事情。

我们会在completeWork函数中看到React如何:

  1. 更新state属性。
  2. 调用render方法并比较子节点。
  3. 更新React元素的props。

并且在commitRoot函数中React如何:

  1. 更新元素的textContent属性。
  2. 调用componentDidUpdate生命周期方法。

但在这之前,让我们先来看看调用setState时React是如何安排工作。我们使用之前文章的一个例子,方便文章讲解,一个简单的计数器组件:

1.gif

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 [
            ,
            {this.state.count}
        ]
    }
}

调度更新

当我们点击按钮的时候,click事件被触发,React会执行我们的回调。在我们的应用程序中,他会更新我们的state。

class ClickCounter extends React.Component {
    ...
    handleClick() {
        this.setState((state) => {
            return {count: state.count + 1};
        });
    }
}  

每一个React组件,都有一个关联的updater(更新器)。updater充当了组件和React core之间的桥梁。这允许setState在ReactDOM,React Native,服务器端渲染和测试用例中有不同的方式实现。

在这里,我们将会关注ReactDOM中updater(更新器)的实现。它使用了Fiber reconciler。对于ClickCounter组件,它是classComponentUpdater。它负责检索Fiber实例,队列化更新,调度工作。

在更新时,更新器会在Fiber节点上添加更新队列。在我们的例子中,ClickCounter组件对应的Fiber节点的结构如下:

{
    stateNode: new ClickCounter, // 保留对class组件实例的引用
    type: ClickCounter, // type属性指向构造函数
    updateQueue: { // state更新和回调,DOM更新的队列。
         baseState: {count: 0}
         firstUpdate: {
             next: {
                 payload: (state) => { return {count: state.count + 1} }
             }
         },
         ...
     },
     ...
}

如你所见updateQueue.firstUpdate.next.payload的内容是我们在setState中传递的回调。它表示在render阶段中需要处理的第一个更新。

处理ClickCounter的Fiber节点的更新

在之前的文章中介绍了nextUnitOfWork全局变量的作用。

nextUnitOfWork保持了对workInProgress tree中一个有工作要处理的Fiber节点的引用。nextUnitOfWork会指向下一个Fiber节点的引用或者为null。可以使用nextUnitOfWork变量判断是否有没有完成工作的Fiber节点。

我们假设以及调用了setState,React将setState的回调添加到了ClickCounter的Fiber节点的updateQueue字段中并调度了工作。React进入了render(渲染)阶段。它从HostRoot节点开始,使用renderRoot函数遍历Fiber树。它会跳过已经处理过Fiber节点,直到找到工作未完成的节点。此时,只有一个Fiber节点有未完成工作,就是ClickCounter Fiber节点。

Fiber树的第一个节点是一种特殊的类型节点,叫做HostRoot。它在内部创建,是最顶层组件的父组件

所有的“work”都会在Fiber节点的备份上进行。备份存储在alternate字段中。如果尚未创建备份节点,React会在处理更新之前,使用createWorkInProgress函数创建备份。假设nextUnitOfWork拥有ClickCounter的Fiber节点的引用。

beginWork

首先Fiber进入beginWork函数。

由于beginWork函数是每一个Fiber节点都会执行的。因此如果需要调试render阶段的源码,这里是放置断点的好地方。我(指Max)经常那么做。

beginWork函数内部是一个巨大的switch语句,switch语句通过Fiber节点的tag属性,判断Fiber节点的类型。然后执行相应的函数执行工作。

我们的节点是CountClicks组件的Fiber节点,所以会进入ClassComponent的分支语句

function beginWork(current$$1, workInProgress, ...) {
    ...
    switch (workInProgress.tag) {
        ...
        case FunctionalComponent: {...}
        case ClassComponent:
        {
            ...
            return updateClassComponent(current$$1, workInProgress, ...);
        }
        case HostComponent: {...}
        case ...
}

然后我们进入updateClassComponent函数

在updateClassComponent函数中,判断组件要么是首次渲染,还是恢复工作(render阶段可以被打断)还是更新。React要么创建实例并挂载这个组件,要么仅仅更新它。

function updateClassComponent(current, workInProgress, Component, ...) {
    ...
    const instance = workInProgress.stateNode;
    let shouldUpdate;
    if (instance === null) {
        ...
        // 如果实例为null, 我们需要构造实例
        constructClassInstance(workInProgress, Component, ...);
        mountClassInstance(workInProgress, Component, ...);
        shouldUpdate = true;
    } else if (current === null) {
        // 在重新开始后,我们已经有了一个可以重用的实例。
        shouldUpdate = resumeMountClassInstance(workInProgress, Component, ...);
    } else {
        // 只是进行更新
        shouldUpdate = updateClassInstance(current, workInProgress, ...);
    }
    return finishClassComponent(current, workInProgress, Component, shouldUpdate, ...);
}

处理CountClicks Fiber的更新

经过beginWorkupdateClassComponent函数,我们已经有了ClickCounter组件的实例,我们此时进入updateClassInstance。updateClassInstance函数是React处理类组件大部分工作的地方。以下是函数中执行的最重要的操作(按执行顺序排列):

  1. 执行UNSAFE_componentWillReceiveProps生命周期函数
  2. 执行Fiber节点中的updateQueue的更新队列,生成新的的state
  3. 使用新的state,执行getDerivedStateFromProps并获取结果
  4. 执行shouldComponentUpdate判断组件是否需要更新。如果是false,跳过整个render处理,包括此组件以及子组件。如果是true,继续更新。
  5. 执行UNSAFE_componentWillUpdate生命周期函数
  6. 添加effect用来触发componentDidUpdate生命周期函数
  7. 在组件实例上更新state和props
尽管componentDidUpdate的effect在render阶段添加,但是该方法将在下一个commit阶段被执行

state和props应该在组件实例的render方法调用之前被更新,因为render方法的输出通常依赖于state和props,如果我们不这样做,它将每次都返回相同的输出。

// 简化后的代码
function updateClassInstance(current, workInProgress, ctor, newProps, ...) {
    // 组件的实例
    const instance = workInProgress.stateNode;
    // 之前的props
    const oldProps = workInProgress.memoizedProps;
    instance.props = oldProps;
    if (oldProps !== newProps) {
        // 如果当前的props和之前的props有差异,执行UNSAFE_componentWillReceiveProps
        callComponentWillReceiveProps(workInProgress, instance, newProps, ...);
    }
    // 更新队列
    let updateQueue = workInProgress.updateQueue;
    if (updateQueue !== null) {
        // 执行更新队列,获取新的状态
        processUpdateQueue(workInProgress, updateQueue, ...);
        // 获取最新的state
        newState = workInProgress.memoizedState;
    }
    // 使用最新的state,调用getDerivedStateFromProps
    applyDerivedStateFromProps(workInProgress, ...);
    // 获取最新的state(getDerivedStateFromProps可能会更新state)
    newState = workInProgress.memoizedState;

    // 执行shouldComponentUpdate,判断组件是否需要更新
    const shouldUpdate = checkShouldComponentUpdate(workInProgress, ctor, ...);
    if (shouldUpdate) {
        // 如果需要更新执行UNSAFE_componentWillUpdate生命吗周期函数
        instance.componentWillUpdate(newProps, newState, nextContext);
        // 并且添加effect,在commit阶段会执行componentDidUpdate,getSnapshotBeforeUpdate
        workInProgress.effectTag |= Update;
        workInProgress.effectTag |= Snapshot;
    }

    // 更新props和state
    instance.props = newProps;
    instance.state = newState;

    return shouldUpdate;
}

上面的代码片段是简化后的代码,例如在调用生命周期函数之前或添加effect。React使用typeof操作符检测组件是否实现了这个方法。例如React检测是否实现了componentDidUpdate,在effect添加之前

if (typeof instance.componentDidUpdate === 'function') {
    workInProgress.effectTag |= Update;
}

好的,现在我们知道了ClickCounter的Fiber节点在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,
        ...
    }
}

观察一下两者的对比。memoizedState的属性值由0变为了1。updateQueue的baseState的属性值由0变为了1。updateQueue没有队列更新,firstUpdate为null。并且我们修改effectTag的值,标记了我们在commit阶段执行的副作用。

effectTag由0变为了4,4在二进制中是0b00000000100, 这代表第三个位被设置,而这一位代表Update

export const Update = 0b00000000100;

总结,ClickCounter组件的Fiber节点,在render阶段做了调用前置突变生命周期方法,更新state以及定义相关副作用。

ClickCounter Fiber的子协调

完成上面的工作后,React进入finishClassComponent函数, 这是React调用组件的render方法,并对子级应用diff算法的地方。React文档对diff算法有大致的概述。

如果深入了解,我们可以知道React中的diff算法实际将React元素与Fiber节点进行了比较。过程非常的复杂(原文作者没有在这里进行过多的叙述),在我们的例子中,render方法返回React元素数组,所以如果你想了解更多的细节可以查看React源码的reconcileChildrenArray函数。

此时有两个重要的事情需要了解。

  1. React在进行子协调的过程时,创建或者更新了子元素的Fiber节点。子元素由render返回。finishClassComponent方法返回的当前节点的第一个子节点的引用。引用会被分配给nextUnitOfWork变量,然后在workLoop中进行处理。
  2. React更新了子级的props,这是父级工作的一部分。为此,它使用从render方法返回的React元素中的数据。

例如,React进行子协调前,span对应的Fiber节点

{
    stateNode: new HTMLSpanElement,
    type: "span",
    key: "2",
    memoizedProps: {children: 0}, // 上一次用于渲染的props
    pendingProps: {children: 0}, // 更新后的props,需要用于dom和子组件上
    ...
}

这是调用render方法后返回React元素结构,Fiber节点和返回的React元素的props有点不同,在创建Fiber节点备份时,createWorkInProgress函数会将React元素更新的数据同步到Fiber节点上。(Fiber上的工作都是在备份节点上进行的)

{
    $$typeof: Symbol(react.element)
    key: "2"
    props: {children: 1}
    ref: null
    type: "span"
}

ClickCounter组件在完成子协调后,span元素的Fiber节点将会更新,结构如下:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    key: "2",
    memoizedProps: {children: 0}, // 在上一次渲染过程中用来创建输出的Fiber props。
    pendingProps: {children: 1}, // 已经更新后的Fiber props。需要用于子组件和DOM元素。
    ...
}

稍后在执行span元素的Fiber节点的工作时,会将pendingProps拷贝到memoizedProps上,并添加effects,方便commit阶段更新dom。

好了,这就是ClickCounter组件在render阶段执行的所有工作。由于button元素是ClickCounter组件的第一个子元素,所有button元素的Fiber节点被分配给了nextUnitOfWork。由于button元素的Fiber节点没有工作,所以React会将它的兄弟节点即span元素的Fiber节点分配给nextUnitOfWork。此处的行为在completeUnitOfWork方法中。

处理span Fiber的更新

nextUnitOfWork目前指向span Fiber备用节点(因为工作都是在workInProgress tree上完成的)。处理的步骤和ClickCounter类似,我们从beginWork函数开始。

有span元素的Fiber节点的tag属性是HostComponent类型,beginWork进入了updateHostComponent分支。(这部分的内容,以及与当前版本的React有了冲突)

function beginWork(current$$1, workInProgress, ...) {
    ...
    switch (workInProgress.tag) {
        case FunctionalComponent: {...}
        case ClassComponent: {...}
        case HostComponent:
          return updateHostComponent(current, workInProgress, ...);
        case ...
}

span Fiber的子协调

在我们的例子中,span的Fiber在子协调中没有什么重要的事情发生

span Fiber完成工作

beginWork完成后,span Fiber进入completeWork。但是在此之前React需要更新span Fiber上的memoizedProps。在子协调时,React更新了span Fiber的pendingProps字段。(这部分的内容已经与现在React版本有所冲突)

{
    stateNode: new HTMLSpanElement,
    type: "span",
    key: "2",
    memoizedProps: {children: 0},
    pendingProps: {children: 1},
    ...
}

span Fiber的beginWork完成后,就会将pendingProps更新到memoizedProps上

function performUnitOfWork(workInProgress) {
    ...
    next = beginWork(current$$1, workInProgress, nextRenderExpirationTime);
    workInProgress.memoizedProps = workInProgress.pendingProps;
    ...
}

然后调用completeWork方法,completeWork方法内部也是一个大的switch语句

function completeWork(current, workInProgress, ...) {
    ...
    switch (workInProgress.tag) {
        case FunctionComponent: {...}
        case ClassComponent: {...}
        case HostComponent: {
            ...
            updateHostComponent(current, workInProgress, ...);
        }
        case ...
    }
}

由于span Fiber是HostComponent,所以会执行updateHostComponent函数,在这个函数中React会执行以下的操作:

  1. 准备DOM更新
  2. 它们添加到span Fiber的updateQueue
  3. 添加DOM更新的effects

在执行这些操作前,Fiber节点结构:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    effectTag: 0
    updateQueue: null
    ...
}

操作后Fiber节点的结构:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    effectTag: 4,
    updateQueue: ["children", "1"],
    ...
}

注意Fiber节点的effectTag值,由0变为了4,4在二进制中是100,这是Update副作用的表示位,这是commit阶段React需要做的工作,而updateQueue字段的负载(payload)将会在更新时用到。

React在依次完成子元素工作和ClickCounter工作后,就完成了render阶段。此时React将 workInProgress tree(备份节点, render阶段更新的树)分配给FiberRoot节点的finishedWork属性。这是一颗新的需要刷新在屏幕上的树,它可以在render阶段之后立即处理,或者挂起等待浏览器的空闲时间。

effects list

在我们的例子中span节点和ClickCounter节点,具有副作用。HostRoot(Fiber树的一个节点)上的firstEffect属性指向span Fiber节点。

React在compliteUnitOfWork函数中创建effects list这是一个带有effects的Fiber树。effects中包含了: 更新span的文本,调用ClickCounter生命周期函数。

1.png

effects list线性列表:

2.png

commit阶段

commit阶段从completeRoot函数开始,在开始任何工作前,它将FiberRoot的finishedWork属性设置为null。

commit阶段始终是同步的,所以它可以安全的更新HostRoot来指示commit开始了。

commit阶段,是React更新DOM, 以及调用生命周期方法的地方。为此React将遍历上一个render阶段构造的effects list,并应用它们。

在render阶段,span和ClickCounter的effects如下:

{ type: ClickCounter, effectTag: 5 }
{ type: 'span', effectTag: 4 }

ClickCounter Fiber节点的effectTag值是5,5的二进制是101,第三位被设置为1,这是Update副作用的表示位,我们需要调用componentDidUpdate生命周期方法。最低位也是1,表面Fiber节点在render阶段的所有工作都已经完成。

span Fiber节点的effectTag值是5,5的二进制是101,第三位被设置为1,这是Update副作用的表示位,我们需要更新span元素的textContent。

应用effects

应用effects在函数commitRoot中完成,commitRoot由三个子方法组成。

function commitRoot(root, finishedWork) {
    commitBeforeMutationLifecycles()
    commitAllHostEffects();
    root.current = finishedWork;
    commitAllLifeCycles();
}

每一个子方法都会循环effects list, 并检查effects的类型。

第一个函数commitBeforeMutationLifeCycles检查Snapshot effects,并且调用getSnapshotBeforeUpdate函数,但是我们在ClickCounter组件中没有添加getSnapshotBeforeUpdate生命周期函数,所以React不会在render阶段添加这个作用,所以在我们的例子中,这个方法什么都没有做。

DOM更新

接下来是commitAllHostEffects, 在这个函数中span的文本内容会从0到1。ClickCounter Fiber节点没有动作。

commitAllHostEffects内部也是一个大的switch,根据effects的类型,应用相应的操作:

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上的workInProgress tree分配给HostRoot。将workInProgress tree设置为current tree

root.current = finishedWork;

调用后置生命周期函数

最后剩下的函数是commitAllLifecycles。在render阶段React会将Update effects添加到ClickCounter组件中,commitAllLifecycles函数是调用后置突变生命周期方法的地方。

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 ...
}

你可以在这个方法中看到,React为第一次渲染的组件调用componentDidMount方法。

总结

Max Koretskyi的文章内容较多,所以最后总结下知识点:

  1. 每一个组件都有一个与之关联的updater(更新器)。更新器充当了组件和React core之间的桥梁。这允许setState在ReactDOM,React Native,服务器端渲染和测试用例中有不同的方式实现。
  2. 对于class组件,updater(更新器)是classComponentUpdater
  3. 在更新时,updater(更新器)会在Fiber节点的updateQueue属性中,添加更新队列。
  4. render(渲染)阶段,React会从HostRoot开始遍历Fiber树, 跳过已经处理过的Fiber节点,直到找到还有work没有完成的Fiber节点。所有工作都是在Fiber节点的备份上进行, 备份存储在Fiber节点的alternate字段上。如果alternate字段还没有创建, React会在处理工作前使用createWorkInProgress创建alternate字段,createWorkInProgress函数会将React元素的状态同步到Fiber节点上。
  5. nextUnitOfWork保持了对workInProgress tree中一个有工作要处理的Fiber节点的引用。
  6. Fiber节点进入beginWork函数,beginWork函数会根据Fiber节点类型执行相对应的工作,class组件会被updateClassComponent函数执行 。
  7. 每一个Fiber节点都会执行beginWork函数。经过beginWork函数,组件要么被创建组件实例子,要么只是更新组件实例。
  8. 经过beginWork, updateClassComponent后,进入updateClassInstance,这里是处理类组件大部分work的地方。(下面的操作按顺序执行)

    • 执行UNSAFE_componentWillReceiveProps生命周期函数
    • 执行Fiber节点中的updateQueue的更新队列,生成新的的state
    • 使用新的state,执行getDerivedStateFromProps并获取返回结果
    • 执行shouldComponentUpdate判断组件是否需要更新。如果是false,跳过整个render处理,包括此组件以及子组件。如果是true,继续更新。
    • 执行UNSAFE_componentWillUpdate生命周期函数
    • 添加effects用来触发componentDidUpdate生命周期函数(commit阶段才会触发)
    • 在组件实例上更新state和props
  9. render阶段class组件主要做了:调用前置突变生命周期方法,更新state,定义相关effects。
  10. 完成updateClassInstance结束后,React进入finishClassComponent, 这里React调用组件的render方法,并对子级应用diff算法的地方(子协调的地方)。
  11. 子协调会创建或者更新子元素的Fiber节点,子元素由render方法返回,子元素的属性会被同步到子元素的Fiber节点上。finishClassComponent会返回第一个子元素的Fiber节点,并分配给nextUnitOfWork, 方便之后在workLoop中继续处理(之后会子节点的节点)。
  12. 更新子元素props是在父元素工作中的一部分。
  13. render阶段完成后。React将workInProgress tree(备份节点, render阶段更新的树)分配给FiberRoot节点的finishedWork属性。
  14. commit阶段之前,FiberRootfinishedWork属性设置为null
  15. commit阶段是同步的,是React更新DOM, 以及调用生命周期方法的地方(应用副作用)。

参考

你可能感兴趣的