react中的diff算法

288 阅读7分钟

一、前言

diff 算法已经是一个老生常谈的话题了。当被同事问起 diff 算法时也能说起一二。那我们能否从源码出发了解,已经理解 diff 的设计初衷等等更深层次的问题。而不是只停留于。同层比较,通过 key 以及 type 作为比较依据等偏浅显的层面。今天我们就一起看看 diff 算法。

二、为什么需要diff

reactdiff是为了比较新的ReactElement对象与旧的fiber节点,判断旧的Fiber是否可以被复用。最终生成一个新的fiber节点。当全部的节点都diff完之后便可生成一颗完整的新的Fiber树。这颗新的 Fiber树将被react用于渲染页面

react中对diff的限制

如果按照一般的思路,我们需要比较每一个新生成的ReactElemnt与旧的fiber。完全比较完两颗树之后,性能消耗是非常大的。为了降低性能消耗。reactdiff设置了一些限制。

  1. reactdiff算法只做同层级的比较。如果一个dom节点跨越层级,那么该dom节点将不会被复用
  2. react会通过key来判断节点是否可以复用。
  3. 如果两个节点类型不同,react会删除旧节点,生成一个新的节点。(key相同的情况下react依然会这么做)。因为不同类型的节点会生成不同的树。比如div修改为p

三、diff

在react中diff的入口函数为 reconcileChildFibers。我们先看一下该函数的核心内容

function reconcileChildFibers (returnFiber, currentFirstChild, newChild, lanes) {
    ....
    switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
              ....
     // 有多个子节点的判断逻辑
     if (isArray(newChild)) {
        return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
      }
}

以上我们可以看到

  1. newChild 不是一个数组时,会走单节点diff逻辑
  2. newChild 时一个数组是我们会走多节点diff逻辑

让我们一个个来瞅瞅

单节点diff

function reconcileSingleElement (returnFiber, currentFirstChild, element, lanes) {
  var key = element.key;
  var child = currentFirstChild;

  // current中有节点 更新状态
  while (child !== null) {
    // TODO: If key === null and child.key === null, then this only applies to
    // the first item in the list.
    // key相同的情况
    if (child.key === key) {
      var elementType = element.type;

        // 节点type相同
        if (child.elementType === elementType) {
          deleteRemainingChildren(returnFiber, child.sibling);
          // 复用child 节点
          var _existing = useFiber(child, element.props);

          return _existing;
        }
      // type 不同标记删除
      // type 不同 删除包括兄弟节点
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不相同打上删除标记
      // 删除当前节点
      // key不同只代表当前节点不能复用
      deleteChild(returnFiber, child);
    }

    child = child.sibling;
  }

  // 创建新的fiber
  var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);

  return _created4;
}

先来整理一下整体流程

当前是否有currentFiber

  • 有 --> 判断currentFiber是否可以复用

    • currentFiber可以复用,复用currentFiber节点
    • currentFiber节点不能复用 ,标记currentFiber为删除,然后生成新的Fiber节点
  • 无 --> 根据ReactElement 生成新的Fiber节点

我们通过代码来看一看

  1. 首先判断 currentFirstChild是否存在,如果不存在,则说明需要创建新的 Fiber 节点。
  2. 判断 key 是否相等。
  • 如果 key 不同,则代表当前的oldFiber节点不能复用,则删除oldfiber节点,继续遍历后面的兄弟节点,寻找能复用的节点。
  • 如果 key 相同,则继续判断 type 是否相投

3. key相同type也相同,则可以复用旧的fiber节点,并返回fiber节点 4. 如果 key相同,type不相同,则代表节点不可复用,并且删除其兄弟节点。

我们按照以上的逻辑来看一个列子

// 之前
<ul>
    <li key="1"></li>
    <li key="2"></li>
    <li key="3"></li>
</ul>

// 之后1
<ul>
    <p key="1"></p>
</ul>

// 之后2
<ul>
    <li key="2"></li>
</ul>
  • 我们先来看一个之后1。当遍历到这里时。发现key都为1。那么key相同。但是type不同。那么唯一一个key相同(可以复用的节点,因为type不同导致不能被复用)。那么剩下的兄弟节点都不能被复用。

  • 我们再来看第二个之后2。当遍历到这里时,首先发现key不同。那么代表当前节点不能杯复用。那么我们继续在旧fiber的兄弟节点中寻找能复用的节点。当我们第二次遍历时,key为2同时type为li。那么key和type都相同。该节点可以被复用。那么复用该节点并返回。

