React 之 diff 算法

35 阅读13分钟

在 render 阶段的 beginWork 方法中,通过调用

  • instance.render() - ClassComponent
  • Component(props, secondArg) - FunctionComponent

方法拿到最新的 JSX 数据。根据最新的 JSX 类型是 单节点类型还是多节点类型进行进行不同的 diff 处理。

生成 JSX 进行 diff 比较

对应的源码如下所示:

// ReactFiberBeginWork.js
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...
  switch (workInProgress.tag) {
    // ...
    case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case ClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    // ...
  }
}

不论节点类型是 FunctionComponent 还是 ClassComponent 或者是 HostComponent 等等,最后都会执行 reconcileChildFibers 方法来对新生成的 JSX 进行 diff 处理。

// ReactChildFiber.js

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

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

    return deleteRemainingChildren(returnFiber, currentFirstChild);
  }

注意:类型是单节点还是多节点,是根据最新生成的 JSX 节点类型来判断的。

算法优化

我们知道,要完全对比两棵树,最小的算法复杂度是 O(n^3)。这意味着什么?

假如我们一棵树有 1000 个节点,那么我们要进行的比较的就会进行 1000000000 次。对于复杂页面来说,超过 1000 个节点非常容易。要进行如此庞大的计算肯定是无法接受的。

所以 React 使用 diff 算法对其进行了优化,将复杂度从 O(n^3) 降低到了 O(n)。刚才也说了,最小的复杂度是 O(n^3),如果要完全对比两棵树,是不可能将它优化到 O(n) 的。为了达到这样的优化,React 在以下方面做了一些限制:

  • 限制一:只对同级元素进行 diff。如果一个 DOM 元素在前后两次更新中跨越了层级,那么 React 不会尝试复用它。
  • 限制二:两个不同类型的元素会产生不同的树。如果元素由 DIV 变成 PReact 会销毁 DIV 及其子孙元素,并新建 P 及其子孙元素。
  • 限制三:开发者可以通过 key 来暗示哪些子元素在不同的渲染下能够保持稳定。

O(n^3) 是怎么计算出来的可以参考以下链接: github.com/Advanced-Fr…

单节点 diff

生成的最新的 JSX 如果是单节点,就会进入 reconcileSingleElement 方法中。这个方法有以下几个判断:

  • key 相同,type 相同。复用当前节点,并 标记删除 当前节点的兄弟节点。
  • key 相同,type 不同。删除当前节点及当前节点的兄弟节点。
  • key 不同。删除当前节点,继续对比最新的 JSX 的节点和当前节点的兄弟节点。

单节点 diff 流程图

对应的源码如下所示:

// 单节点 diff
function reconcileSingleElement(
    returnFiber: Fiber, // workInProgress
    currentFirstChild: Fiber | null, // current.child
    element: ReactElement,  // 使用最新数据生成的 React element 元素
    lanes: Lanes,
  ): Fiber {
    // 获取 React element 元素上的 key 属性
    const key = element.key;
    // current 树上的 Fiber 节点
    let child = currentFirstChild;
    while (child !== null) {
      // key 相同时
      if (child.key === key) {
        const elementType = element.type;
        if (elementType === REACT_FRAGMENT_TYPE) {
          // 如果当前的类型为 Fragment,删除这个节点的兄弟节点
          if (child.tag === Fragment) {
            // 前后都是 Fragment 类型,在父节点的 deletions 上添加标记当前节点的兄弟节点为删除
            deleteRemainingChildren(returnFiber, child.sibling);
            // 节点复用
            const existing = useFiber(child, element.props.children);
            existing.return = returnFiber;
            return existing;
          }
        } else {
          if (
            child.elementType === elementType ||
            // Keep this check inline so it only runs on the false path:
            (__DEV__
              ? isCompatibleFamilyForHotReloading(child, element)
              : false) ||
            (typeof elementType === 'object' &&
              elementType !== null &&
              elementType.$$typeof === REACT_LAZY_TYPE &&
              resolveLazy(elementType) === child.type)
          ) {
           // 在父节点的 deletions 上添加标记当前节点的兄弟节点为删除
            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;
          }
        }
        // key 相同,类型不同,删除当前节点
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        // key 不同,直接删除节点
        deleteChild(returnFiber, child);
      }
      child = child.sibling;
    }

    // currentFiber 没有内容时,走新建流程
    if (element.type === REACT_FRAGMENT_TYPE) {
      const created = createFiberFromFragment(
        element.props.children,
        returnFiber.mode,
        lanes,
        element.key,
      );
      created.return = returnFiber;
      return created;
    } else {
      // 当 key 或者类型不相等时,会根据新创建的 React element 元素创建新的 Fiber 节点
      const created = createFiberFromElement(element, returnFiber.mode, lanes);
      created.ref = coerceRef(returnFiber, currentFirstChild, element);
      created.return = returnFiber;
      return created;
    }
  }

