【进阶】透过源码来看 Diff 算法!

1,657 阅读10分钟

前言

在前面我们已经讲了 render 阶段,和 commit 阶段的主要工作,React 的整体工作流程基本已经打通。

具体可以查看本专栏已经更新文章

在 update 时,react 会通过 diff 算法通过最小的代价完成 Fiber 树的更新,保证真实 DOM 进行更新渲染。

beginWork那一节我们知道了,在 reconcileChildren 中,会根据当前的 current 是否存在,来判断是进入 update 还是 mount 的逻辑
update时,会维护两棵虚拟 DOM 树,React 在每次更新时,都会将本次的新的内容与旧的 Fiber 树进行对比,通过 Diff 算法比较他们之间的差异后,构建新的 Fiber 树,将计算好的需要更新的节点放入更新队列中,从而在 commti 阶段,依据这个 Diff 结果,对真实 DOM 进行更新渲染。
这样可以确保通过最小的代价来将旧的 Fiber 树转化为新的 Fiber 树,以保证 UI 与新的树保持同步

首先我们需要了解几个概念,虚拟 DOM 以及 Diff 的一些策略,先把 Diff 的流程描述一下

image.png

虚拟 DOM

虚拟 DOM ,它是一种编程概念,在这个概念里,以一种虚拟的表现形式被保存在内存中。在 React 中,render 执行的结果得到的并不是真正的 DOM 节点,而是 JavaScript 对象

虚拟 DOM 只保留了真实 DOM 节点的一些基本属性,和节点之间的层次关系,它相当于建立在 JavaScript 和 DOM 之间的一层“缓存”

<div class="hello">
    <span>hello world!</span>
</div>

上面的这段代码会转化可以转化为虚拟 DOM 结构

{
    tag: "div",
    props: {
        class: "hello"
    },
    children: [{
        tag: "span",
        props: {},
        children: ["hello world!"]
    }]
}

其中对于一个节点必备的三个属性 tag、props、children

  • tag 指定元素的标签类型,如lidiv
  • props 指定元素身上的属性,如 classstyle,自定义属性
  • children 指定元素是否有子节点,参数以数组形式传入

而我们在 render 中编写的 JSX 代码就是一种虚拟 DOM 结构。

Diff 策略

那么基于多叉树的 Diff 算法,如果需要完整的对比之间的差异,复杂度会达到 O(n^3),也就是 1000 个元素需要进行 10 亿次的比较,这个开销非常之大,React 基于一些策略,来实现了 O(n) 复杂度的 Diff 算法

  1. 只对同级元素进行 diff ,如果 dom 前后两次更新跨层级,不会复用,而作为新元素
  2. 两个不同类型的元素会产生出不同的树。如 div 变成 p 会将整棵树销毁
  3. 可以通过** key** 来暗示不同的渲染下保持稳定

image.png
而针对这三种策略,分别对应着** **tree diffcomponent diff 以及 element diff 来进行算法优化

按 Tree 层级 Diff

首先会将新旧两个 DOM 树,进行比较,这个比较指的是分层比较。又由于 DOM 节点跨层级的移动操作很少,忽略不计。React 通过 updataDepth 对 虚拟 DOM 树进行层级控制,只会对同层节点进行比较,也就是图中只会对相同颜色方框内的 DOM 节点进行比较。例如:
当对比发现节点消失时,则该节点及其子节点都会被完全删除,不会进行更深层次的比较,这样只需要对树进行一次遍历,便能完成整颗 DOM 树的比较
image.png

这里还有一个值得关注的地方:DOM 节点跨层级移动
为什么会提出这样的问题呢,在上面的删除原则中,我们发现当节点不存在了就会删除,那我只是给它换位了,它也会删除整个节点及其子节点吗?
image.png
如图,我们需要实现这样的移动,你可能会以为它会直接这样移动
image.png
但是实际情况,并不是这样的。由于 React 只会简单的进行同层级节点位置变化,对于不同层级的节点,只有创建和删除操作,当发现 B 节点消失时,就会销毁 B,当发现 C 节点上多了 B 节点,就会创建 B 以及它的子节点。
因此这样会非常的复杂,所以 React 官方并不建议我们进行 DOM 节点跨级操作

按 component 层级 diff

在组件层面上,也进行了优化

  • 如果是同一类型的组件,则按照原策略继续比较 虚拟 DOM Tree
  • 如果不是,则将这个组件记为 dirty component ,从而替换整个组件下的所有子节点

同时对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点就可以节省大量的 diff 运算的时间,因此 React 允许用户通过 shouldComponentUpdate() 判断该组件是否需要进行 diff 算法分析
总的来说,如果两个组件结构相似,但被认定为了不同类型的组件,则不会比较二者的结构,而是直接删除

按 element 层级 diff