单个节点的额diff比较简单,接下来我们看一看多节点diff

多节点diff

多节点diff的入口为 reconcileChildrenArray。由于源码内容比较多大家如果感兴趣可以点击查看源码

我们先来看一下多节点diff的存在的集中情况

  1. 节点更新
  2. 节点删除或增加
  3. 节点移动位置

但是在大多数情况下,节点更新的几率会比较大。因此diff会优先判断节点是否更新了。

按照这个思路react的diff设计为两轮遍历。

  • 第一轮遍历:处理更新的节点
  • 第二轮遍历:处理那些不属于更新的节点

注意:此处遍历时,新的ReactElement是数组类型。而旧的Fiber节点是链表类型。所以在比较时会遍历ReactElement的下标和Fiber节点的sibling

第一轮遍历

  1. newReactElementoldFiber 比较。
  2. 如果可以复用则比较下一个节点。
  3. 如果不可以复用存在两种情况
  • key不相同导致的不可复用。跳出第一轮遍历
    
  • key相同,type不同导致的不可复用。会将此刻的oldFiber标记为删除,然后继续遍历。
    
  1. 如果newReactElement或者oldFiber遍历完。跳出第一轮遍历。

第二轮遍历

按照以上的逻辑。当第一轮遍历完成时。会出现四种情况

  1. newReactElementoldFiber 都遍历完了
  2. newReactElement 遍历完了,oldFiber 没有遍历完
  3. newReactElement 没有遍历完,oldFiber 遍历完了
  4. newReactElementoldFiber 都没有遍历完。
  • 当出现第一情况时,表示只需要在第一轮遍历中处理更新即可。那么diff流程直接结束了
  • 当第二种情况出现时,表示 newReactElement已经处理完了,但是 oldFiber有多余。那么多出来的 oldFiber都可以标记为删除。
  • 当第三种情况出现时:表示 oldFiber遍历完了。能复用的都复用了。newReactElement中新增了一部分节点。那么只要把多出来的 newReactElement节点标记上新增类型即可。
  • 当第四种情况出现时。表示有节点移动了位置。这是最核心,也是最复杂的一种情况。那么让我们来看看react是如何处理这种情况的

移动位置时的处理

针对这种情况。react是如何处理的呢。让我们一起看一看

首先react会遍历完所有还没有处理的oldFiber。并且以key为key,以oldFiber为Value保存在一个map中。这是为了能快速寻找到 oldFiber

然后我们 lastPlacedIndex作为节点是否移动的参照物。

**lastPlacedIndex是指最后一个可复用节点在oldFiber中的位置**

我们假设没有改变节点位置。那么之后出现的可复用节点在oldFiber中的位置都应该大于lastPlacedIndex。因为我们是按照顺序遍历的

如果出现了一个可复用节点的下标小于 lastPlacedIndex。这就代表,该可复用节点需要向右移动。

这段逻辑非常晦涩。我们可以来看个例子

// 之前
abcd

// 之后
acdb

===第一轮遍历开始===
a(之后)vs a(之前)  
key不变,可复用
此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
所以 lastPlacedIndex = 0;

继续第一轮遍历...

c(之后)vs b(之前)  
key改变,不能复用,跳出第一轮遍历
此时 lastPlacedIndex === 0;
===第一轮遍历结束===

===第二轮遍历开始===
newChildren === cdb,没用完,不需要执行删除旧节点
oldFiber === bcd,没用完,不需要执行插入新节点

将剩余oldFiber(bcd)保存为map

// 当前oldFiber:bcd
// 当前newChildren:cdb

继续遍历剩余newChildren

key === c 在 oldFiber中存在
const oldIndex = c(之前).index;
此时 oldIndex === 2;  // 之前节点为 abcd,所以c.index === 2
比较 oldIndex 与 lastPlacedIndex;

如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
并将 lastPlacedIndex = oldIndex;
如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动

在例子中,oldIndex 2 > lastPlacedIndex 0,
则 lastPlacedIndex = 2;
c节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:bd
// 当前newChildren:db

key === d 在 oldFiber中存在
const oldIndex = d(之前).index;
oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3
则 lastPlacedIndex = 3;
d节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:b
// 当前newChildren:b

key === b 在 oldFiber中存在
const oldIndex = b(之前).index;
oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
则 b节点需要向右移动
===第二轮遍历结束===

最终acd 3个节点都没有移动,b节点被标记为移动

大家可以查看这个链接查看一个diff的小例子