注意:这里的删除指的是在父节点的 deletions 属性中添加当前要删除节点,起标记作用,并没有在这里做任何的删除操作。

多节点 diff

多节点类型的 diff 要比单节点 diff 复杂些。

在了解多节点 diff 前,首先思考一个问题,什么情况下会产生节点为 Array 的数据?

我们要明白一个本质,那就是 diff 其实是 diff 当前 workInProgress 的 child 节点。搞明白了这一点,想知道什么情况下会产生类型为 Array 的就容易了。

  • 当前节点下有多个子节点的情况下。在当前节点进行 beginWork 时,进行到 reconcileChildFibers 方法时会进行多节点 diff
  • 当前节点类型为 Fragment 并且 Fragment 下面有多个子节点时。进行到 reconcileChildFibers 方法时会进行多节点 diff

在 reconcileChildFibers 的开始,有这样一个判断和赋值:

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) {
      // 将 Fragment 下面的 children 赋值给 newChild 变量
      newChild = newChild.props.children;
    }
    // ...
  }

在 reconcileChildFibers 方法中,首先会判断当前节点是不是 Fragment 并且 key 值为 null,如果满足条件,newChild 就赋值为 newChild.props.children,而 newChild.props.children 就是 Fragment 节点的子节点,如果 Fragment 下面有多个子节点,那对当前节点进行 diff 时也会进入多节点 diff

有一个点需要注意:

参与比较的双方 oldFiber 代表 current fiberNode,其数据结构是 链表newChildren 代表 JSX 对象,其数据结构是 数组。由于 oldFiber 是链表,所以无法借助 “双指针” 从数组首尾同时遍历以提高效率。同样的道理,Vue 的 Diff 算法中有一个步骤是 “倒叙遍历 VNode 数组”,在 React 中也是不适用的。

多节点的 diff 会经过三轮遍历。

  • 第一轮遍历

    • key 相同,type 相同。可以复用。
    • key 相同,type 不同。不可以复用,将 oldFiber 标记为 deletion,新创建 newFiber
    • key 不同。退出第一轮循环。
  • 第二轮遍历

    • oldFiber 遍历完,newChildren 未遍历完。进入第二轮遍历。
    • 将剩下的 newChildren 标记为 Placement。即插入 Tag
  • 第三轮遍历

    • oldFiber 和 newChildren 都未遍历完。
    • 进行节点移动或插入操作。

第一轮遍历

遍历 newChildren,将 newChildren[newIdx] 与 oldFiber 比较,判断是否可复用。

  • key 相同,type 相同。可以复用。

判断 key 相同时,执行 updateElement 对比类型。

// updateSlot
function updateSlot(
    returnFiber: Fiber,
    oldFiber: Fiber | null,
    newChild: any,
    lanes: Lanes,
): Fiber | 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;
          }
        }
      }
    // ...
}

对比类型,类型相同,复用节点。