element diff 是专门针对同一层级的所有节点的策略。当节点在同一层级时,diff 提供了 3个节点操作方法:插入,移动,删除
当我们要完成如图所示操作转化时,会有很大的困难,因为在新老节点比较的过程中,发现每个节点都要删除再重新创建,但是这只是重新排序了而已,对性能极大的不友好。因此 React 中提出了优化策略:
允许添加唯一值 key 来区分节点
image.png
引入 key 的优化策略,让性能上有了翻天覆地的变化

那 key 有什么作用呢?

当同一层级的节点添加了 key 属性后,当位置发生变化时。react diff 进行新旧节点比较,如果发现有相同的 key 就会进行移动操作,而不会删除再创建

那 key 具体是如何起作用的呢?

首先在 React 中只允许节点右移
因此对于上图中的转化,只会进行 A,C 的移动
则只需要对移动的节点进行更新渲染,不移动的则不需要更新渲染

为什么不能用 index 作为 key 值呢?

index 作为 key ,如果我们删除了一个节点,那么数组的后一项可能会前移,这个时候移动的节点和删除的节点就是相同的 key  ,在react中,如果 key 相同,就会视为相同的组件,但这两个组件是不同的,这样就会出现很麻烦的事情,例如:序号和文本不对应等问题
所以一定要保证 key 的唯一性

建议

React 已经帮我们做了很多了,剩下的需要我们多加注意,才能有更好的性能
基于三个策略我们需要注意

  • tree diff 建议:开发组件时,需要注意保持 DOM 结构稳定
  • component diff 建议:使用 shouldComponentUpdate() 来减少不必要的更新
  • element diff 建议:减少最后一个节点移动到头部的操作,这样前面的节点都需要移动

下节开始,我们将会介绍 React 源码中的 Diff 算法的实现,它可以根据同级节点的数量分为两类

  • 一种是用于 objectnumberstring 类型的节点,这些都是单一节点的 Diff
  • 一种是 array 类型的多节点 Diff

我们将分为两节来介绍,Diff 算法在不同场景下的逻辑

新内容为单节点

在上面,已经说过了 Diff 算法的一些策略,这节开始通过源码来看看 React 是如何实现的
Diff 算法发生在 beginWork 阶段的 reconcileChildFibers 函数中,在这里会根据 Fiber 节点的 tag 不同,进入不同的逻辑
当新创建的节点 typeofobject 时,我们来看看 REACT_ELEMENT_TYPE 类型的 diff 。其执行的函数是 placeSingleChild() 函数,传参是 reconcileSingleElement函数的返回值

// ReactChildFiber.old.js  function reconcileChildFibers
if (typeof newChild === 'object' && newChild !== null) {
  // 单一节点的 Diff
  switch (newChild.$$typeof) {
    case REACT_ELEMENT_TYPE:
      return placeSingleChild(
        reconcileSingleElement(
          returnFiber,
          currentFirstChild,
          newChild,
          lanes,
        ),
      );
      // case....
  }

核心逻辑在于 ReconcileSingleElement

ReconcileSingleElement

reconcileSingleElement函数中,通过 while循环遍历父 Fiber 节点下所有的旧子 Fiber 节点,在每次的遍历中,都会对比 key 和 type 是否一致

  function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null, // 父 Fiber 下,第一个子 Fiber
    element: ReactElement, // 当前 react element
    lanes: Lanes, // 优先级
  ): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    // dom 节点是否存在
    while (child !== null) {
      // 旧 fiber 节点的 key 和 新 fiber 节点的 key 相同
      if (child.key === key) {
        const elementType = element.type;
        // type 是否相等
        if (elementType === REACT_FRAGMENT_TYPE) {
          //如果新的 ReactElement 和 旧fiber 都是 fragment 类型且 key 相同
          if (child.tag === Fragment) {
            // 删除,单一节点更新,key type 不同,删除
            deleteRemainingChildren(returnFiber, child.sibling);
            const existing = useFiber(child, element.props.children);
            existing.return = returnFiber;
            ...
            return existing;
          }
        } else if(child.elementType === elementType){
          // 旧fiber节点的 key 和 新fiber节点的key 相同
          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 不同,删除 child
        deleteChild(returnFiber, child);
      }
      child = child.sibling;
    }
      // 创建新的 Fiber ....
  }

如果新旧 Fiber 节点的key 和 type 都一致,那么可以复用当前的旧 Fiber 节点,此时

  • 通过 deleteRemainingChildren来对当前旧 Fiber 节点后面的兄弟 Fiber 节点标记 Deletion 删除标记
  • 通过 useFiber 函数基于当前旧子 Fiber 节点和新 props 生成新的 Fiber 节点,以复用 Fiber 节点,返回新节点
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;

如果新旧 Fiber 节点的key 相同,但 type 不同,则将当前 Fiber 及其所有兄弟节点删除

