前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >小前端读源码 - React组件更新原理

小前端读源码 - React组件更新原理

作者头像
LamHo
发布2022-09-26 10:40:03
5870
发布2022-09-26 10:40:03
举报
文章被收录于专栏:小前端看世界

年后一直忙于工作,导致一直没有去继续阅读React的更新原理。今天我们接着往下阅读吧!

说到更新原理就离不开setState了,React是什么时候触发组件的更新的呢?就是通过自身触发setState改变组件自身的state,或者是传入的props改变的时候触发更新组件的。之前我们都有听说过React有一个很牛逼的虚拟DOM树,能通过比对虚拟DOM树的变化去进行最小化更新组件,从而提高整个DOM渲染的性能。这也是React的一大卖点之一。但是我们并不知道React是怎么知道更新了,以及怎么知道传入的props变化的,然后diff算法是如何快速判断到底哪个组件更新,哪个组件没有更新的,我们就带着这些问题去阅读吧!

可以先阅读setState机制,会更好理解之后的内容。

Lam:小前端读源码 - React16.7.0(深入了解setState)

本文基于以下DEMO进行阅读:

代码语言:javascript
复制
class App extends React.Component {
    constructor() {
        super();
        this.state = {
            text1: 1,
            text2: 1
        }
    }

    render() {
        return (
            <div id='a1'>
                <p>curr Data1: {this.state.text1}</p>
                <p>curr Data2: {this.state.text2}</p>
                <button onClick={() => {
                    this.setState({text1: 2})
                }}>setState</button>
            </div>
        )
    }
}

接下来我们快速简单的过一下setState的大概流程:

  1. 触发setState函数,将触发setState的this和setState的参数传入enqueueSetState函数中。
  2. enqueueSetState函数,提出当前触发setState的Fiber节点并将传入的setState的参数创建一个update对象,update对象中的payload就是传入的state对象。
  3. enqueueUpdate函数,将当前Fiber的state和需要修改的state创建一个对象传入当前Fiber节点的updateQueue对象中。updateQueue对象有几个关键值,baseState(当前Fiber节点的state)、firstUpdate(首个更新任务)、lastUpdate(最后一个更新任务,防止多次重复setState)。
  4. scheduleWork函数,更新子组件的时间戳。
  5. requestWork函数调用addRootToSchedule,并判断当前是否在渲染中,和是否批量更新。
  6. addRootToSchedule,将root赋值到全局的firstScheduledRoot,lastScheduledRoot函数中。

经过上面的setState调用栈,最终我们得出的整个Fiber树中,已经包含了本次更新的任务在App的Fiber节点的updateQueue对象中了。因为我们现在是通过合成事件触发setState的,所以并不会立即触发performWorkOnRoot函数。然后会一层一层回到interactiveUpdates$1函数的调用栈中,最终执行performWork函数。

如果对这一块有疑问可以看看以下文章:

Lam:小前端读源码 - React16.7.0(合成事件)

performWork

performWork函数中,会先通过findHighestPriorityRoot函数,将之前lastScheduledRoot变量赋值到nextFlushedRoot变量中(就是root)。通过将nextFlushedRoot传入到performWorkOnRoot函数中进行渲染。

performWorkOnRoot

在进入performWorkOnRoot函数时,会判断一个全局变量isRendering是否为true,如果为true代表当前正在执行performWorkOnRoot中,将会跳出本次渲染,等待下次,如果当前没有进行渲染,那么就会将全局的isRendering改为true。

最后就将当前的root对象传入renderRoot函数中进行render阶段。

详细的render阶段的介绍可以通过查看以下文章会有说到:

Lam:小前端读源码 - React16.7.0(三)Lam:小前端读源码 - React16.7.0(渲染总结篇)

之前的文章主要是说首次渲染的render和commit阶段,这次我们修改了state触发的render阶段会有一些不一样的情况。

现在的demo中,我们是改变state触发render的,所以在updateClassInstance函数中会有这么一段逻辑。

代码语言:javascript
复制
var oldState = workInProgress.memoizedState;
var newState = instance.state = oldState;
var updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
  processUpdateQueue(workInProgress, updateQueue, newProps, instance, renderExpirationTime);
  newState = workInProgress.memoizedState;
}
  1. workInProgress.memoizedState是当前组件的state
  2. newState是新的state值

还记得在setState的时候,将新的state作为一个任务存到updateQueue对象中。然后传入processUpdateQueue函数中。在processUpdateQueue函数中最终通过getStateFromUpdate函数返回新的state值。

