react diff算法完全解读

1,349 阅读5分钟

react diff算法完全解读

前言

diff算法在前端面试中也算是一个高频考题了,那怎么给面试官一个满分解答呢?难道还是简单的说个“深度优先,同层级比较”吗?这太短小精悍了......!

好了,下面开始进入正题

单节点diff

单节点diff就比较简单了,从同层级的老fiber节点中找出key值和type都相等的老节点,如果该老fiber节点存在,则复用他,然后删除剩余的节点,否则重新生成一个新的fiber节点(这也就意味着以这个节点为根的子树需要重新生成)。
下面我们来看看单节点diff的核心源码

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  // 遍历同层级的老fiber节点
  while (child !== null) {
    if (child.key === key) {
      const elementType = element.type;
      // 从老fiber节点中找出key和type都相同的节点,如果找到则将该节点复用,并删除多余的节点,退出循环
      if (child.elementType === elementType) {
        deleteRemainingChildren(returnFiber, child.sibling);
        const existing = useFiber(child, element.props);
        existing.ref = coerceRef(returnFiber, child, element);
        existing.return = returnFiber;
        return existing;
      }
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 如果该fiber节点没匹配上,则删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 能走到这里就意味着无法从老fiber中匹配到key和type都相同的节点,无法复用,需要重新生成
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.return = returnFiber;
  return created;
}

多节点diff

重点来了,注意了啊,多节点才是精髓

多节点diff主要可以分为两轮循环,第一轮循环主要是新旧节点同位置对比,找到第一个无法复用的节点位置后,以最后一个可复用旧节点的位置作为后续作为判断节点是否需要重新插入的基准位置值(该值后续可能会变),然后跳出循环。

如果经历了第一轮循环后,会存在三种情况:

  1. 新节点已经遍历完成:删除剩余的老节点,结束多节点diff
  2. 老节点遍历完成,新节点还为遍历完,将剩余的新节点逐一创建fiber节点,并标记为重新插入,然后结束diff
  3. 老节点、新节点都没遍历完,这种情况就比较复杂,需要将剩余的老节点放入一个map中,然后开启第二轮循环;

第二轮循环:

本轮循环是遍历剩余的新节点,遍历时,新节点都从map中寻找有没有自己能复用的老节点(key和type相同即可复用),如果map中存在就复用,然后将该老节点从map中移除,否则就重新生成。如果老节点被复用了,就会将该老节点原来所在的位置和第一轮循环确定的基准位置值比较,老节点的位置在基准位置值的左边时,说明复用该老节点的新节点需要重新插入,基准位置值不变;老节点的位置在基准位置值的右边时,说明复用该老节点的新节点无须移动,但是基准位置值需要更新为老节点的位置。

第二轮循环结束了,只需要将map中剩余的老节点标记为删除即可。


举个例子:(第三种情况)

目前页面上有3个li,内容和key分别为1、2、3(fiber树如下图所示) ReactChildren (1).png

现在我要让页面变成3个li,内容和key分别是1、3、2,其所对应的ReactElement结构为

[
    {$$typeof: REACT_ELEMENT_TYPE, type: 'li', props: {children: 1}, key: 1, ...},
    {$$typeof: REACT_ELEMENT_TYPE, type: 'li', props: {children: 3}, key: 3, ...},
    {$$typeof: REACT_ELEMENT_TYPE, type: 'li', props: {children: 2}, key: 2, ...},
]

那么我们需要怎么做呢?

注意:diff比较是老fibernewChild(在这我就先把它叫新节点吧)的比较

  • 首先定义一个lastPlacedIndex用来作为基准位置来判断旧节点是否需要移动,初始为0,newIndex代码新节点的索引 幻灯片1.png

  • 此时第一轮循环开始了,oldFiber跟newChild进行比较,发现它们的key和type都相等,该节点可复用,而且oldFiber的位置并不在lastPlaceIndex的左边,无需重新插入,继续往下遍历 幻灯片2.png

  • 这个时候发现oldFiber的key值和newChild的key不想等,这意味着无法复用,第一轮循环结束。老节点和新节点都未遍历完成,需要开启第二轮循环了。 幻灯片3.png

  • 将剩余的老节点存入一个map中,如果老节点中存在key值,则将该key值作为map中的key,没有就以老节点所在的位置作为map中的key,该节点作为map中的值 幻灯片4.png

  • 第二轮循环开始,新节点存在key值,为3,从map中寻在以key为3的值,发现map中存在该值,且该老节点的key和type都newChild相同,可以复用,然后将该老节点的位置(2)与基准位置值(0)对比,发现该老节点位置在基准位置的右边,复用该老节点的新节点无须移动,基准位置值更新为2,最后从map中删除key为3的值 幻灯片5.png

  • 新节点存在key值,为3,从map中寻在以key为2的值,发现map中存在该值,且该老节点的key和type都newChild相同,可以复用,然后将该老节点的位置(1)与基准位置值(2)对比,发现该老节点位置在基准位置的左边,复用该老节点的新节点需要重新插入,标记为Placement,基准位置值不变,最后从map中删除key为2的值 幻灯片6.png

  • 此时新节点也已经遍历完成了,第二轮循环结束,将map中剩余的老节点标记为删除 幻灯片7.png


下面来看下react diff代码片段的实现
function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes
): Fiber | null {

  let resultingFirstChild: Fiber | null = null;
  let previousNewFiber: Fiber | null = null;

  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  let nextOldFiber = null;
  // 第一轮遍历
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    // ...
    nextOldFiber = oldFiber.sibling;
    // 如果type和key相同则复用该fiber节点返回一个newFiber,否则返回null,然后跳出第一轮循环
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes
    );
    if (newFiber === null) {
      // ...
      break;
    }
    if (oldFiber && newFiber.alternate === null) {
      // 兜底操作,如果该newFiber不是复用来的,就将oldFiber标记为删除
      deleteChild(returnFiber, oldFiber);
    }
    // 标记该newFiber是否需要重新插入
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }

  // 第一轮循环结束,newChildren遍历完成,删除多余的oldFiber
  if (newIdx === newChildren.length) {
    // We've reached the end of the new children. We can delete the rest.
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  if (oldFiber === null) {
    // 老fiber被遍历完了,但是newChildren还未遍历完,则需要生成fiber并标记为需要插入
    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;
  }

  // 将剩余的oldFiber存入map中,key=oldFiber.key||oldFiber.index
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

  // 遍历剩余的newChildren
  for (; newIdx < newChildren.length; newIdx++) {
    // 从map中查询是否有oldFiber可以复用,根据newChild.key||newIndex来查询,有则复用,没有则重新生成
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes
    );
    if (newFiber !== null) {
      // 走到这里说明newFiber是生成而来的
      if (newFiber.alternate !== null) {
        // 从map中移除已经被复用的oldFiber
        existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
      }
      // 判断该节点是否需要移动
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  // 删除map中剩余的oldFiber
  existingChildren.forEach((child) => deleteChild(returnFiber, child));

  return resultingFirstChild;
}

留个思考题?如果diff过程中,oldFibers中有部分节点的key值相同,会造成什么问题呢?