React 源码阅读 - Diff 算法

395 阅读6分钟

Diff 算法 diff 的是什么

一个 DOM 节点,在某一时刻最多会有四个节点与其相关:

  1. DOM 节点本身。
  2. current Fiber。如果该 DOM 节点已经在页面中渲染好了,那么 current Fiber 代表该 DOM 节点对应的 Fiber 节点。
  3. workInProgress Fiber。如果该 DOM 节点将在本次更新中渲染到页面中,workInProgress Fiber 代表该 DOM 节点对应的 Fiber 节点。
  4. jsx 对象。也就是 class 组件的 render 方法或者 function 组件返回的结果,源码里面其类型为 ReactElement

Diff 算法比较的是 2 和 4,生成 3。

React Diff 算法与普通 Diff 算法的区别

普通的 Diff 算法是将两棵树进行完全对比,即使在比较前沿的算法中其算法时间复杂度也有 O(n³),n 是树中的元素个数。如果用这个算法,那么 1000 个元素所需要的执行的计算量就是 1000³,也就是十亿的量级。这个计算量是非常恐怖的。

为了降低算法复杂度,Reactdiff 会预设三个限制:

  1. 只对同级元素进行 Diff。如果一个 DOM 节点在前后两次更新中跨越了曾经,那么 React 不会尝试复用他。
  2. 两个不同类型的元素会产生出不同的树。如果元素由 div 变成 pReact 会销毁 div 及其后代节点,并新建 p 及其后代节点。
  3. 开发者可以通过 key 这个属性来暗示哪些子元素在不同的渲染下能保持稳定。

对于第三点解释一下:

// 更新前
<div>
  <p key="key1">p</p>
  <h3 key="key2">h3</h3>
</div>

// 更新后
<div>
  <h3 key="key2">h3</h3>
  <p key="key1">p</p>
</div>

如果不加 key 那么根据第二条 ph3,那么这个节点将被销毁,并新建。

但是现在加了 key,而且更新之后还存在,所以这个节点可以复用,只是需要交换下顺序。

经过这一系列处理之后的 Diff 算法时间复杂度可以提升到 O(n)。

源码

我们之前介绍协调的时候就说过,diff 算法就是在协调阶段使用的,beginWork 的时候会调用 reconcileChildFibers

// path: packages/react-reconciler/src/ReactChildFiber.new.js
// 根据 newChild 类型选择不同的 diff 函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  lanes: Lanes,
): Fiber | null {
  const isUnkeyedTopLevelFragment =
    typeof newChild === 'object' &&
    newChild !== null &&
    newChild.type === REACT_FRAGMENT_TYPE &&
    newChild.key === null;
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
  }

  // 处理 object 类型
  if (typeof newChild === 'object' && newChild !== null) {
    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;
          return reconcileChildFibers(
            returnFiber,
            currentFirstChild,
            init(payload),
            lanes,
          );
        }
    }

    // 数组类型
    if (isArray(newChild)) {
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }

    if (getIteratorFn(newChild)) {
      return reconcileChildrenIterator(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }

    throwOnInvalidObjectType(returnFiber, newChild);
  }

  // 处理字符串或者数字类型
  if (typeof newChild === 'string' || typeof newChild === 'number') {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        lanes,
      ),
    );
  }

  // 省略一些代码

  // 以上情况都没命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

这个函数的作用总结起来:

  1. newChildobject 类型要区分是不是 Array 类型,Array 类型代表着同级含有多个节点,其它的则代表同级只有一个节点。
  2. newChild 为字符串或者数字类型的时候,代表同级只有一个节点。

同级一个节点和多个节点的处理方式有一些区别。这也是 Diff 算法重点处理的问题。

新的节点为单节点的 Diff

objectreconcileSingleElement 为例:

// path: packages/react-reconciler/src/ReactChildFiber.new.js
function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  // 判断是否存在对应 DOM 节点
  while (child !== null) {
    // 上一次更新时存在 DOM 节点
    // 比较 key 是否相同
    if (child.key === key) {
      // key 相同再比较 type 是否相同,相同则返回可复用的 fiber
      const elementType = element.type;
      if (elementType === REACT_FRAGMENT_TYPE) {
        if (child.tag === Fragment) {
          // 将该 fiber 的兄弟 fiber 标记为删除
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props.children);
          existing.return = returnFiber;
          // 省略一些代码
          return existing;
        }
      } else {
        if (
          child.elementType === elementType ||
          (__DEV__
            ? isCompatibleFamilyForHotReloading(child, element)
            : false) ||
          (enableLazyElements &&
            typeof elementType === 'object' &&
            elementType !== null &&
            elementType.$$typeof === REACT_LAZY_TYPE &&
            resolveLazy(elementType) === child.type)
        ) {
          // 将该 fiber 的兄弟 fiber 标记为删除
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props);
          existing.ref = coerceRef(returnFiber, child, element);
          existing.return = returnFiber;
          // 省略一些代码
          return existing;
        }
      }
      // 代码执行到这里代表:key 相同但是 type 不同
      // 将该 fiber 及其兄弟 fiber 标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key 不同,将该 fiber 标记为删除
      deleteChild(returnFiber, child);
    }
    // 切换上一次的节点为兄弟节点
    child = child.sibling;
  }

  // 不存在对应 DOM 节点,创建新的 fiber 节点并返回
  if (element.type === REACT_FRAGMENT_TYPE) {
    const created = createFiberFromFragment(
      element.props.children,
      returnFiber.mode,
      lanes,
      element.key,
    );
    created.return = returnFiber;
    return created;
  } else {
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }
}