deleteRemainingChildren(returnFiber, child);

如果新旧 Fiber 节点的 key 不同,则删除当前 child 即可

deleteChild(returnFiber, child); 

deleteRemainingChildrendeleteChild的区别是,前者会通过 while 循环,遍历删除全部的 childsibling 节点

deleteRemainingChildren

shouldTrackSideEffects就是 current对应的参数,也就用来表明当前是 mount 还是 update 阶段,如果是 mount 阶段则不做操作,只有在 update才会做出处理
遍历 child,循环调用 deleteChild 进行删除

function deleteRemainingChildren(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
): null {
  if (!shouldTrackSideEffects) {
    return null;
  }

  let childToDelete = currentFirstChild;
  while (childToDelete !== null) {
    deleteChild(returnFiber, childToDelete);
    childToDelete = childToDelete.sibling;
  }
  return null;
}

placeSingleChild

placeSingleChild 函数所做的就是为 reconcileSingleElement 新生成的 Fiber 节点,打上 PlacementeffectTag,在 commit 阶段进行 DOM 更新时执行插入的操作

function placeSingleChild(newFiber: Fiber): Fiber {
  if (shouldTrackSideEffects && newFiber.alternate === null) {
    newFiber.flags |= Placement;
  }
  return newFiber;
}

疑问

  • 为什么在 key 相同 type 不同时,删除全部的子节点?而 key 不同时,只删除当前节点?

因为 当 key 相同,type 不同时,表达我们已经找到了对应的 fiber,但是由于 type 不同导致了不能复用,又因为 key 是唯一的,剩下的其他的 Fiber 都无法再与这个 key 匹配了,所以剩下的都是 key 的不同情况,因此全部标记删除
当 key 不同时,后面的 fiber 有可能会和当前的 key 相同,因此仅仅删除当前的 fiber

新内容为数组

在上面,我们知道了 React 是如何对单节点进行 Diff 的,逻辑很简单,判断 key 和 type 即可,而对于 多节点而言,也就是 Array 而言,在场景上也就是将一个或多个 Fiber 替换成多个 Fiber,处理起来会复杂很多
这种情况下,reconcileChildFibersnewChild 参数类型为 Array,在 reconcileChildFibers 函数内部会进入下面的 if 中,从而处理 array 多节点的 diff

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // ...省略
  }

如下图 newChild 为新的节点,是 Array 类型的

image.png

Diff 的思路

首先我们先看看一共有哪些情况需要被处理:节点更新、节点增加、节点减少、节点位置变化等
在实际的场景中,节点更新使用的场景会更加高一些,因此 React 中会优先处理节点更新的情况
reconcileChildrenArray的参数中,currentFirstChildFiberNode 也就是链表的结构,newChild则是一个 JSX 对象,因此我们对比的是一个链表和一个数组

虽然本次更新的 JSX 对象 newChildren 为数组形式,但是和 newChildren 中每个组件进行比较的是 current fiber,同级的 Fiber 节点是由 sibling 指针链接形成的单链表,即不支持双指针遍历。 即 newChildren[0]fiber 比较,newChildren[1]fiber.sibling 比较。 所以无法使用双指针优化。

因此,基于此原因, Diff 算法采用两轮遍历:
第一轮遍历:处理更新的节点
第二轮遍历:处理剩下的不属于更新的节点

第一轮遍历

第一轮遍历主要处理的是更新的逻辑
遍历 newChildren,将 newChildren[i]oldFiber进行比较,判断是否可以复用
Diff 的逻辑存在于 updateSlot 这个方法中,这个方法的逻辑和 reconcileSingleElement 非常像,如果 key 不同会返回 null,key 相同会返回新的 Fiber 节点

调用 updateSlot 来 diff oldFiber 和 新的child,生成新的fiber

function updateSlot( returnFiber: Fiber, oldFiber: Fiber | null,  newChild: any,lanes: Lanes,): Fiber | null {
  const key = oldFiber !== null ? oldFiber.key : null;
// ...
  // 	处理 react Element 
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE: {
        if (newChild.key === key) {
          return updateElement(returnFiber, oldFiber, newChild, lanes);
        } else {
          return null;
        }
      }
      //...
    }

  return null;
}

1. key 不同跳出遍历

updateSlot 的返回值是 null 时,会跳出第一轮循环
这也说明了,oldFibernewIdx 为下标的 newChild 的 key 不同,说明 oldFiber 不能复用

if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    break;
}

2. key 相同 type 不同

当 key 相同时,会进入 switch case 中的 if 逻辑,执行 updateElement 方法,type 不同而执行 createFiberFromElement方法,生成新的 Fiber

function updateElement(
  returnFiber: Fiber,
  current: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  const elementType = element.type;
// ...
  if (current !== null) {
    if (current.elementType === elementType ){
    // type 相同逻辑
    }
  }
  // type 不同
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, current, element);
  created.return = returnFiber;
  return created;
}

