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

在我的上篇文章 Inside Fiber: 深入了解React新协调算法中介绍了理解更新过程细节的所需的基础知识,我将在本文中描述这个更新过程。

我已经概述了将在本文中使用的主要数据结构和概念,特别是Fiber节点,currentwork-in-progress树,副作用(side-effects)以及effects链表(effects list)。我也提供了主要算法的高级概述和render阶段与commit阶段的差异。如果你还没有阅读过它,我推荐你从那儿开始。

我还向你介绍了带有一个按钮的示例程序,这个按钮的功能就是简单的增加数字。

你可以在这查看在线代码。它的实现很简单,就是一个render函数中返回buttonspan元素的类组件。当你点击按钮的时候,在点击事件的处理函数中更新组件的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是如何处理状态更新和创建effects list的。我们可以看到render阶段和commit阶段的高级函数中发生了什么。

尤其是在React的completeWork函数中:

  • 更新ClickCounterstate中的count属性
  • 调用render方法获取子元素列表并比较
  • 更新span元素的props

以及,在React的commitRoot 函数中:

  • 更新span元素的文本内容属性
  • 调用componentDidUpdate生命周期方法

但是在那之前,我们先快速看看当我们在点击处理函数中调用setState时工作是如何调度的。

请注意,你无需了解这些来使用React。本文是关于React内部是如何运作的。

调度更新

当我们点击按钮时,click事件被触发,React执行传递给按钮props的回调。在我们的程序中,它只是简单的增加计数器和更新state

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

每个组件都有相应的updater,它作为组件和React核心之间的桥梁。这允许setState在ReactDOM,React Native,服务端渲染和测试程序中是不同的实现。(译注:从源码可以看出,setState内部是调用updater.enqueueSetState,这样在不同平台,我们都可以调用setState来更新页面)

本文中,我们关注ReactDOM中实现的updater对象,它使用Fiber协调器。对于ClickCounter组件,它是classComponentUpdater。它负责获取Fiber实例,为更新入列,以及调度work。

当更新排队时,它们基本上只是添加到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节点的更新

我上篇文章中的work循环部分中解释了全局变量nextUnitOfWork的角色。尤其是,这个变量保存workInProgress树中有work待做的Fiber节点的引用。当React遍历树的Fiber时,它使用这个变量知道是否存在其他有未完成work的Fiber节点。

我们假定setState方法已经被调用。 React将setState中的回调添加到ClickCounterfiber节点的updateQueue中,然后调度work。React进入render阶段。它使用renderRoot函数从最顶层HostRootFiber节点开始遍历。然而,它会跳过已经处理过得Fiber节点直到遇到有未完成work的节点。基于这点,只有一个节点有work待做。它就是ClickCounterFiber节点。

所有的work都是基于保存在Fiber节点的alternate字段的克隆副本执行的。如果alternate节点还未创建,React在处理更新前调用createWorkInProgress函数创建副本。我们假设nextUnitOfWork变量保存代替ClickCounterFiber节点的引用。

beginWork

首先, 我们的Fiber进入beginWork函数。

因为这个函数对树中每个节点执行,所以如果你想调试render阶段,它是放置断点的好地方。 我经常这样做,还有检查Fiber节点的type来确定我需要的节点。

beginWork函数大体上是个大的switch语句,通过tag确定Fiber节点需要完成的work的类型,然后执行相应的函数来执行work。在这个例子中,CountClicks是类组件,所以会走这个分支:

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

我们进入updateClassComponent函数。取决于它是首次渲染、恢复work还是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'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。这是React为类组件执行大部分work的地方。以下是在这个函数中按顺序执行的最重要的操作:

  • 调用UNSAFE_componentWillReceiveProps()钩子(已废弃)
  • 处理updateQueue中的更新以及生成新state
  • 使用新state调用getDerivedStateFromProps并得到结果
  • 调用shouldComponentUpdate确定组件是否需要更新;如果返回结果为false,跳过整个渲染过程,包括在该组件和它的子组件上调用render;否则继续更新
  • 调用UNSAFE_componentWillUpdate(已废弃)
  • 添加一个effect来触发componentDidUpdate生命周期钩子

