React源码解析之diff算法

5,212 阅读12分钟

diff的作用

在React中,diff算法需要与虚拟DOM配合才能发挥出真正的威力。React会使用diff算法计算出虚拟DOM中真正发生变化的部分,并且只会针对该部分进行dom操作,从而避免了对页面进行大面积的更新渲染,减小性能的开销。

React diff算法

在传统的diff算法中复杂度会达到O(n^3),比如说我们页面有1000个元素,那么则需要对比10亿次,效率十分低下,不能满足前端渲染所需要的效率。为了解决这个问题,React中定义了三种策略,在对比时,根据策略只需遍历一次树就可以完成对比,将复杂度降到了O(n):

  1. tree diff:在两个树对比时,只会比较同一层级的节点,会忽略掉跨层级的操作
  2. component diff:在对比两个组件时,首先会判断它们两个的类型是否相同,如果不同则不会进一步向下比较,会直接销毁组件,创建新的组件插入。
  3. element diff:对于同一层级的一组节点,会使用具有唯一性的key来区分是否需要创建,删除,或者是移动。

源码结构

diff的入口函数是reconcileChildren

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    // current === null,说明是创建,不是更新,调用mountChildFibers函数根据子元素创建Fiber
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
   // 对current树和workInProgress树进行diff算法对比,找出差异部分
    workInProgress.child = reconcileChildFibers(
      workInProgress, // workInProgress书上的Fiber节点
      current.child, // current树上的当前Fiber节点的子节点
      nextChildren, // 使用最新数据生成的React element元素
      renderLanes, // 渲染的lane优先级集合
    );
  }
}

可以看到这个函数首先判断workInProgress树上的Fiber节点对应的current树上的Fiber节点是否存在,如果等于null,则不存在,说明是创建,不是更新,然后调用mountChildFibers函数根据调用render函数生成的React elements构建workInprogress树。如果不等于null,则存在,说明是更新,然后会调用reconcileChildFibers函数,对current树和workInProgress树进行diff算法对比,找出差异部分进行更新。

diff算法对比的过程在reconcileChildFibers函数,我们来看一下源码:

function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {
   
    // 处理单个节点
    if (typeof newChild === 'object' && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          return placeSingleChild(
            reconcileSingleElement(
              returnFiber,
              currentFirstChild,
              newChild,
              lanes,
            ),
          );
        case REACT_PORTAL_TYPE:
         ...
        case REACT_LAZY_TYPE:
         ...
      }

      // 处理同一层级多个节点
      if (isArray(newChild)) {
        return reconcileChildrenArray(
          returnFiber,
          currentFirstChild,
          newChild,
          lanes,
        );
      }

     ....
    }

   ...
  }

这边只贴了比较重要的部分,可以看到会根据render函数新生成的React element的类型来判断是单个节点还是多个节点。

单节点

单个节点的diff过程主要是在reconcileSingleElement函数中,我们先来看单个节点是如何做diff的:

function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    element: ReactElement,
    lanes: Lanes,
  ): Fiber {
    const key = element.key; // 获取React element元素上的key属性
    let child = currentFirstChild; // current树上的Fiber节点
    while (child !== null) {
      // current树已经被渲染在屏幕上
      // 通过current树上的Fiber节点的key属性与新生成的React element元素上的key属性对比,
      // 如果不相等,则会把该节点对应的current树上的Fiber对象添加到父Fiber的deletions属性中
      // 并且在flags集合中添加删除标识,然后根据新创建的React element元素创建新的Fiber节点
      // 在commit阶段会根据flags集合中是否添加删除标识,去拿出deletions属性中添加的Fiber对象,
      // 将Fiber对象对应的旧的dom节点包括它下面所有的子节点全部删除,然后将新的节点插入到页面中
      // 如果相等,则会复用之前current树上相对应的fiber,并使用最新的props更新fiber上的pendingProps属性
      // 在commit阶段会更新dom节点
      if (child.key === key) {
        const elementType = element.type;
        if (elementType === REACT_FRAGMENT_TYPE) {
          if (child.tag === Fragment) {
            deleteRemainingChildren(returnFiber, child.sibling);
            const existing = useFiber(child, element.props.children);
            existing.return = returnFiber;
            ...
            return existing;
          }
        } else {
          // key相等,通过current树上的Fiber节点的elementType属性与新生成的React element元素上的type属性对比,判断类型是否相同
          // 如果不相等,则会把该节点对应的current树上的Fiber对象添加到父Fiber的deletions属性中
          // 并且在flags集合中添加删除标识,然后根据新创建的React element元素创建新的Fiber节点
          // 在commit阶段会根据flags集合中是否添加删除标识,去拿出deletions属性中添加的Fiber对象,
          // 将Fiber对象对应的旧的dom节点包括它下面所有的子节点全部删除,然后将新的节点插入到页面中
          // 如果相等,则会复用之前current树上相对应的fiber,并使用最新的props更新fiber上的pendingProps属性
          // 在commit阶段会更新dom节点
          if (
            child.elementType === elementType ||
            (__DEV__
              ? isCompatibleFamilyForHotReloading(child, element)
              : false) ||
            (enableLazyElements &&
              typeof elementType === 'object' &&
              elementType !== null &&
              elementType.$$typeof === REACT_LAZY_TYPE &&
              resolveLazy(elementType) === child.type)
          ) {
            deleteRemainingChildren(returnFiber, child.sibling);
            // 复用之前current树上相对应的fiber,并使用最新的props更新fiber上的pendingProps属性
            const existing = useFiber(child, element.props);
            existing.ref = coerceRef(returnFiber, child, element);
            existing.return = returnFiber;
           
            return existing;
          }
        }
        // Didn't match.
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        deleteChild(returnFiber, child);
      }
      child = child.sibling;
    }

    // 当key或者类型不相等时,会根据新创建的React element元素创建新的Fiber节点
    ...
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }

首先获取新生成的React element元素上的key属性,然后通过current树上的Fiber节点的key属性与React element元素上的key属性对比:

  1. key不相等:则会调用deleteChild函数把Fiber对象添加到父Fiber的deletions属性中,并且在flags集合中添加删除标识,会在commit阶段将添加进deletions集合中的Fiber对应的dom以及他下面所有的子节点都删除。
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
    const deletions = returnFiber.deletions;
    if (deletions === null) {
      returnFiber.deletions = [childToDelete];
      returnFiber.flags |= ChildDeletion;
    } else {
      deletions.push(childToDelete);
    }
  }

然后根据新创建的React element元素创建新的Fiber节点。

  1. key相等:则会通过current树上的Fiber节点的elementType属性与React element元素上的type属性对比,判断类型是否相同类型相同则会调用useFiber函数,重用旧的Fiber节点,并使用React element元素上的props更新fiber上的pendingProps属性,可以理解为重用节点,并且会更新该节点的属性,最后返回该节点添加到workInProgress树上。类型不同,则会调用deleteRemainingChildren函数,把Fiber对象添加到父Fiber的deletions属性中,并且在flags集合中添加删除标识,会在commit阶段将添加进deletions集合中的Fiber对应的dom以及他下面所有的子节点都删除。最后根据新创建的React element元素创建新的Fiber节点返回。

小结

单个节点的对比,这个节点可以是组件,也可以是html标签,它们首先都会进行key属性的对比,一般情况下,不是列表中的元素,我们是不加key的,所以key值都为null。在对比的时候,key值都为null,所以会直接对比类型是否相同。如果相同类型,则会更新重用旧节点,如果类型不相同,则会删除旧节点以及它下面所有的子节点,然后根据React element创建新的节点插入。

多个节点

分析完单个节点,我们再来看多个节点的diff,多个节点的diff过程主要是在reconcileChildrenArray函数中实现的:

function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<*>,
    lanes: Lanes,
  ): Fiber | null {

    let resultingFirstChild: Fiber | null = null; // 列表中使用React element数据更新了属性的第一个Fiber节点
    let previousNewFiber: Fiber | null = null; // 上一个更新了属性的Fiber节点,用以列表中兄弟节点的相互关联

    let oldFiber = currentFirstChild; // current树上的列表中的第一个Fiber节点
    let lastPlacedIndex = 0; // 上一个元素移动位置的下标
    let newIdx = 0; // 遍历React element树的下标
    let nextOldFiber = null; // current树上的列表中元素的兄弟节点

    // 这个for循环的作用是剔除没有变化的节点,并对节点进行更新和重用
    // 当检查到尾部有新增节点时,oldFiber为null,则会跳出循环,然后创建新的Fiber插入到尾部,不需要与其它节点进行对比
    // 当有新节点插在中间插入,newFiber则为null,则会跳出循环,然后需要移动节点位置进行排序
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      // 判断旧Fiber节点上的key与React element上的key属性相等的话,则会使用React element的数据更新Fiber节点上的属性
      // 不相等,则会返回null
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        lanes,
      );

      // 发现有元素的key属性有变化,说明不是更新场景,则会跳出for循环
      if (newFiber === null) {
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }
      if (shouldTrackSideEffects) {
        if (oldFiber && newFiber.alternate === null) {
          deleteChild(returnFiber, oldFiber);
        }
      }
      // 将newIdx赋值给workInProgress树上的Fiber节点的index属性,代表当前元素在列表中的位置(下标)
      // 判断current树上元素的Index是否小于lastPlacedIndex,是则表示该元素需要移动位置,否则表示不需要移动位置
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        // 将更新了属性的兄弟Fiber节点进行关联
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
    }

    // 新的子节点已经遍历完成,如果还有剩下的节点,表示current树上有,但是workInProgress树上没有的节点,需要全部删除
    if (newIdx === newChildren.length) {
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultingFirstChild;
    }

    // 节点新增,不需要与旧节点对比,直接创建新增
    if (oldFiber === null) {
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
        if (newFiber === null) {
          continue;
        }
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          // TODO: Move out of the loop. This only happens for the first run.
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
      return resultingFirstChild;
    }

    // 将current树上的列表中还未对比的元素添加进Map对象中
    // 下面的for循环会根据key取出Map中对应的旧的Fiber与React element做类型的比较
    // 如果类型相同则更新Fiber属性,不同,则会根据React element重新创建一个新的Fiber做插入操作
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    // 节点移动
    for (; newIdx < newChildren.length; newIdx++) {
      // 根据key取出Map中对应的旧的Fiber与React element做类型的比较
      // 如果类型相同则使用React element的数据更新Fiber节点上的属性进行重用,不同,则会根据React element的数据重新创建一个新的Fiber做插入操作
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        lanes,
      );
      if (newFiber !== null) {
        if (shouldTrackSideEffects) {
          // newFiber.alternate不为null,表示是重用的节点,需要将existingChildren中重用的节点删除掉
          // 遍历结束后existingChildren中剩下的节点,则是需要删除的
          if (newFiber.alternate !== null) {
            // 在调用updateFromMap方法时,会根据key取出相对应的Fiber
            // 调用updateFromMap方法完成后,对应key的Fiber值被重用了,所以需要删除Map中使用过的key对应的值
            existingChildren.delete(
              newFiber.key === null ? newIdx : newFiber.key,
            );
          }
        }
        // 将newIdx赋值给workInProgress树上的Fiber节点的index属性,代表当前元素在列表中的位置(下标)
        // 判断current树上元素的Index是否小于lastPlacedIndex,是则表示该元素需要移动位置,否则表示不需要移动位置
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }

    // 节点删除
    if (shouldTrackSideEffects) {
      // existingChildren中剩下的Fiber,表示current树上存在,但是workInProgress树上不存在的元素
      // 将剩下的Fiber添加到父Fiber节点的deletions属性中, 并且在flags集合中添加删除标识,在commit阶段会将这些元素进行删除
      existingChildren.forEach(child => deleteChild(returnFiber, child));
    }

    // 返回列表中的第一个节点
    return resultingFirstChild;
  }

下面,我们通过举例子的方式,来分析多节点的diff算法。

新增子节点

假如我们现在界面已经渲染好了,所对应的current树是这样:

1634282060532.jpg

现在做了一次更新操作,调用render方法生成的React element树是这样:

1634282249722.jpg

在列表的尾部新增了key值为E的元素。

在调用reconcileChildrenArray函数时,所传的参数:

  1. returnFiber:workInProgress树上对应A节点的Fiber对象
  2. currentFirstChild:current树上的列表中的第一个元素B所对应的Fiber对象
  3. newChildren:调用render方法生成的React element树,也就是上一张图中展示的样子。
  4. lanes:需要更新的任务的优先级的集合