因此此时的 newFiber 为新创建的这个 Fiber 节点,如果是在 update 阶段,会进入下面 if 的逻辑,newFiberalternate 指针为 null ,将 oldFiber 标记删除

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

创建了新的 Fiber 节点,我们需要为新的 Fiber 节点赋值一个 placement 的 effectTag ,这样在 commit 阶段才会更新 DOM 插入到 DOM Tree 中

function placeChild(
  newFiber: Fiber,
  lastPlacedIndex: number,
  newIndex: number,
): number {
    // 新节点插入的索引
  newFiber.index = newIndex;
  // ...
  const current = newFiber.alternate;
    // 先判断是不是 update
  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 {
    // 新创建的 fiber 节点,alternate 为 null,赋值 placement 的 effectTag
    newFiber.flags |= Placement;
    // lastPlacedIndex 代表本次更新的节点在 DOM 中的位置
    return lastPlacedIndex;
  }
}

然后将本次 Diff 生成的新节点与上一个新节点建立连接,也就是 sibling 指针指向
之后开始对下一个 oldFiber 进行 Diff

lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
  // 标识为 新节点的第一个
  resultingFirstChild = newFiber;
} else {
  // 和前一个新节点连接
  previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;

3. 遍历完成跳出遍历

1. newChildren 遍历完成,oldFiber 未遍历完成

如果 newChildren 遍历完成,而 oldFiber 还没遍历完成,那么剩下的所有 oldFiber 都需要被删除
通过 deleteRemainingChildren 函数来对,所有的 oldFiber 节点进行删除

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

2. oldFiber 遍历完成,newChildren 未遍历完成

如果是这种情况,需要剩余的 newChildren 全都是需要新增的节点,那就继续遍历 newChildren的剩余节点,调用 createChild 方法来生成新的 Fiber 节点,然后调用 placeChild 来打上 PlaceMenteffectTag ,然后建立 sibling 的连接,与 Fiber 树进行关联

if (oldFiber === null) {
  // If we don't have any more existing children we can choose a fast path
  // since the rest will all be insertions.
  for (; newIdx < newChildren.length; newIdx++) {
    // 创建 Fiber
    const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
    if (newFiber === null) {
      continue;
    }
    // 添加 effectTag
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    // 连接 Fiber 树
    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;
}

3. 如果 oldFiber 和 newChildren 都没有遍历完成

这意味着有节点在本次更新中改变了位置
为了在 O(1) 复杂度内,找到 key 对应的 oldFiber 节点,通过 mapRemainingChildren 函数创建一个以 oldFiber 的 key 为键,oldFiber 为值的 Map 数据结构, 将所有未处理的 oldFiber 存入 key --> FiberNode 的 Map 中

const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

第二轮遍历

只有在 oldFibernewChildren 都没有遍历完成的情况下,才会进入第二轮遍历,遍历是基于生成的 Map 数据结构 existingChildren 进行的。
遍历 newChildren通过 updateFromMap 函数从 Map(existingChildren) 中找到具有相同 key 的 oldFiber

for (; newIdx < newChildren.length; newIdx++) {
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    lanes,
  );
  if (newFiber !== null) {
    // update 阶段,从 map 中删除 oldFiber
    if (shouldTrackSideEffects) {
      if (newFiber.alternate !== null) {
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key,
        );
      }
    }
    // 打上 placeMent 的 effectTag
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    // 建立 Fiber 连接
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}
  • 如果找到了,则基于 oldFibernewChildrenprops 创建新的 fiber,然后从 Map中删除该 oldFiber,打上 PlaceMent 的 effectTag,并将新生成 Fiber 与 Fiber 树建立连接

需要注意的是,这次的 placeChild 中,我们需要关注它的核心逻辑

  • oldIndex < lastPlacedIndex 时,表示该可复用节点之前的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动
  • 如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
function placeChild(
...
    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;
    }
...
}

如果在 newChildren 遍历完成后,map 中还有值,则需要全部进行删除标记,因为在 newChildren 中已经不存在了

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

至此,reconcileChildArray 流程已经全部走完,多节点 Diff 的逻辑已经全部讲解完毕!

总结

本文介绍了 React 在 Diff 算法中采用的三种策略,按 tree、类型、列表进行 Diff,并通过源码来分析了 React 的 Diff 实现。

在 Diff 的流程结束之后,会形成新的 Fiber 树,Diff 的节点会有 flags 字段标记副作用。在 commit 阶段会根据记录的 flags 进行 真实 DOM 的更新!

下一节,讲解状态更新流程!

参考资料

谈谈React中Diff算法的策略及实现
React diff算法
浅谈react 虚拟dom,diff算法与key机制

❤️ 谢谢支持

喜欢的话别忘了 点赞 三连哦~。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