深入react的state和props更新

3,684 阅读19分钟

本文为意译和整理,如有误导,请放弃阅读。原文

前言

这篇文章用一个由parent component和children component组成的例子来讲述fiber架构中react将props传递给子组件的处理流程。

正文

在我先前的文章中 深入React Fiber架构的reconciliation 算法 提到要想理解更新流程的技术细节,我们需得具备一定的基础知识。而这部分的基础知识就是篇文章要讲述的内容。

对于本文所提到的数据结构和概念,我已经在上一篇文章概述过了。这些数据结构和概念主要包括有:

  • fiber node
  • current tree
  • work-in-progress tree
  • side-effects
  • effects list

同时,我也对主要算法进行了宏观上的阐述,也解释过render阶段和commit阶段之间的差异性。如果你还没有阅读过讲述这些东西的文章,我建议你先去阅读。

我也引入过一个简单demo。这个demo的主要功能是通过点击button来增加界面上的一个数字。

你可以这里去玩玩它。这个demo实现了一个简单的组件。这个组件的render方法返回了两个子组件:button和span。当你点击界面上的按钮的时候,我们会在click的事件处理器中去更新组件的state。结果是,界面上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是如何添加effects和在commit阶段调用这个方法的。

在本文中,我会带你看看,React是如何处理state更新和构建effects list的。我们也对rendercommit阶段的顶层函数进行简单的讲解。

特别地,我们着重看看completeWork方法:

  • 更新ClickCounter组件state中的count属性。
  • 调用组件实例的render方法,获取到children列表,然后执行比对。
  • 更新span元素的props。

commitRoot方法:

  • 更新span元素的textContent属性。
  • 调用componentDidUpdate这个生命周期函数。

在深入这些东西之前,我们快速地过一遍“当我们在click事件处理器中调用setState的时候,work是如何被调度”的这一环节。

Scheduling updates

当我们点击界面上的button的时候,click事件被触发了,然后React执行我们作为props传递进去的事件回调。在我们的demo中,这个事件回调就是简单地通过增加count字段值来更新组件的状态。

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

每一个React组件都有自己的updater,这个updater充当着组件与React core通讯的桥梁。这种设计,使得多个render(比如:ReactDOM, React Native, server side rendering和testing utilities)去实现自己的setState方法成了可能。

在这篇文章中,我们单独分析一下updater对象在ReactDOM中的实现。在这个实现中,就用到了Fiber reconciler。具体对于ClickCounter组件来说,这个updater对象就是classComponentUpdater。它的职责有:1)把Fiber的实例检索回来; 2)将更新请求入队;3)对work进行调度。

当我们说“一个更新请求被入队”,其实意思就是把一个setState的callback添加到Fiber node的“updateQueue”队列中去,等待处理。回归到本示例,ClickCounter组件所对应的Fiber node具体的数据结构:

{
    stateNode: new ClickCounter,
    type: ClickCounter,
    updateQueue: {
         baseState: {count: 0}
         firstUpdate: {
             next: {
                 payload: (state) => { return {count: state.count + 1} }
             }
         },
         ...
     },
     ...
}

正如你所看到的那样,updateQueue.firstUpdate.next.payload引用所指向的那个函数就是我们传给setState方法的那个callback。它代表着render阶段第一个需要被处理的“更新请求”。

处理ClickCounter Fiber node身上的更新请求

在我先前的那篇文章关于work loop的那一章节中,我已经解释过nextUnitOfWork这个全局变量所扮演的角色了。特别地,这一章节说到了这个全局变量指向的是workInProgresstree上那些有work需要去做的Fiber node。当React遍历整颗Fiber树的时候,就是用这个全局变量来判断是否还有未完成自己的work的Fiber node。

我们从setState方法已经被调用的地方开始说起。在setState方法被调用之后,React会把我们传给setState的callback传递ClickCounterfiber node,也就是说把这个callback添加到fiber node的updateQueue对象中。然后,就开始调度work。也是从这里开始,React开始进入了render阶段了。它调用renderRoot这个函数,从最顶层的HostRoot开始遍历整颗fiber node树。尽管是从最顶层的根节点开始,但是React会掉过那些已经处理过的 fiber node,只会处理那些还有work需要去完成的节点。此时此刻,我们只有一个fiber node是有work需要去做的。这个node就是ClickCounterfiber node。