reconcileChildrenArray函数中可以看到,首先将列表中的第一个子节点(B)赋值给了oldFiber,初始化了newIdx为0,nextOldFiber为null,将newIdx作为for循环的索引,对newChildren进行遍历。在遍历的过程中,调用了updateSlot函数,这个函数的作用则是判断current树上的Fiber节点与React element上的key属性和类型是否相等,相等的话,则会使用React element的props更新current树上的Fiber节点上的属性,对旧的Fiber进行重用,不相等,则会返回null:

 function updateSlot(
    returnFiber: Fiber,
    oldFiber: Fiber | null,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {

    const key = oldFiber !== null ? oldFiber.key : null;
    
    ...

    if (typeof newChild === 'object' && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE: {
          // 判断key是否相等
          if (newChild.key === key) {
            // 对类型进行对比,类型相同则更新并且重用旧Fiber,不相同则根据React element重新创建一个新的Fiber
            return updateElement(returnFiber, oldFiber, newChild, lanes);
          } else {
            return null;
          }
        }
        case REACT_PORTAL_TYPE: {
         ...
        }
        case REACT_LAZY_TYPE: {
         ...
        }
      }

      ...
  }

经过对比,current树上的B节点与React element树上的B节点key属性和类型都一样,则会返回旧的Fiber节点,然后调用placeChild函数,这个函数的主要作用就是判断当前节点是否需要移动:

function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
    newFiber.index = newIndex;
    ...
    const current = newFiber.alternate;

    // 判断workInProgress树上的Fiber节点在current树上是否有对应的Fiber节点
    // 有的话则会对比新老Fiber的index,来判断是否需要移动
    // 如果current为null,则说明current树上没有对应的Fiber,该Fiber是新增的需要插入
    if (current !== null) {
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // This is a move.
        newFiber.flags |= Placement;
        return lastPlacedIndex;
      } else {
        // This item can stay in place.
        return oldIndex;
      }
    } else {
      // This is an insertion.
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    }
  }

这个函数中通过获取current树上的Fiber节点的index属性,也就是它在列表中的位置,与lastPlacedIndex对比,此时lastPlacedIndex为0,B节点在current树中的位置也是0,所以位置不变,只有当节点在current树中的位置小于lastPlacedIndex时,才会进行向右移动。

B对比完成,则开始对D和C依次进行对比,由于D和C都没有变动,所以过程和B一样。

在D开始对比时,会获取它的兄弟节点赋值给nextOldFiber,由于在current树上,D是最后一个节点所以会将null赋值给nextOldFiber

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      ...
      oldFiber = nextOldFiber;
}

在D完成对比时,又将nextOldFiber赋值给了oldFiber,在对E开始遍历时,oldFibernull,则会终止循环,此时的索引newIdx为D的下标:2

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      ...
}

此时,新生成的Reat元素还没有遍历完,还剩一个E,则会进入节点新增的代码中:

 // 节点新增,不需要与旧节点对比,直接创建新增
if (oldFiber === null) {
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
    if (newFiber === null) {
      continue;
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
  return resultingFirstChild;
}

根据上面我们知道此时的oldFibernull,直接进入for循环,调用createChild函数,这个函数的作用,则是根据React element创建一个新的Fiber,然后调用placeChild函数,由于newFiber是新创建的,所以current为null,需要添加插入标识,进行插入操作:

function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
    ...
    const current = newFiber.alternate;

    // 判断workInProgress树上的Fiber节点在current树上是否有对应的Fiber节点
    // 有的话则会对比新老Fiber的index,来判断是否需要移动
    // 如果current为null,则说明current树上没有对应的Fiber,该Fiber是新增的需要插入
    if (current !== null) {
     ...
    } else {
      // 添加插入标识
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    }
  }

最后返回resultingFirstChild,也就是对比完成后的列表中的第一个子节点,添加到WorkInProgress树中,形成新的WorkInProgress树。

在这个过程中,React通过diff查找,将key与类型相同的节点都进行了更新和重用(B, C D),将不同的节点进行了创建与插入的操作。

删除子节点

这个例子中我们将C进行了删除。

1634287615928.jpg

对比B也是与上面的一样过程,最终结果也是被重用。

当使用D做对比时,调用updateSlot时,传入的参数为:

const newFiber = updateSlot(
    returnFiber, // A
    oldFiber, // C
    newChildren[newIdx], // D
    lanes,
  );

将D与C进行比较,key不同,updateSlot返回null,然后跳出当前for循环,此时oldFiber为C所对应的Fiber节点,newIdx为1,然后会调用mapRemainingChildren函数,将current树上的列表中还未对比完成的节点C,D添加进Map对象中:existingChildren

