react之Reconciliation

March 24, 2021

React 的特点在于其异步渲染,fiber机制,是其他类 react 框架无法比拟的。前面介绍了些很基本的异步渲染。接下来介绍一下传说中的 diff 算法吧。其实这个在 React 官方文档 Reconciliation 里面早有介绍(advanced guide 里面的内容很多初级 React 工程师应该都没有看过,然而 advanced guide 里面包含了 context、错误边界、HOC、render props 以及 Reconciliation,没有看过的还请多刷几遍)。其中两大基准假设如下

  1. Two elements of different types will produce different trees.
  2. The developer can hint at which child elements may be stable across different renders with a key prop.

这两个准则很好的优化树的对比算法,遇到不同类型的元素自然是会生成不一样的树,而通过 key 这个 react 特性的属性可以来判断新旧元素是否是相似的,来减少优化步骤。

reconcileChildFibers

diff 的过程其实就是 reconciliation 的过程,在生成 workInProgress tree 的时候,新 fiber 的生成就取决于老 fiber 与相关 props。

元素的更新都需要有对应的子 fiber。下面以 fiber.tag 为 HostComponent 来举例子,HostComponent 指的就是一般的 'div' 'span' 等等常见的 HTML 标签。生成/更新子 fiber 的方式如下:

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  expirationTime: ExpirationTime,
): Fiber | null {
  const isUnkeyedTopLevelFragment =
    typeof newChild === 'object' &&
    newChild !== null &&
    newChild.type === REACT_FRAGMENT_TYPE &&
    newChild.key === null;
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
  }
  // 新child是否为非空对象,
  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // 对于非空对象的情况
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 普通的 div/span 等元素其 $$typeof 都是 REACT_ELEMENT_TYPE
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            expirationTime,
          ),
        );
      case REACT_PORTAL_TYPE:
        // react 新API portal 组件
        return placeSingleChild(
          reconcileSinglePortal(
            returnFiber,
            currentFirstChild,
            newChild,
            expirationTime,
          ),
        );
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 如果child是文本或者数字,就直接替换就好了,fiber都不用生成
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        expirationTime,
      ),
    );
  }

  if (isArray(newChild)) {
    // 对于数组的情况, react 新支持的 <></> 等方式,元素包含多子元素也是数组
    return reconcileChildrenArray(
      returnFiber,
      currentFirstChild,
      newChild,
      expirationTime,
    );
  }

  if (getIteratorFn(newChild)) {
    // 需要迭代,这里先不管。
    return reconcileChildrenIterator(
      returnFiber,
      currentFirstChild,
      newChild,
      expirationTime,
    );
  }

  if (isObject) {
    throwOnInvalidObjectType(returnFiber, newChild);
  }
  // 省略部分抛错提示处理
  // 剩下的都当作空处理,也就是 delete 掉
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