function updateElement(
    returnFiber: Fiber,
    current: Fiber | null,
    element: ReactElement,
    lanes: Lanes,
  ): Fiber {
    const elementType = element.type;
    if (current !== null) {
      // 类型相同,复用节点
      if ( current.elementType === elementType) {
        // Move based on index
        const existing = useFiber(current, element.props);
        existing.ref = coerceRef(returnFiber, current, element);
        existing.return = returnFiber;
        return existing;
      }
    }
  }
  • key 相同 type 不同导致不可复用,会将 oldFiber 在父节点的 deletions 属性中添加标记,继续第一轮循环。

key 相同,类型不同时,在 updateElement 方法中会执行

function updateElement(
    returnFiber: Fiber,
    current: Fiber | null,
    element: ReactElement,
    lanes: Lanes,
  ): Fiber {
    // ...
    // Insert
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, current, element);
    created.return = returnFiber;
    return created;
  }

调用 createFiberFromElement 新建一个 Fiber 节点并返回。然后在 reconcileChildrenArray 的第一次循环中,执行以下逻辑:

if (shouldTrackSideEffects) {
    if (oldFiber && newFiber.alternate === null) {
    deleteChild(returnFiber, oldFiber);
    }
}

如果 oldFiber 存在,并且 newFiber 也有,但是 newFiber.alternate === null。这说明新的节点在旧的 Fiber 树中不存在,所以会执行 deleteChild(returnFiber, oldFiber); 将当前旧的节点在父节点的 deletions 属性中添加标记。

然后,执行

lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); // 标记节点插入
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
    previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;

在 placeChild 方法里面,根据 oldIndex 与 lastPlacedIndex 的值标记当前新的节点是否需要插入。

  • key 不同导致的不可复用,立即跳出遍历,第一轮遍历结束。

如果 key 值不同,updateSlot 就会返回 null。在第一次循环中,有这样的判断:

const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx],
    lanes,
);
if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
break;
}

如果返回的 newFiber 为 null,就跳出循环。

如果 newChildren 遍历完(即 newIdex === newChildren.length)或者 oldFiber 遍历完,则跳出遍历,第一轮遍历结束。

当第一次遍历完后,如果 newChildren 遍历完,oldFiber 没遍历完,将 oldFiber 中没遍历完的节点添加删除标记。

if (newIdx === newChildren.length) {
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

如果 newChildren 没遍历完,oldFiber 遍历完,就会进行第二轮遍历。

第二轮遍历

如果 oldFiber 遍历完,但 newChildren 没遍历完。进行第二轮遍历,将剩下的 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;
}

如果 oldFiber 不为空。根据 oldFiber 的 key 或者 index 为 key,当前节点为 value,构建 Map 对象。

function mapRemainingChildren() {
    existingChildren.set(existingChild.index, existingChild)
}

构建好 Map 后,进入第三轮遍历。

第三轮遍历

根据 newFiber 的 key 去 existingChildren 中取:

  • 能取到。复用当前节点。
  • 不能取到。新建一个节点。

生成节点后,如果当前节点是复用的之前的节点,将当前的 key 从 existingChildren 对象中删除。然后根据新生成的节点的 index 与 lastPlacedIndex 进行比较,判断是执行移动还是插入操作。

// 第三次遍历,节点移动
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) {
    // newFiber.alternate 不为 null,表示是重用的节点,需要将 existingChildren 中重用的节点删除掉
    // 遍历结束后 existingChildren 中剩下的节点,则是需要删除的
    if (shouldTrackSideEffects) {
      if (newFiber.alternate !== null) {
        // 在调用 updateFormMap 方法时,会根据 key 取出相对应的 Fiber
        // 调用 updateFromMap 方法完成后,对应 key 的 Fiber 值被重用了,所以需要删除 Map 中使用过的 key 对应的值
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key,
        );
      }
    }
    // 将 newIdex 赋值给 workInProgress 树上的 Fiber 节点的 index 属性,代表当前元素在列表中的位置(下标)
    // 判断 current 树上元素的 Index 是否小于 lastPlacedIndex,是则表示该元素需要移动位置了,否则表示不需要移动位置
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); // 标记为插入的逻辑
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

当第三轮遍历结束后,清空 existingChildren 对象。

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