const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

function mapRemainingChildren(
    returnFiber: Fiber,
    currentFirstChild: Fiber,
  ): Map<string | number, Fiber> {
    const existingChildren: Map<string | number, Fiber> = new Map();

    let existingChild = currentFirstChild;
    while (existingChild !== null) {
      if (existingChild.key !== null) {
        existingChildren.set(existingChild.key, existingChild);
      } else {
        existingChildren.set(existingChild.index, existingChild);
      }
      existingChild = existingChild.sibling;
    }
    return existingChildren;
  }

然后根据newIdx对React element树进行新一轮的遍历:

// 节点移动
for (; newIdx < newChildren.length; newIdx++) {
  // 根据key取出Map中对应的旧的Fiber与React element做类型的比较
  // 如果类型相同则使用React element的数据更新Fiber节点上的属性进行重用,不同,则会根据React element的数据重新创建一个新的Fiber做插入操作
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    lanes,
  );
  if (newFiber !== null) {
    if (shouldTrackSideEffects) {
      // newFiber.alternate不为null,表示是重用的节点,需要将existingChildren中重用的节点删除掉
      // 遍历结束后existingChildren中剩下的节点,则是需要删除的
      if (newFiber.alternate !== null) {
        // 在调用updateFromMap方法时,会根据key取出相对应的Fiber
        // 调用updateFromMap方法完成后,对应key的Fiber值被重用了,所以需要删除Map中使用过的key对应的值
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key,
        );
      }
    }
    // 将newIdx赋值给workInProgress树上的Fiber节点的index属性,代表当前元素在列表中的位置(下标)
    // 判断current树上元素的Index是否小于lastPlacedIndex,是则表示该元素需要移动位置,否则表示不需要移动位置
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

进入for循环,此时的newIdx为1,调用updateFromMap函数:

const newFiber = updateFromMap(
    existingChildren, // Map: {C: FiberC, D: FiberD}
    returnFiber, // A
    newIdx, // 1
    newChildren[newIdx], // React element D
    lanes,
  );
  
   function updateFromMap(
    existingChildren: Map<string | number, Fiber>,
    returnFiber: Fiber,
    newIdx: number,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {
    ...
    if (typeof newChild === 'object' && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE: {
          // 根据key取出Map中对应的旧的Fiber与React element做类型的比较
          // 如果类型相同则使用React element的数据更新Fiber节点上的属性进行重用,不同,则会根据React element的数据重新创建一个新的Fiber做插入操作
          const matchedFiber =
            existingChildren.get(
              newChild.key === null ? newIdx : newChild.key,
            ) || null;
          return updateElement(returnFiber, matchedFiber, newChild, lanes);
        }
        case REACT_PORTAL_TYPE: {
          ...
        case REACT_LAZY_TYPE:
         ...
      }

    ...
  }

这个函数的作用则是根据key取出Map中对应的旧的Fiber与React element做类型的比较,如果类型相同则使用React element的数据更新Fiber节点上的属性进行重用,不同,则会根据React element的数据重新创建一个新的Fiber返回。

由于D在existingChildren中是存在的,并且类型也没有变化,所有会重用旧Fiber。

updateFromMap调用完成,则会判断newFiber是否是重用的,如果是重用的,那么它的alternate属性肯定是不为null的,则会把existingChildren中重用过的Fiber删除。也就是会把D从existingChildren中删除。

然后调用placeChild:

lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

newFiber则是D对应current树上的Fiber对象,lastPlacedIndex则是B的位置:0,newIdx为:1:

function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
    // 将当前位置交给newFiber占据
    newFiber.index = newIndex;
   
    const current = newFiber.alternate;

    // 判断workInProgress树上的Fiber节点在current树上是否有对应的Fiber节点
    // 有的话则会对比新老Fiber的index,来判断是否需要移动
    // 如果current为null,则说明current树上没有对应的Fiber,该Fiber是新增的需要插入
    if (current !== null) {
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // This is a move.
        newFiber.flags |= Placement;
        return lastPlacedIndex;
      } else {
        // This item can stay in place.
        return oldIndex;
      }
    } else {
     ...
    }
  }

由于D是重用的,所以current不为null,然后取出D在curren树上的位置:2,赋值给oldIndex,上面提到lastPlacedIndex是B的位置:0,oldIndex大于lastPlacedIndex,不需要移动。