在getStateFromUpdate中,会获取updateQueue中的firstUpdate的payload(setState传入的对象),如果本次触发render阶段的有传入state,那么将会和旧的state进行浅合并,否则返回旧的state。

代码语言:javascript
复制
if (typeof _payload2 === 'function') {
// Updater function
{
  if (debugRenderPhaseSideEffects || debugRenderPhaseSideEffectsForStrictMode && workInProgress.mode & StrictMode) {
    _payload2.call(instance, prevState, nextProps);
  }
}
  partialState = _payload2.call(instance, prevState, nextProps);
} else {
  // Partial state object
  partialState = _payload2;
}
if (partialState === null || partialState === undefined) {
  // Null and undefined are treated as no-ops.
  return prevState;
}
// Merge the partial state and the previous state.
return _assign({}, prevState, partialState);

最终updateQueue本来的baseState会被新的state替换,并将Fiber中的memoizedState替换为新的state。

这个时候就有一个问题了,现在的Fiber节点中已经不存在旧的state,怎么进行比对是否有变化呢?答案是在执行processUpdateQueue函数前,updateClassInstance函数内已经将旧的state保存在old_state变量中。

如果newProps === oldProps && newState === oldState的话,将会return false;

代码语言:javascript
复制
   if (oldProps === newProps && oldState === newState && !hasContextChanged() && !checkHasForceUpdateAfterProcessing()) {
    // If an update was already in progress, we should schedule an Update
    // effect even though we're bailing out, so that cWU/cDU are called.
    if (typeof instance.componentDidUpdate === 'function') {
      if (oldProps !== current.memoizedProps || oldState !== current.memoizedState) {
        workInProgress.effectTag |= Update;
      }
    }
    if (typeof instance.getSnapshotBeforeUpdate === 'function') {
      if (oldProps !== current.memoizedProps || oldState !== current.memoizedState) {
        workInProgress.effectTag |= Snapshot;
      }
    }
    return false;
  }

从这里其实可以知道为什么我们state有时候不会触发更新,例如text1是一个对象,我们修改他里面的值,因为最终我们修改的只是对象内部的属性,state.text1是没有改变内存地址,导致两个state对比是没有变化的。

当前我们触发了setState并且将test1的值从1改为2,所以state将不相等,所以将会跳入后面的代码。

之后会代用checkShouldComponentUpdate函数,改函数就是检测当前的Fiber节点中,是否有注册shouldComponentUpdate函数,如果有,就会调用shouldComponentUpdate函数将shouldComponentUpdate函数的返回结果return到updateClassInstance函数中。否则返回true。

updateClassInstance

代码语言:javascript
复制
function checkShouldComponentUpdate(workInProgress, ctor, oldProps, newProps, oldState, newState, nextContext) {
  var instance = workInProgress.stateNode;
  if (typeof instance.shouldComponentUpdate === 'function') {
    startPhaseTimer(workInProgress, 'shouldComponentUpdate');
    var shouldUpdate = instance.shouldComponentUpdate(newProps, newState, nextContext);
    stopPhaseTimer();

    {
      !(shouldUpdate !== undefined) ? warningWithoutStack$1(false, '%s.shouldComponentUpdate(): Returned undefined instead of a ' + 'boolean value. Make sure to return true or false.', getComponentName(ctor) || 'Component') : void 0;
    }

    return shouldUpdate;
  }

  if (ctor.prototype && ctor.prototype.isPureReactComponent) {
    return !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState);
  }

  return true;
}

因为当前demo中没有注册shouldComponentUpdate函数,所以会直接return true。

最终将新的props和state赋值到Fiber中的stateNode属性的props和state中,stateNode就是不同类型组件的实体类型,如果是一个class的Fiber,那么stateNode就是class本身,如果是html组件,那么stateNode就是实际的dom节点。

最终updateClassInstance函数返回shouldUpdate到updateClassInstance函数中。

finishClassComponent

代码语言:javascript
复制
finishClassComponent(current$$1, workInProgress, Component, shouldUpdate, hasContext, renderExpirationTime)

在finishClassComponent函数中,无论是否更新,都会更新refs的值(触发refs的回调函数)。

如果传入的shouldUpdate为false的话,会执行bailoutOnAlreadyFinishedWork函数。当前我们的shouldUpdate是为true的,所以继续往下看。

之后会触发当前Fiber的stateNode的render方式将class实例化出一个reactElement出来。这个时候reactElement因为外层的class的state变化,已经有所不同。之前是1,现在是2。

之后通过调用reconcileChildren函数,将实例化后的reactElement转换为Fiber节点保存到当前Fiber节点的child属性中。