ClickCounterfiber node的alternate字段用于保存一个指向[当前fiber node的克隆副本]的引用。这个克隆副本上的work都是已经执行完成的了。这个克隆副本被称为当前fiber node的alternate fiber node。如果alternate fiber node还没有被创建的话,那么React就会在处理更新请求之前使用createWorkInProgress函数去完成复制工作。现在,我们假设变量nextUnitOfWork保存着指向当前fiber node的alternate fiber node的引用。

beginWork

首先,我们的fiber node将会被传递到beginWork 函数里面。

因为这个函数会在fiber node tree上的每一个节点调用。所以,如果你想调试render阶段,这是一个打断点的好地方。我经常这么干,通过检测fiber node的type值来确定当前节点是否是我要跟进的那个。

beginWork函数基本上就是一个大的switch语句。在这个switch语句,beginWork根据workInProgress的tag值来计算初当前fiber node所需要完成的work的类型。然后,执行相应的函数去执行这个work。在我们的demo中,因为ClickCounter是一个class component,所以,我们会执行以下的分支语句:

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

那么,我们会进入updateClassComponent函数中。取决于当前:1)是否是组件的首次渲染:2)是否是work正在被恢复执行;3)是否是一次React更新,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 will already have an instance we can reuse.
        shouldUpdate = resumeMountClassInstance(workInProgress, Component, ...);
    } else {
        shouldUpdate = updateClassInstance(current, workInProgress, ...);
    }
    return finishClassComponent(current, workInProgress, Component, shouldUpdate, ...);
}

Processing updates for the ClickCounter Fiber

我们已经为ClickCounter创建过一个实例了,所以,我们的执行将会进入updateClassInstance方法。在这个方法中,React执行了class component绝大部分的work。以下是这个方法执行的最重要的操作(罗列的顺序也是代码执行的顺序):

  • 调用UNSAFE_componentWillReceiveProps生命周期函数(已弃用);
  • 处理updateQueue中的更新请求和生成一个新的state值;
  • 用一个新的state值去调用getDerivedStateFromProps,并获取调用结果。
  • 调用shouldComponentUpdate来确保一个组件是否真的想要更新。如果调用返回值为false的话,那么React将会跳过整个渲染流程包括调用组件实例和它的子组件实例的render方法。否则的话,正常走更新流程。
  • 调用UNSAFE_componentWillUpdate生命周期函数(已弃用);
  • 把生命周期函数componentDidUpdate添加成一个effect。

虽然,“调用componentDidUpdate”这个effect是在render阶段添加的,但是这个方法的实际执行是在接下来的commit阶段。

  • 更新组件实例上的state和props值。

state和props值的更新应该是在render方法调用前的。因为render的返回值是需要依赖最新的state和props值(译者注:这也是指出了一个事实,即react组件更新的本质就是用最新的state和props值去调用组件实例的render方法)。如果我们不这么干的话,那么render方法的每一次调用的返回值都是一样的。

下面是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;
}

我已经把一些比较次要的代码移除掉了。举个例子,在调用生命周期函数和添加effect并触发它之前,React会用typeof操作符去检查这个组件是否实现了某个方法。下面的代码中,React会在添加effect之前检查componentDidUpdate方法是否是一个function:

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

到了这里,我们已经知道在render阶段,ClickCounter fiber node需要执行哪些操作了。下面,我们来看看,这些操作是如何改变fiber node上的相关值的。当React开始执行work的时候,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) => {…}
            }
        },
        ...
    }
}

当work执行完毕,ClickCounter组件所对应的fiber node已经长成这样的:

{
    effectTag: 4,
    elementType: class ClickCounter,
    firstEffect: null,
    memoizedState: {count: 1},
    type: class ClickCounter,
    stateNode: {
        state: {count: 1}
    },
    updateQueue: {
        baseState: {count: 1},
        firstUpdate: null,
        ...
    }
}

仔细观察一下连个fiber node属性值之间的差异。我们会发现,在处理完更新请求后,memoizedState和baseState中的count字段的属性值已经变为1了。与此同时,React也把ClickCounter的组件实例的状态也更新了。

当前,我们在updateQueue中已经没有更新请求了,所以firstUpdate的值为null。还有很重要的一点,我们的effectTag字段的值已经从0变为4了。4用二进制表示就是100,而这就是update这个side-effect的tag值

export const Update = 0b00000000100;

下面做个小总结。当React在ClickCounterfiber node上执行work的时候,React要做的事有:

  • 调用pre-mutation生命周期方法
  • 更新state值
  • 定义相关的side-effect(译者注:将某些操作标记为side-effect)

Reconciling children for the ClickCounter Fiber