分类处理 newChild,并生成不同的 fiber。child 包括以前不兼容的 array 的形式。对于 HostComponent 而言,其 newChild 是 jsx 解析后的对象,type 为盒子类型,props 为其属性,包括 children,子元素。前面只是分类处理,而 reconcileSingleElement 才是生成 diff 的关键。

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  expirationTime: ExpirationTime,
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  while (child !== null) {
    if (child.key === key) {
      // newChild 的 key 是否和 currrent 的 child.key 一样?
      if (
        child.tag === Fragment
          ? element.type === REACT_FRAGMENT_TYPE
          : child.type === element.type
      ) {
        // key 一样外,type 必须也是一样的哦。
        deleteRemainingChildren(returnFiber, child.sibling);
        const existing = useFiber(
          child,
          element.type === REACT_FRAGMENT_TYPE
            ? element.props.children
            : element.props,
          expirationTime,
        );
        existing.ref = coerceRef(returnFiber, child, element);
        existing.return = returnFiber;
        return existing;
      } else {
        deleteRemainingChildren(returnFiber, child);
        break;
      }
    } else {
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
  const created = createFiberFromElement(
    element,
    returnFiber.mode,
    expirationTime,
  );
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

从 current 里面找到对应的 key,也就是老元素的 key,如果新老元素的 key,以及 type 是一样的,则认为是同一个元素发生更新,这个时候会直接创建和之前一样的 fiber,包括 stateNode 实例也是一样的,其 alternate 则为 current。只是会传入新的 props,给到生成的 fiber。对比先后生成情况可以发现:

  1. 处理 newChild 的时候,可以直接复用之前 current.alternate,修改其 pendingProps 以及 effect list 就可以了,而不是新建一个 fiber。同时可以直接复用 stateNode,只要后面再做更新处理就好,远比新生成 stateNode,开销要小。
  2. 对于更新的当前 fiber,由于存在 alternate。所以沿着 workInProgress tree 的子级时候,就可能存在 current.child 和新生成 fiber 的 newChild 做对比,又是一个 diff 的过程了。反之,如果一开始不配,直接生成新的 fiber,则下轮的时候,没有对应的 current,又是生成新的 fiber,其开销要比 diff 多很多。
  3. 节省内存不会反复创建 fiber。

可以看到这里符合上面的两个基本点,相同类型的 type 才复用 fiber,否则,删除掉,并重新创建 fiber。而对于单元素而言,相同 type 还是不够的,必须相同 key,值得一提的是,没有设置 key 的时候,key 为 null,所以还是相同 key 值。但是对于复杂情况,如多元素 key 的判断,则要依赖于数组,也就是 reconcileChildrenArray 方法。

reconcileChildrenArray

对于有 key 的情况,按照官网介绍的 reconciliation keys。通过设置 key 可以有效的保留之前元素,而不是每次都去对比考虑新建/修改一个元素。但是 key 的分布可以是散列,没有规律的。react 又要如何识别呢?通过建立一个 map 的结构就可以快速识别。

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;
}

这里将以前的 old fiber 通过建立 Map 的方式,搭建一个字典对象,当新元素需要对比的时候,先瞅一瞅 existingChildren 里面有没有对应的 key,当然 type 也必须要是一样的。另外除了 key 之外,children 里面的排序也很重要。当没有 key 的时候,existingChildren 的键名将会是 index。在处理 key 的时候也是要小心不和 index 混淆了。创建 fiber 的时候,每个 fiber 都有 index 字段。数组的 children 转为 fiber 的时候,元素在 children 里面的顺序就是其 index。所以对于数组的情况,如果没有设置 key,通过 index 字段,react 也可以识别是不是同一个 fiber 的更新,但是 index 是有序的,这也照就很多不变,插入删除一个都会导致后面的全部更新。key 的设置还是很重要的。

react 里面对于数组元素,例如通过 map 遍历返回的,都是要有 key 的标识,这样可以提高下次渲染的重用,提高其性能。

在 preact 里面也是类似的。将所有 old 元素循环下来,通过 keyed 保存对应的 key 和 child,在对新的元素做 key 判断,从而找到对应的元素。

completeWork

这里会对上面 fiber 的 stateNode 更新,和之前。react 源码下一步 里面的 diff 属性就有介绍到。前文是 diff fiber,这里是 diff stateNode。

到最后就是 commitRoot 里的三大循环了。

effectList

在 fiber 的 completeUnitOfWork 里面会收集所有的有 side-effect 的 fiber,将其通过 nextEffect 字段联系起来,成为一个单向的链表。其顺序是先子节点后父节点,只有有 side-effect 的节点才会被添加进来。也就是是说如果父节点 fiber 没有 side-effect,就是没有变动的话,是不会出现在 effectList 链表里面。

A是祖节点,B/C 为父节点,D/E/F 为子节点,蓝色线为父子关系,绿色线为 effectList 关系。当 D/E/F 有变化,而 B/C 没有变化的时候,形成的 effectList 如左图所示,这里 A.firstEffect 为 F,A.lastEffect 为 D,D/E/F 形成 nextEffct 关系。于是 commit 阶段的时候,只是对 D/E/F 做处理,而 B/C 是没有的。也是优化更新,实现局部更新的方式。

对于右图,则是当 B 也发生变化的时候,这个时候 A.lastEffect 为 B,D.nextEffect 为 B。父节点是在子节点之后出现的。通过这样的 effectList 的构建,形成单向的链表,而不是多层次的递归。

总结

React 的 diff 算法里面体现更多的是复用的概念,往往更新只是一小部分的,但是 react 的循环却是从头到尾一直进行的。通过两棵新老 fiber tree 的对比,可以有效避免创建多余的 fiber,提高其性能。对于日常开发的我们更重要的是牢记开头说的两点

  1. Two elements of different types will produce different trees.
  2. The developer can hint at which child elements may be stable across different renders with a key prop.