详情可以看看以下文章:

Lam:小前端读源码 - React16.7.0(三)

因为现在在render阶段,那么workLoop会一直递归查找整个Fiber树的每一个Fiber节点的变化。回到我们的demo里面,这次的setState影响的是其中一个p标签的值。所以我们直接跳到workLoop对受影响的p标签有什么操作。

可以从断点中发现,原生html标签的Fiber节点描述标签内的任何东西是通过props来描述的。

在p标签中显示test1变量在Fiber节点中就是p标签的Fiber节点的props是1,下一个test的值是2。

  • memoizedProps -> 当前props
  • pendingProps -> 下一个props

在调用performUnitOfWork函数时,将pendingProps赋值到memoizedProps中,然后继续workLoop去render新的Fiber树。

直到完成workLoop,返回出renderRoot函数中的时候,更新state的render阶段就已经结束了。

接下来我们图解一下整个阶段发生了什么事情。

在render阶段,react还需要知道它需要更新的是什么,其中有几个关键的变量。

  1. effectTag -> 决定如何赋值firstEffect、lastEffect和nextEffect
  2. firstEffect -> 首次更改效果
  3. lastEffect -> 最后一次更改效果
  4. nextEffect -> 下一次更爱效果

在第一次渲染的时候,在completeWork函数,如果是text类型的或者标签类型的组件,当前Fiber树是第一次渲染的时候,那么effectTag都为0。而class类型的effectTag在第一次渲染的时候为。

那么在第一次渲染的时候,就决定了将App的Class赋值到Root的firstEffect和lastEffect为App的Fiber节点了。

那么在触发setState的时候,最终DEMO中改变的p标签的内容1变成2,那么在completeWork函数中1这个Text组件的时候,判断到不一样,那么就会为它的Fiber节点标记上4。

Fiber树其实有两颗

在每一次的renderRoot阶段,都会建立nextUnitOfWork变量。而这个变量是通过createWorkInProgress函数创建的(传入root)。

createWorkInProgress函数中会判断当前的RootFIber节点是否已经存在alternate节点(备用节点)。如果有则将RootFIber中的一些值更新到备用节点上,如果没有就新建一个备用节点。

在renderRoot函数中会将当前的Fiber节点传入createWorkInProgress函数中,最终返回备用节点,并赋值到nextUnitOfWork。然后整个workLoop的工作都将会在备用节点完成,最终形成一个备用树。包括上文说道的一切操作,到是在当前节点的备用节点上进行的。并不会改动当前节点的任何信息。

可能这么说会比较难懂,可以配合下图进行理解:

这是说明每个节点中的备用节点和当前节点的关系。

下图表示在经过render阶段后的两个树的状态:

从上图我们就很容易发现备用树和当前树的alternate是刚好相反的。而在setState后,备用树的所有需要改变的值都已经更新了。

commit阶段

经过render阶段对state和props的更新判断后,已经建立好了两个不一样的Fiber树了。接下来就去到commit阶段了。

代码语言:javascript
复制
 onComplete(root, rootWorkInProgress, expirationTime);

其中root是当前的root节点对象,rootWorkInProgress备用树!

commit阶段可以参考以下文章:

Lam:小前端读源码 - React16.7.0(渲染总结篇)

因为是通过setState触发了更新,最终生成的备用树中,受影响的节点只有一个p标签的一个内容,那么在进入到commitRoot函数中的时候,获取到的firstEffect就是Text组件的Fiber,因为触发state修改了p标签内的Text组件。

在commitRoot函数中,会根据当前firstEffect类型执行不同的更新方式,现在我们是属于一个Text组件,那么最终触发的是commitAllHostEffects函数。在commitAllHostEffects函数中会根据effectTag的标识来决定更新方式,那么当前是4,将会执行update的方式进行更新,进入commitWork函数。

commitWork函数会根据当前的组件类型选择不同更新方式,现在是一个Text组件,所以会执行commitTextUpdate函数进行更新。

代码语言:javascript
复制
function commitTextUpdate(textInstance, oldText, newText) {
  textInstance.nodeValue = newText;
}

到此,基本上整个更新的流程已经跑过一遍了,但是这个只是最简单的更新。如果稍微复杂一点呢。下面举两个例子:

  1. 如果更新的组件会涉及多个会如何更新?
  2. 如果更新后组件不是改变文字内容,而是渲染不同的组件呢?

如果更新的组件会涉及多个会如何更新

我们把DEMO修改一下,改为一次渲染导致两个p标签的内容需要更新。