实例:

// 待更新
<ul>
  <li key="0"></li>
  <li key="1"></li>
  <li key="2"></li>
</ul>

// 更新后
<ul>
  <p key="1"></p>
</ul>

更新后只有一个 p 姐弟啊,属于单节点 Diff,会走到上面代码逻辑。

reconcileSingleElement 函数中会遍历之前的三个 li 对应的 fiber

key 不相同的时候会删除该 fiber,其还未遍历到的兄弟 fiber 待处理。

key 相同的时候会继续判断 type 是否相同,这里当遍历到 key="1" 的节点时就会触发这个逻辑,此时 type 不相同,会将该节点及其兄弟节点全部删除,这样兄弟节点就算没遍历到也会被删掉。

新的节点为多节点的 Diff

多节点的 Diff 会走到 if (isArray(newChild)) { ... } 这段逻辑。

// path: packages/react-reconciler/src/ReactChildFiber.new.js
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++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber === null) {
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        deleteChild(returnFiber, oldFiber);
      }
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }

  // 第二轮遍历:newChildren 遍历完,oldFiber 没遍历完
  if (newIdx === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  // 第二轮遍历:newChildren 没遍历完,oldFiber 遍历完
  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;
  }

  // 第二轮遍历:newChildren 和 oldFiber 都没遍历完
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key,
          );
        }
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  if (shouldTrackSideEffects) {
    existingChildren.forEach(child => deleteChild(returnFiber, child));
  }

  return resultingFirstChild;
}

Diff 主要就是为了找出新旧节点的不同之处,所以对于节点来说,无非就是三种操作:新增、删除、更新。而更新又是这三种操作中发生频率最高的。所以 ReactDiff 算法中将遍历分成了两轮,第一轮专门用来处理更新,第二轮用来处理非更新的操作。

第一轮遍历

  1. 首先遍历 newChildren,比较 newChildren 的第一个子与 oldFiber,通过 keytype(updateSlot 函数中完成) 判断是否可以复用。
  2. 如果可以复用那么 newIdx++,继续比较 newChildren 的下一个子与 oldFiber.sibling,可以复用则继续遍历。
  3. 如果不可以复用,分为两种情况:① key 不同,执行 break 立即跳出 for 循环,第一轮遍历结束;② key 相同 type不同,执行 deleteChild,将 oldFiber 标记为 DELETION,并继续遍历。
  4. 如果 newChildren 遍历完,或者 oldFiber 遍历完跳出循环,结束第一轮遍历。

如果步骤3跳出的遍历,此时 newChildren 没有遍历完,oldFiber 也没有遍历完。

如果步骤4跳出的遍历,可能 newChildrenoldFiber 之一遍历完,也可能都遍历完了。

/*** 步骤3跳出 ***/
// 更新之前
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>            
// 更新之后
<li key="0">0</li>
<li key="2">1</li>
<li key="1">2</li>

/*** 步骤4跳出 ***/
// 更新之前
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
            
// 更新之后 情况1 —— newChildren 与 oldFiber 都遍历完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
            
// 更新之后 情况2 —— newChildren 没遍历完,oldFiber 遍历完
// newChildren 剩下 key==="2" 未遍历
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
<li key="2" className="cc">2</li>
            
// 更新之后 情况3 —— newChildren 遍历完,oldFiber 没遍历完
// oldFiber 剩下 key==="1" 未遍历
<li key="0" className="aa">0</li>

第一轮遍历结束之后,开始第二轮遍历。

第二轮遍历

第二轮遍历需要根据第一轮遍历的结果分为四种情况。

newChildren 与 oldFiber 同时遍历完

第一轮遍历就完成了组件的更新(更新由 updateSlot 函数完成),Diff 结束。

newChildren 没遍历完,oldFiber 遍历完

已有的 DOM 节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的 newChildren 为生成的 workInProgress fiber 依次标记 PlacementplaceChild 函数完成)。

newChildren 遍历完,oldFiber 没遍历完

意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的 oldFiber,依次标记 Deletion

newChildren 与 oldFiber 都没遍历完

这意味着有节点在这次更新中改变了位置。这是 Diff 算法 最精髓也是最难懂的部分。

步骤:

  1. mapRemainingChildren 函数将为 oldFiber 生成一个 MapexistingChildren,使用节点的 key 属性作为 MapkeyMapvalueoldFiber
  2. 遍历剩余的 oldFiber,通过 existingChildren 中的 key 找到 key 相同的 oldFiber (由 updateFromMap 函数完成)。
  3. lastPlacedIndex 为最后一个可复用节点在 oldFiber 的位置,用 oldIndex 表示 oldFiberindex 属性,也就是位置索引,比较 lastPlacedIndexoldIndex 的大小,oldIndex >= lastPlacedIndex 则该节点不需要移动,并将oldIndex 赋值给 lastPlacedIndexoldIndex > lastPlacedIndex 则该节点需要移动。

总结起来,我们应尽量减少将节点从后面移动到前面的操作,这样的操作对性能不友好。

比如:abcd -> acdb 是将 b 直接移动到了后面;abcd -> dabc 则是将 abc 依次移动到后面。