注意:mapRemainingChildren 这个方法生成的 map 对象使用的是 key 或者 index 为 key。如果我们在使用时,key 值存在重复,这里的 map.set(existingChild.key, existingChild) 就可能存在着设置覆盖的问题,相同的 map 值只会存在一个,更新时就可能就会出现异常。

比如我有以下的数据结构:

this.state = {
  // 原数据
  list: [
    { label: '问题1', value: 1 },
    { label: '问题2', value: 2 },
    { label: '问题3', value: 2 },
    { label: '问题4', value: 2 },
    { label: '问题5', value: 2 },
  ]
}

<ul>
    {
      list.map((item, index) => (
        <li key={item.value}>{item.label}</li>
      ))
    }
</ul>

展示如下所示:

初始化展示

当更新时,重新设置 list 的数据如下:

updateList = () => {
    const newList = [
      { label: '问题6', value: 1 },
      { label: '问题7', value: 4 },
      { label: '问题8', value: 3 },
    ]
    this.setState({
      list: newList,
    })
}

展示如下:

错误的更新

为什么会这样?

当第二轮遍历结束后,根据 key 值生成 Map 对象,由于原数据的有 4个 key 值为 2 的数据。所以 existingChildren 只会有一个值,这个值就是原数据中最后一个 value = 2 的值,也就是展示的 问题5

existingChildren值

当第三轮遍历结束后

  • 问题6 的 key 值在 oldFiber 中存在,节点可以复用。
  • 问题7问题8 的 key 在 oldFiber 中不存在,执行新建并插入到节点最后的逻辑。
  • 删除 existingChildren 中还剩余的值。由于里面只有一个 key = 2 的 map问题5),所以会删除 问题5。此时 diff 结束,问题2、3、4 没有删掉,还在页面中,也就是我们看到的,新列表只有 问题6、7、8,但是会展示 问题6、2、3、4、7、8 的原因。

通过以上的学习,我们从源码的角度了解了 diff 算法的具体实现。当然,这么多的文字可能头都看晕了,下面用几个示例图来描述一下 diff 执行过程。

举个例子

假如 oldFiber 有 5个 节点 ABCDE,将节点更新为 ADBCE

  • newChildren 第一次遍历

    • A 节点 key 相同,type 相同。可以复用,lastPlacedIndex = 0
  • D 节点 key 不同。退出第一轮遍历。此时 oldFiber 和 newChildren 都不为空,进入第三轮遍历。

  • D 节点在 oldFiber 中存在,oldIndex = 3 > lastPlacedIndex = 0,可以复用,不用移动。更新 lastPlacedIndex = 3

  • B 节点在 oldFiber 中存在,oldIndex = 1 < lastPlacedIndex = 3,可以复用,需要移动。此时 lastPlacedIndex = 3

  • C 节点在 oldFiber 中存在,oldIndex = 2 < lastPlacedIndex = 3,可以复用,需要移动。此时 lastPlacedIndex = 3

  • E 节点在 oldFiber 中存在,oldIndex = 4 > lastPlacedIndex = 3,可以复用,不用移动。此时 lastPlacedIndex = 4

更新示例一

假如我们将 ABCDE 更新为 EABCD,来看看是如何执行的:

  • 对 newChildren 进行第一轮遍历。

    • E 与 A 节点的 key 值不同。退出第一轮遍历。
  • oldFiber 和 newChildren 都不为空,进行第三轮遍历。

  • E 节点在 oldFiber 中存在,oldIndex = 4 > lastPlacedIndex = 0。可以复用,不用移动。更新 lastPlacedIndex = 4

  • A 节点在 oldFiber 中存在。oldIndex = 0 < lastPlacedIndex = 4。可以复用,需要移动。此时 lastPlacedIndex = 4

  • B 节点在 oldFiber 中存在。oldIndex = 1 < lastPlacedIndex = 4。可以复用,需要移动。此时 lastPlacedIndex = 4

  • C 节点在 oldFiber 中存在。oldIndex = 2 < lastPlacedIndex = 4。可以复用,需要移动。此时 lastPlacedIndex = 4

  • D 节点在 oldFiber 中存在。oldIndex = 3 < lastPlacedIndex = 4。可以复用,需要移动。此时 lastPlacedIndex = 4