尽管调用componentDidUpdate的effect是在render阶段添加的,这个方法将在接下来的commit阶段执行。

  • 更新组件实例的stateprops

组件实例的stateprops应该在render方法调用前更新,因为render方法的输出通常依赖于stateprops。如果我们不这样做,它每次都会返回一样的输出。

下面是该函数的简化版本:

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;
}
复制代码

上面代码片段中我删除了一些辅助代码。对于实例,调用生命周期方法或添加effects来触发它们前,React使用typeof操作符检查组件是否实现了这些方法。比如,这是React添加effect前如何检查componentDidUpdate方法:

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

好的,我们现在知道了render阶段中为ClickCounterFiber节点执行了什么操作。现在让我们看看这些操作如何改变Fiber节点的值。当React开始work,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) => {…}
            }
        },
        ...
    }
}
复制代码

work完成后,我们得到一个长这样的Fiber节点:

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

花点时间观察属性值的差异

更新被应用后,memoizedStateupdateQueuebaseState的属性count的值变为1。React也更新了ClickCounter组件实例的state。

至此,队列中不再有更新,所以firstUpdatenull。更重要的是,我们改变了effectTag属性。它不再是0,它的是为4。 二进制为100,意味着第三位被设置了,代表Update副作用标记

export const Update = 0b00000000100;
复制代码

可以得出结论,当执行ClickCounterFiber节点的work时,React低啊用变化前生命周期方法,更新state,定义有关的副作用。

协调ClickCounter Fiber的子组件

在那之后,React进入finishClassComponent。这是调用组件实例render方法和在子组件上使用diff算法的地方。文档中对此有高级概述。以下是相关部分:

当比较两个相同类型的React DOM元素时,React查看两者的属性(attributes),保留DOM节点,仅更新变化了的属性。

然而,如果我们深入挖掘,会知道它实际是对比Fiber节点和React元素。但是我现在不会详细介绍因为过程相当复杂。我会单独些篇文章,特别关注子协调过程。

如果你想自己学习细节,请查看reconcileChildrenArray函数,因为在我们的程序中render方法返回一个React元素数组。

至此,有两个很重要的事需要理解。第一,当React进行子协调时,它会为从render函数返回的子React元素创建或更新Fiber节点。finishClassComponent函数当前Fiber节点的第一个子节点的引用。它被赋值给nextUnitOfWork并在稍后的work循环中处理。第二,React更新子节点的props作为父节点执行的一部分work。为此,它使用render函数返回的React元素的数据。

举例来说,这是React协调ClickCounterfiber子节点之前span元素对应的Fiber节点看起来的样式

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

可以看到,memoizedPropspendingPropschildren属性都是0。这是render函数返回的span元素对应的React元素的结构。

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

可以看出,Finer节点和返回的React元素的props是有差异的createWorkInProgress内部用这创建替代的Fiber节点,React把React元素中更新的属性复制到Fiber节点

因此,在React完成ClickCounter组件子协调后,span的Fiber节点的pendingProps更新了。它们将匹配spanReact元素中的值。

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

稍后,React会为spanFiber节点执行work,它将把它们复制到memoizedProps以及添加effects来更新DOM。

好的,这就是render阶段React为ClickCounterfiber节点所执行的所有work。因为button是ClickCounter组件的第一个子节点,它会被赋值给nextUnitOfWork变量。button上无事可做,所有React会移动到它的兄弟节点spanFiber节点上。根据这里描述的算法,这发生在completeUnitOfWork函数内。

处理Span fiber的更新

nextUnitOfWork变量现在指向spanfiber的alternate,React基于它开始工作。和ClickCounter执行的步骤类似,开始于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节点的work