当上面提到的小总结的东西完成后,React执行将会进入finishClassComponent。在这个函数里面,React将会调用组件实例的render方法,然后在它的子组件实例(正是render方法返回的东西)上应用diff算法。在这篇文章里面有一个关于diff算法高质量的概括:

当对比中的两个react DOM element(译者注:本质上就是react element,但是type的值是DOM类型的字符串)具体相同的type的时候,React会查看两者的attribute的差异性,保留底层所对应的DOM node对象,只是更新那些需要改变的attribute。

如果我们再深究一点的话,那么,我们会了解到其实对比是react element所对应的fiber node。在本文中,我不会讨论太多细节,因为这里面的处理流程还是挺复杂的。我将会在一个单独的文章上专门来讲述child reconciliation的处理流程。

如果你着急去了解child reconciliation细节的话,那么你可以查看这个reconcileChildrenArray函数。因为在我们这个demo中,ClickCounter的render方法返回的是一个react element组成的数组。

当前,有两件重要的事情需要我们去理解。第一件是,随着child reconciliation流程的执行,React会为从render方法中返回的child react element创建或者更新对应的fiber node。finishClassComponent函数会返回当前fiber node第一个child fiber node的引用。这个引用将会赋值给nextUnitOfWork,并且会在work loop的下一个循环中使用到;第二件事是,React把对子fiber node 的props的更新当作父fiber node的work的一部分。为了达成这事,React会使用从render方法返回的react element身上的数据。

举个例子,在React对ClickCounterfiber node 的children进行reconcile之前,span元素所对应的fiber node是长这样的:

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

正如你所见的那样,memoizedPropspendingProps中的children属性值都是0 。而下面,就是调用render方法后返回的span元素所对应的react element:

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

正如你所见的那样,fiber node中的props与返回的react element中的props是不同的。在createWorkInProgress函数中,这种不同性会应用 在alternate fiber node的创建过程中。React就是从react element上拷贝已经更新的props到alternate fiber node上的。

当React对ClickCounter组件的children完成了reconcile之后,span元素所对应的fiber node的pendingProps字段的值将得到更新。该字段值将会跟span元素所对应的react element的props值保持一致:

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

稍后,React会span元素所对应的fiber node执行work,它会将它们复制到memoizedProps上,并向DOM更新上添加effect(add effect to DOM update)。

到此为止,我们已经讲完了ClickCounterfiber node在render阶段所需要执行的所有的work了。因为button组件是ClickCounter组件的第一个子元素,所以,它所对应的fiber node将会被赋值给nextUnitOfWork变量。因为这个fiber node没有任何work需要去做的。所以,React会移步到它的sibling-span元素所对应的fiber node。根据这里所描述的算法可以得知,以上过程发生在completeUnitOfWork函数里面。

Processing updates for the Span fiber

所以,nextUnitOfWork变量现在指向span元素所对应的fiber node(后面简称为“span fiber node”)的alternate fiber node。React对span fiber node的更新处理流程就是从这里开始。跟ClickCounter fiber node的处理流程是一样的,我们都是从beginWork函数开始。

因为span节点属于HostComponent类型的,所以,这一次,我们会进入HostComponent的分支:

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

最终,我们会进入updateHostComponent这个函数。往上,你可以看到我们上面在分析ClickCounter fiber node时候的所提到的updateClassComponent,针对functional component,React会执行updateFunctionComponent等等。你可以在ReactFiberBeginWork.js文件中找到所有的这些函数的实现代码。

Reconciling children for the span fiber

在我们的demo中,因为span节点的子节点太过简单了,所以在updateHostComponent函数中,没啥太重要的事情发生。

Completing work for the Span Fiber node

一旦beginWork执行完毕,当前fiber node就会被传递到completeWork中去。在本示例中,这个fiber node就是span fiber node。在此之前,React需要更新span fiber node上的memoizedProps字段值。你可能还记得,当React对ClickCounter组件的子组件进行reconcile的时候,它已经更新span fiber node上的pendingProps字段:

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

所以,一旦beginWork函数在span fiber node上调用完毕的话,那么React会更新memoizedProps字段值,使得它与pendingProps字段值保持一致:

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

执行完beginWork函数后,React就会执行completeWork函数。这个函数的实现基本上就是一个大大的switch语句。这跟之前所提到的beginWork里面的switch语句差不多:

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

因为我们的span fiber node(所对应的react element)是HostComponent,所以,我们会进入到updateHostComponent函数里面。在这个函数里面,React基本上就做了以下的三件事情:

  • 为DOM更新做准备
  • 将准备的结果添加到span fiber node的updateQueue字段中;
  • adds the effect to update the DOM