可以看到,虽然我们只想将最后一个移动到第一个,但是这样操作后,能复用并且不用移动的节点只有 E 一个,其余的节点都需要移动。开发时,如果要性能最大化,这样的操作需要尽量少用。

更新示例二

假如我们将 ABCDE 更新为 FABCD,来看看是如何执行的:

  • 对 newChildren 进行第一轮遍历。key 值不同,退出第一轮遍历。

    • F 与 A 节点的 key 值不同。退出第一轮遍历。
  • oldFiber 和 newChildren 都不为空,进行第三轮遍历。

  • F 节点在 oldFiber 中不存在,执行新增操作。

  • A 节点在 oldFiber 中存在。oldIndex = 0 === lastPlacedIndex = 0。可以复用,不用移动。此时 lastPlacedIndex = 0

  • B 节点在 oldFiber 中存在。oldIndex = 1 > lastPlacedIndex = 0。可以复用,不用移动。此时 lastPlacedIndex = 1

  • C 节点在 oldFiber 中存在。oldIndex = 2 > lastPlacedIndex = 1。可以复用,不用移动。此时 lastPlacedIndex = 2

  • D 节点在 oldFiber 中存在。oldIndex = 3 > lastPlacedIndex = 2。可以复用,不用移动。此时 lastPlacedIndex = 3

可以看到,除了第一个 F 节点需要插入外,其余的节点都是可以复用,并且不需要移动的。

更新示例三

注意

1. 如果节点的展示是有条件判断的,初始时不展示,但它 Fiber 结构中的 index 是会占位的,后面的节点 index 再顺序增加。

比如:

render() {
    const { num } = this.state;
    return (
      <>
        {
          num > 1 && 
          <span>新节点</span>
        }
        <h1>{num}</h1>
        <button onClick={() => this.setState({ num: num + 1 })}>点击我+1</button>
      </>
    )
  }

这里面的 h1 节点对应 Fiber 数据结构中的 index === 1。这个在 reconcileChildFibers 中会用到。

总结

  • 如果节点下的子节点是 stringnumberboolean 类型的值。children 不会再进行 beginWork,也就不会再进行 diff
  • 只对同级元素进行 diff。如果一个 DOM 元素在前后两次更新中跨越了层级,那么 React 不会尝试复用它。
  • 两个不同类型的元素会产生不同的树。如果元素由 DIV 变成 PReact 会销毁 DIV 及其子孙元素,并新建 P 及其子孙元素。
  • 开发者可以通过 key 来暗示哪些子元素在不同的渲染下能够保持稳定。
  • key 值如果重复,diff 结束后无法正确的删除掉不需要的子节点,可能会导致渲染上的异常。
  • 在 diff 过程中,只能节点进行标记新增、移动、删除,真正执行节点并修改 UI 展示的,还是在 commit 阶段。
  • diff 的三次循环,循环的顺序特别重要。大多数情况下,要么直接更新所有节点,要么给列表的最后再添加一些新节点,那种要将后面的节点移动到前面来的操作相对较少。所以将对 oldFiber 循环放在第一个,以达到复用的最大化,尽可能地减少 diff 的次数。
  • 如果一个节点的展示是有条件判断的,某些条件下,该节点可能不展示,但节点的 index 仍然保留占有位,后面的节点的 index 会自动 +1,后面的节点不会因为前面节点由于条件判断不展示而 index 就根据不展示节点再前面的那个节点的 index + 1。比如 A,false,C,虽然 false 节点不展示,但是 C 节点的 index = 2,这个在多节点 diff 时需要用到。
  • 正常节点下面有多个节点的情况,没有设置 key 时,key 值默认是 null,更新后,key 值也为 nullnull === null 为 true,也就相当于前后的 key 值是相等的。
  • diff 的本质是对当前节点的 children 节点进行对比。