一旦beginWork完成,节点就进入completeWork函数。但是在那之前,React需要更新span Fiber节点的memoizedProps属性。你应该还记得协调ClickCounter组件子节点时更新了spanFiber节点的pendingProps

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

所以一旦spanfiber的beginWork完成,React会将pendingProps更新到memoizedProps

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

然后调用的completeWork和我们看过的beginWork相似,基本上是一个大的switch语句。

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

由于spanFiber节点是HostComponent,它会执行updateHostComponent函数。在这个函数中React大体上做了这些事:

  • 准备DOM更新
  • 把它们加到spanfiber的updateQueue
  • 添加effect用于更新DOM

在这些操作执行前,spanFiber节点看起来像这样:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    effectTag: 0
    updateQueue: null
    ...
}
复制代码

works完成后它看起来像这样:

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

注意effectTagupdateQueue字段的差异。它不再是0,它的值是4。用二进制表示是100,意味着设置了第3位,正是Update副作用的标志位。这是React在接下来的commit阶段对这个节点唯一要做的任务。updateQueue保存着用于更新的载荷。

一旦React处理完ClickCounter级它的子节点,render阶段结束。现在它可以将完成的替代树赋值给FiberRootfinishedWork属性。这是需要被刷新到屏幕上的新树。它可以在render阶段之后马上被处理,或这当React被浏览器给予时间时再处理。

Effects list

在我们的例子中,由于span节点ClickCounter组件有副作用,React将添加指向spanFiber节点的链接到HostFiberfirstEffect属性。

React在compliteUnitOfWork函数内构建effects list。这是带有更新span节点文本和调用ClickCounter上hooks副作用的Fiber树看起来的样子:

这是由有副作用的节点组成的线性列表:

Commit阶段

这个阶段开始于completeRoot函数。它在做其他工作之前,它将FiberRootfinishedWork属性设为null

root.finishedWork = null;
复制代码

于之前的render阶段不同的是,commit阶段总是同步的,这样它可以安全地更新HostRoot来表示commit work开始了。

commit阶段是React更新DOM和调用突变后生命周期方法componentDidUpdate的地方。为此,它遍历在render阶段中构建的effects list并应用它们。

有以下在render阶段为spanClickCounter定义的effects:

{ type: ClickCounter, effectTag: 5 }
{ type: 'span', effectTag: 4 }
复制代码

ClickCounter的effect tag的值是5或二进制的101,定义了对于类组件基本上转换为componentDidUpdate生命周期方法的Update工作。最低位也被设置了,表示该Fiber节点在render阶段的所有工作都已完成。

span的effect tag的值是4或二进制的100,定义了原生组件DOM更新的update工作。这个例子中的span元素,React需要更新这个元素的textContent

应用effects

让我们看看React如何应用这些effects。commitRoot函数用于应用这些effects,由3个子函数组成:

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

每个子函数都实现了一个循环,该循环用于遍历effects list并检查这些effects的类型。当发现effect和函数的目的有关时就应用它。我们的例子中,它会调用ClickCounter组件的componentDidUpdate生命周期方法,更新span元素的文本。

第一个函数 commitBeforeMutationLifeCycles 寻找 Snapshot effect然后调用getSnapshotBeforeUpdate方法。但是,我们在ClickCounter组件中没有实现该方法,React在render阶段没有添加这个effect。所以在我们的例子中,这个函数不做任何事。

DOM更新

接下来React执行 commitAllHostEffects 函数。这儿是React将span元素的t文本由0变为1的地方。ClickCounter fiber没有要做的,因为类组件的节点没有任何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节点的updateQueue载荷更新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。它将替代树是设为当前树:

root.current = finishedWork;
复制代码

调用突变后生命周期hooks

剩下的函数是commitAllLifecycles。这是 React 调用突变后生命周期方法的地方。在render阶段,React为ClickCounter组件添加Update effect。这是commitAllLifecycles寻找的effects之一并调用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方法的函数。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享