详解react的diff算法

274 阅读5分钟

React Diff算法

前置了解

我们知道react采用了双缓存的技术,最多同时存在两棵树,一棵是current Fiber树(就是当前展示在屏幕上的),另一颗是workInProgress Fiber树(在内存中构建)。React应用的根结点通过current指针在不同的Fiber树的rootFiber进行切换,来实现current Fiber树指向的切换

当一个组件update的时候,React会将当前组件和这个组件在上一次更新时对应的Fiber节点进行比较,这个就是Diff算法,然后将比较的结果生成新的Fiber节点。

而Diff算法的本质就是对比current FiberJSX对象(React.createElement生成的对象),生成workInProgress Fiber

Diff的瓶颈解决

diff操作本身存在一定的性能损耗,在最前沿的算法里,对比前后两棵树的算法复杂程度为O(n3),n是树中元素的数量。

那么如果有100个元素,需要执行的计算量也是百万量级的了。

因此,React预设了相关限制:

  1. 同级元素进行Diff
  2. 两个不同类型的元素会产生出不同的树,如果元素从div变成p,会销毁div及其子孙节点,新建p及其子孙节点。
  3. 可以通过key让一些子元素在不同的渲染下保持稳定。

如何保持稳定呢?

// 更新前
<div>
  <div key="a">a</div>
  <h1 key="b">b</h1>
</div>

// 更新后
<div>
  <h1 key="b">b</h1>
  <div key="a">a</div>
</div>

如果没有key,当它们diff的时候会认为第一个节点从div变成了h1,会销毁并新建。有了key之后就知道它们只是更换了一下顺序,dom节点可以复用。

Diff如何实现

点击查看react的源码 从Diff的入口函数reconcileChildFibers我们可以看到,会根据newChild(JSX对象)类型去调用不同的函数。

// 根据newChild调用不同的函数
function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {

    // 是否是没有key值的顶层REACT_FRAGMENT_TYPE元素
    const isUnkeyedTopLevelFragment =
      typeof newChild === 'object' &&
      newChild !== null &&
      newChild.type === REACT_FRAGMENT_TYPE &&
      newChild.key === null;
    // 如果是,则将newChild指向他的children
    if (isUnkeyedTopLevelFragment) {
      newChild = newChild.props.children;
    }

    // newChild是否是一个对象
    const isObject = typeof newChild === 'object' && newChild !== null;

    if (isObject) {
      // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE 或 REACT_LAZY_TYPE
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          return placeSingleChild(
            reconcileSingleElement(
              returnFiber,
              currentFirstChild,
              newChild,
              lanes,
            ),
          );
        case REACT_PORTAL_TYPE:
          return placeSingleChild(
            reconcileSinglePortal(
              returnFiber,
              currentFirstChild,
              newChild,
              lanes,
            ),
          );
        case REACT_LAZY_TYPE:
          if (enableLazyElements) {
            const payload = newChild._payload;
            const init = newChild._init;
            // TODO: This function is supposed to be non-recursive.
            return reconcileChildFibers(
              returnFiber,
                currentFirstChild,
              init(payload),
              lanes,
            );
          }
      }
    }

    if (typeof newChild === 'string' || typeof newChild === 'number') {
     // 调用 reconcileSingleTextNode 处理
      return placeSingleChild(
        reconcileSingleTextNode(
          returnFiber,
          currentFirstChild,
          '' + newChild,
          lanes,
        ),
      );
    }
    if (isArray(newChild)) {
      // 调用 reconcileChildrenArray 处理
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }
    // 其他情况省略
    // 以上都没有命中,删除节点
    return deleteRemainingChildren(returnFiber, currentFirstChild);
  }

我们可以将Diff分为2类:

  1. newChild类型是objectnumberstring时,代表同级只有一个节点。——单节点Diff
  2. newChild类型为Array,同级有多个节点。——多节点Diff

单节点Diff

可以查看源码中的reconcileSingleElement函数,以object为例:

// newChild是否是一个对象
    const isObject = typeof newChild === 'object' && newChild !== null;

    if (isObject) {
      // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE 或 REACT_LAZY_TYPE
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          return placeSingleChild(
            reconcileSingleElement(
              returnFiber,
              currentFirstChild,
              newChild,
              lanes,
            ),
          );
//          ...

image.png

如何判断DOM节点是否可以复用?

React会先判断key是否相同,如果key相同,则会去判断type是否相同,如果都相同,那么这个DOM节点才能复用。

细节:

child! == null并且key相同type不同时会执行deleteRemainingChildrenchild和它兄弟fiber都标记删除。(因为此处是单一节点,找到了对应的key,原先的child都可以删了)

如果child!==null并且key不同时就仅仅将child标记删除

多节点Diff

可分为三种情况:

  • 节点更新
  • 节点新增或减少
  • 节点位置变化

思路

根据不同的情况,执行不同的逻辑。日常开发中,更新组件发生的频率高于新增删除,所以diff会优先判断是否更新。 整体逻辑会分为两轮遍历:

  1. 第一轮遍历处理更新的节点
  2. 第二轮遍历处理剩下的不属于更新的节点
第一轮遍历

遍历newChildren,将newChildren[i]oldFiber比较,判断节点是否可以复用。一般有以下三种情况:

  • key相同,type相同。说明可以复用,则根据 oldFiber 和 新 ReactElement 的 props 生成新fiber
  • key相同,type不同。则无法复用,会将oldFiber标记为DELETION,然后继续遍历
  • 如果key不同,则会跳出整个遍历,第一轮遍历结束。

如果newChildren遍历完了,或者oldFiber遍历完了,则跳出遍历,第一轮遍历结束。

第二轮遍历

第一轮遍历结束可分为以下情况

newChildren和oldFiber都遍历完了

只需要在第一轮遍历进行组件更新,这时候Diff结束。

newChildren没遍历完,oldFiber遍历完了

已有的DOM节点都被复用了,但是还有新增的节点,这时候就需要遍历剩下的newChildren给生成的workInProgress fiber标记Placement

newChildren遍历完了,oldFiber没遍历完

说明有节点被删除了,所以需要遍历剩下的oldFiber,标记Deletion

newChildrenoldFiber都没遍历完

说明节点在更新中改变了位置。具体如下。

处理移动的节点

处理移动的节点需要使用到key,会将未处理的oldFiber存入一个以key为key,oldFiber为value的Map中。

然后遍历剩下的newChildren,通过newChildren[i].key就能在Map中找到key相同的oldFiber

如何确定节点是否移动

根据最后一个可复用的节点在oldFiber中的位置索引,即lastPlacedIndex

更新中节点是按照newChildren的顺序排列,在遍历newChildren过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用节点中最靠右那个,即在lastPlacedIndex对应的可复用的节点在本次更新中位置的后面。

所以只需要比较遍历到的可复用节点在上次更新是否也在lastPlacedIndex对应的oldFiber后面

如果用变量oldIndex表示遍历到的可复用节点oldFiber中的位置索引。如果oldIndex<lastPlacedIndex,说明本次更新该节点需要往右移动。

lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex>=lastPlacedIndex,则lastPlacedIndex = oldIndex

这个就是React中的Diff算法,我们可以发现,从性能上,我们可以尽量减少节点从后面移动到前面的操作。