在执行这行操作之前,span fiber node长这样的:

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

当上面的work执行完成后,span fiber node长这样:

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

请注意两者在effectTag和updateQueue字段值上的不同。对于effectTag的值来说,它不再是0,而是4。用二进制表示就是100,而第三位就是update这 种side-effect所对应的二进制位。如今该位置为1,则说明span fiber node后面所需要执行的side-effect就是update。在接下来的commit阶段,对于span fiber node来说,这也是React唯一需要帮它完成的任务了。而updateQueue字段值保存的是用于update的数据。

一旦React处理完ClickCounterfiber node和它的子fiber node们,那么render阶段算是结束了。React会把产出的alternate fiber node树赋值给FiberRoot对象的finishedWork属性。这颗新的alternate fiber node树包含了需要被flush到屏幕的东西。它会在render阶段之后马上被处理或者稍后在浏览器分配给React的,空闲的时间里面执行。

effects list

在我们给出的示例中,因为span fiber node和ClickCounter fiber node是有side effect的。React将会给span fiber node添加一个link,让它指向HostFiber的firstEffect属性

在函数compliteUnitWork中,react完成了effect list的构建。下面就是本示例中,带有effect的fiber node树。在这棵树上,有着两个effect:1)更新span节点的文本内容;2)调用ClickCounter组件的生命周期函数:

而下面是由具有effect的fiber node组成的线性列表:

commit阶段

这个阶段以completeRoot函数开始。在继续往下走之前,它首先将FiberRoot的finishedWork属性值置为null:

root.finishedWork = null;

不像render阶段,commit阶段是同步执行的。所以,它能很安全地更新HostRoot,以此来指示commit工作已经开始了。

commit阶段是React进行DOM操作和调用post-mutation生命周期方法componentDidUpate的地方。为了实现上面这些目标,React会遍历render阶段所产出的effect list,并应用相应的effect。

就本示例而言,我们在render阶段过后,我们有以下几个effect:

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

ClickCounterfiber node的effect tag为5,用二进制表示就是“101”。它对应的work是update。而对于class component而言,这个work会被“翻译为”componentDidUpdate这个生命周期方法。在二进制“101”中,最低位为“1”,代表着当前这个fiber node的所有work都在render阶段执行完毕了。

span fiber node的effect tag值是4,用二进制表示是“100”。这个编号所代表的work是“update”,因为当前的span fiber node对应的是host component类型的。这个“update”work更具体点来说就是“DOM更新”。回归到本示例,“DOM更新”更具体点是指“更新span元素的textContent属性”。

Applying effects

让我们一起来看看,React是如何应用这些effect的。函数commitRoot就是用来应用effect的。它由三个子函数组成:

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

这三个子函数都实现对effect list的遍历,并且在遍历过程中去检查effect的类型。如果它们发现当前的这个effect跟它们函数的职责相关的,那么就会应用这个effect。在我们的示例中,具体点讲就是在ClickCounter组件上调用componentDidUpdate这个生命周期方法和更新span元素的文本内容。

第一个子函数commitBeforeMutationLifeCycles 会查找snapshot类型的effect,并调用getSnapshotBeforeUpdate方法。因为在ClickCouner组件身上,我们并没有实现这个方法,所以React并没有在render阶段把这个effect添加到该组件对应的fiber node身上。所以,在我们这个示例中,这个子函数啥事都没做。

DOM updates

接下来,React会移步到commitAllHostEffects函数上面来。就是在这个函数里面,React完成了将span元素的文本内容从“0”更新到“1”。这个函数几乎跟ClickCounter这个fiber node没有关系。因为这个fiber node对应的是class component,而class componnet是没有任何的直接的DOM更新方面的需求的。

这个函数的大体框架是对不同类型的effect执行不同操作。在我们这个示例中,我们需要更新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 node的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更新]这个effect被应用后,React将finishedWork树赋值给HostRoot。它把alternate tree设置为current tree:

root.current = finishedWork;

Calling post mutation lifecycle hooks

我们剩下最后一个commitAllLifecycles要讲了。在这个函数里面,React调用了所有的post-mutational 生命周期方法。在render阶段,React往ClickCounter组件身上添加了一个叫“update”的effect。这个effect就是本函数所要查找的effect,一旦找到之后,React就会调用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,但是因为我们这个示例中并没有使用到这个特性。所以相应的那部分代码(指commitAttachRef(nextEffect);)就不会被执行。对componentDidUpdate方法的调用是发生在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这个生命周期方法的地方。不过这个调用时机是在组件的首次挂载的过程中而已。