代码语言:javascript
复制
class App extends React.Component {
    constructor() {
        super();
        this.state = {
            text1: 1,
            text2: 1
        }
    }

    render() {
        return (
            <div id='a1'>
                <p>{this.state.text1}</p>
                <p>{this.state.text1}</p>
                <button onClick={() => {
                    this.setState({text1: 2})
                }}>setState</button>
            </div>
        )
    }
}

在触发setState的时候,在render阶段,两个p标签因为内容需要更新,所以两个p标签的Fiber节点的effectTag都为4。

那么在completeUnitOfWork函数决定更新的循序就有变化了。

第一个的p标签Fiber节点执行完completeUnitOfWork后,父级div的Fiber节点的更新顺序如下:

第二个的p标签Fiber节点执行完completeUnitOfWork后,父级div的Fiber节点的更新顺序如下:

第三个button标签Fiber节点执行完completeUnitOfWork后,父级div的Fiber节点的更新顺序如下:

很奇怪为什么button都会更新呢,他又没有任何的改变!

那是因为在diff对比中,因为button中存在onClick属性,所以diff算法会对它特殊处理,会判断需要重新渲染。

最终在commitWork函数中,会循环根Fiber节点,因为这次是修改多个属性,所以渲染完firstEffect的Fiber后,会找firstEffect的Fiber节点是否存在nextEffect,如果存在则继续递归完成所有渲染!

更新state渲染不同的组件

再次修改DEMO。

代码语言:javascript
复制
class App extends React.Component {
    constructor() {
        super();
        this.state = {
            text1: 1,
            text2: 1
        }
    }

    render() {
        return (
            <div id='a1'>
                {this.state.text1 == 1 ? <p>curr text 1</p> : <p>curr text 2</p>}
                <button onClick={() => {
                    this.setState({text1: 2})
                }}>setState</button>
            </div>
        )
    }
}

在render阶段的时候,当建立div的Fiber节点的时候,需要循环divFiber节点的children属性。这个时候children是两个reactElement,分别为p和button。

在updateElement函数中,有这么一段逻辑,如果当前传入的reactElement的类型和当前对应节点的类型是同样的话,会复用Fiber节点,只需要修改当前节点的备用节点(alternate)。否则会新建一个Fiber节点。并且将当前父级节点的firstEffect和lastEffect设置为旧的Fiber节点,并设置父级Fiber节点的effectTag = 8。

代码语言:javascript
复制
function deleteChild(returnFiber, childToDelete) {
    if (!shouldTrackSideEffects) {
      // Noop.
      return;
    }
    // Deletions are added in reversed order so we add it to the front.
    // At this point, the return fiber's effect list is empty except for
    // deletions, so we can just append the deletion to the list. The remaining
    // effects aren't added until the complete phase. Once we implement
    // resuming, this may not be true.
    var last = returnFiber.lastEffect;
    if (last !== null) {
      last.nextEffect = childToDelete;
      returnFiber.lastEffect = childToDelete;
    } else {
      returnFiber.firstEffect = returnFiber.lastEffect = childToDelete;
    }
    childToDelete.nextEffect = null;
    childToDelete.effectTag = Deletion;
  }

那么这里就有一个优化的点了,就是如果对不同state进行判断渲染不同的组件的时候,应该尽量使用相同的HTML标签,减少react卸载元素和重新创建Fiber节点的操作。使用同样的HTML标签能让react对需要改变的标签替换内容即可。

Diff

整个更新流程下来了,其实决定如何更新的是通过firstEffect、lastEffect、nextEffect和effectTag。那么Diff在那里呢?

diffProperties函数就是diff算法的函数。什么时候调用呢?下面是调用顺序:

在render的最后阶段,会对比新旧Fiber节点的不一样,去决定是否更新Fiber节点。

diff算法网上有很多教学,这里就不一一细说。有兴趣可以去看看源码,大概就是新旧的props会根据不同的参数例如style、children、dangerouslySetInnerHTML等等不同的参数会有不同的对比方式。最终返回更新内容的一个数组,然后为对应Fiber节点的effectTag打上标记,然后在commit阶段就知道应该如何更新组件了。

阅读源码的文章基本上就是到此结束了。开始阅读系列的时候才16.7,读完后变16.8.4了。救命。有机会再写一下关于React Hook的一些文章吧。

喜欢就点个赞,关注一下我的专栏吧!

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2019-03-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • performWork
  • performWorkOnRoot
  • updateClassInstance
  • finishClassComponent
  • Fiber树其实有两颗
  • commit阶段
  • 如果更新的组件会涉及多个会如何更新
  • 更新state渲染不同的组件
  • Diff
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档