placeChild调用完成后,此时,新的子节点已经遍历完成。

existingChildren中还剩下一个C,到最后existingChildren中剩下的Fiber,表示current树上存在,但是workInProgress树上不存在的节点,需要进行删除,会调用deleteChild函数将剩下的Fiber添加到父Fiber节点的deletions属性中, 并且在flags集合中添加删除标识,在commit阶段会将这些节点进行删除。

移动子节点

在这个例子中,我们将C移动到了D的后面。

1634290894445.jpg

首先对比两棵树上的B节点,key和类型都是一样的,对B进行重用。

然后拿D与C进行对比,发现key不同,跳出第一个循环,将未完成对比的节点添加到existingChildren中:C,D。

接着进入第二个循环,调用updateFromMap,根据React element树上D的key值从existingChildren中取出old fiber,然后再将old fiber D的类型与React element中D的类型进行对比,发现是相同的,对old fiber D进行重用,重用完成后从existingChildren中删除D。

然后调用placeChild方法,进行位置移动:

 lastPlacedIndex = placeChild(
     newFiber, // D节点被重用,为old fiber
     lastPlacedIndex, // 0
     newIdx // 1
 );
 
 function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
   newFiber.index = newIndex;
    ...
    const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // This is a move.
        newFiber.flags |= Placement;
        return lastPlacedIndex;
      } else {
        // This item can stay in place.
        return oldIndex;
      }
    ...
  }

old fiber D的index为current树上的位置为2,lastPlacedIndex为0,oldIndex大于lastPlacedIndex,不进行移动。

拿C与D对比,调用updateFromMap,根据React element树上D的key值从existingChildren中取出old fiber,然后再将old fiber C的类型与React element中D的类型进行对比,发现是相同的,对old fiber C进行重用,重用完成后从existingChildren中删除C。

调用placeChild,old fiber C的index为current树上的位置为1,lastPlacedIndex为2,oldIndex小于lastPlacedIndex,向右移动。

为什么是向右移动?可以看到placeChild中的第一句代码:

function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
   newFiber.index = newIndex;
   ...
  }

将当前newIndex赋值给了fiber Cindex属性,newIndex此时为2,current树上fiber Cindex为1,将fiber C向右移动一位index1index则变为了2。

从上面我们看到,移动节点的关键在于placeChild函数中的这段代码:

const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
    newFiber.flags |= Placement;
    return lastPlacedIndex;
} else {
    return oldIndex;
}

如果我们把D节点移动到第一位呢?

第一次遍历对比,newIndex0Dcurrent树上的位置为2,lastPlacedIndex0,由于oldIndex大于lastPlacedIndex,D节点不会进行移动,index被赋值为0,返回oldIndex赋值给lastPlacedIndex2

第二次遍历对比,newIndex1Bcurrent树上的位置为0,lastPlacedIndex2,由于oldIndex小于lastPlacedIndex,B节点向右移动,index被赋值为1,返回lastPlacedIndex2

第三次遍历对比,newIndex2Ccurrent树上的位置为1,lastPlacedIndex2,由于oldIndex小于lastPlacedIndex,C节点向右移动,index被赋值为2,返回lastPlacedIndex2

可以看到,将D移动到第一位,B,C都会向右移动

1634525694215.jpg

一旦节点过多,这样的操作会引起较大的性能开销,所以我们尽可能的避免将尾部的节点向前移动。

总结

  • React diff通过三个策略将传统的diff算法的复杂度从O(n^3)降至O(n)。
    • tree diff: 在对比两颗树时,只会对比同一层的节点,会忽略跨层级的操作。
    • component diff:在对比两个组件时,只对类型进行比较,如果类型不一样,则不会再进一步的比较,会对老的组件及其子节点全部进行销毁,将新的组件创建并插入。
    • element diff:对于同一层的一组节点,会使用具有唯一性的key进行区分。
  • 不管是单个节点还是多个节点,都会先进行key值的比较,然后再进行类型的比较,以此判断是否需要对该节点进行重用,还是创建。
  • 关于key属性的作用,我们可以看到每次遍历都会先进行key值的对比,通过key可以快速的找到变化的节点,并针对这些节点进行操作。
  • 在开发中,我们尽量不要将尾部的节点向前移动,能够减小性能的开销。