图文结合讲解React17、Vue2.0、Vue next Diff算法原理及实现

1,859 阅读13分钟

React Fiber Diff算法

算法原理

React Fiber Diff算法分为两个部分,第一部分是通过从左到右同步遍历的方式寻找可以原地复用的节点;第二部分是采用下标递增法标记需要移动的节点。(diff标记阶段是在render phase,而移动阶段则是在commit phase)。

下面举几个例子:

  1. 前后不变的例子:

从上面的例子中,可以很明显看出各个节点都能够复用,并且不需要做任何移动节点的操作。React算法在第一部分处理所有能够原地复用的节点。

image.png

  1. 前后顺序改变的例子:

如图所示,在这个例子中,React算法在第一部分会先原地复用“A”节点,在第二部分的时候,会先将旧列表中可复用节点的下标设置为在该元素新列表对应节点的下标,如D的newIndex为3,B的newIndex为1,设置完毕后,根据下标递增法,将不符合下标递增规则的元素往后移动。

image.png

如图所示,在这个例子中,React算法在第一部分会先原地复用“A”节点,在第二部分的时候,会先将旧列表中可复用节点的下标设置为在该元素新列表对应节点的下标,如D的newIndex为3,B的newIndex为1,设置完毕后,根据下标递增法,将不符合下标递增规则的元素往后移动。

算法流程

Tip:在讲解算法流程之前,先讲讲React Fiber Node组织架构

React Fiber架构中,在同一层中的Fiber是以单向链表的形式组织,而不像React15以及其他框架上是以数组的形式组织。单向链表上组织,那么意味着不方便查询,而且不支持从后往前遍历。所以React在采用下标递增法复用节点的时候,采用map结构存储所有节点,方便后续进行查询操作。下面是React Fiber架构图:

image.png

React fiber架构是一种树状复杂链表结构,有每个节点都有sibling、child、return三个指针,分别指向下一个同级节点、子节点、父亲节点。而diff算法对比的是新旧两棵树同一层次上sibling链表。这里还有一个要注意的点就是在sibling链表上,fiber节点具有一个index属性,标志着该节点上链表上的下标,设置该属性的目的是方便diff算法判断该节点是否需要移动。

除此之外,React采用双缓冲机制,即每个视图在update阶段都会有两个Fiber树,一个是current,描述着当前已经渲染了的视图,另外一个是workInProgress,代表正在工作的Fiber树。两者中对应节点(如果判断DOM可以复用)通过alternate进行互相引用。在本篇文章中,只需要知道workInProgress中fiber节点的alternate属性指向current树中判定已经是可以复用的fiber即可。

Tip: React Fiber Diff算法是将 Fiber结构(链表)和React Elements(数组)形式进行新旧对比

React Fiber Diff算法分为两个阶段:

  1. 原地复用节点

先看看算法实现:

let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;

// 指向旧链表中第一个没有经过复用的节点
let oldFiber = currentFirstChild;
// 这个是目前新的fiber节点复用了旧的fiber节点的最大下标,用来标记节点是否需要移动
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;
  }
  // react将旧的fiber每个节点都看作一个slot,先匹配前后key是否相同,如果相同则匹配了该slot
  // 后续再根据elementType等信息决定是否复用该节点
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx],
    lanes,
  );
  // 匹配不到可以原地复用的节点,第一部分工作结束
  if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    break;
  }
  if (shouldTrackSideEffects) {
    if (oldFiber && newFiber.alternate === null) {
      // 匹配到slot,但是fiber并没有复用,应当标记删除节点
      deleteChild(returnFiber, oldFiber);
    }
  }
  // 每个更新都找到新节点对应旧节点的最大下标
  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
  if (previousNewFiber === null) {
    resultingFirstChild = newFiber;
  } else {
    previousNewFiber.sibling = newFiber;
  }
  previousNewFiber = newFiber;
  oldFiber = nextOldFiber;
}

// 当新列表是旧列表的子列表,那么此时删除剩余节点
if (newIdx === newChildren.length) {
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

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) {
      // TODO: Move out of the loop. This only happens for the first run.
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
  return resultingFirstChild;
}

上述代码中从左到右同步遍历新的React Elements数组和旧的React Fiber链表找出可以原地复用的节点进行复用操作。现在以比较简单的例子讲解该流程的实现:

image.png

在上面例子中,Old Fiber是旧的Fiber,而下面React Elements是新的状态,那么复用流程如下: 上述的简单代码应用到上面的例子,那么执行完后状态为:

react diff第一阶段.gif

在上面的例子中,新旧列表原地复用了“A”节点和“B节点”。

  1. 复用需要移动的节点

算法的第一部分,先从左到右复用了可以原地复用的节点,接下来是在比较混乱的列表中最大限度复用节点,代码如下:

// 将除了可以原地复用之外的节点制作成为一个map,以空间换时间的策略方便后面查询操作
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

// Keep scanning and use the map to restore deleted items as moves.
for (; newIdx < newChildren.length; newIdx++) {
  // 通过map找到可以复用的节点,如果不可以复用,那么进行创建新的节点
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    lanes,
  );
  if (newFiber !== null) {
    if (shouldTrackSideEffects) {
      if (newFiber.alternate !== null) {
        // alternate并不是null,说明该节点是匹配到复用节点,
        // 从map中删除已经被复用的节点,避免重复复用
        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) {
  // 在这里map剩下的节点都是没有被匹配到的节点,那么应当进行删除操作
  existingChildren.forEach(child => deleteChild(returnFiber, child));
}

该算法大体上可以成三个步骤:

  1. 在新旧列表中找出key相同的节点:这是一个查询操作,React在这里采用空间换时间策略,使用map<FiberNodeKey, Fiber>存储旧的节点,之后遍历新节点列表的时候通过新节点的key获取可能可以复用的节点。
  2. 判断节点是否可以复用,可复用则跳到步骤c,否则进行创建节点操作
  3. 如果可以复用,那么需要判断该节点是否需要移动,上述代码中用到了placeChild这个方法判断节点需不需要移动,接下来查看这个方法的具体实现:
function placeChild(
  newFiber: Fiber,
  lastPlacedIndex: number,
  newIndex: number,
): number {
  newFiber.index = newIndex;

  // 如果新的fiber匹配到旧的fiber,那么alternate属性会指向复用的节点
  const current = newFiber.alternate;
  if (current !== null) {
    // 拿到复用的旧节点的下标
    const oldIndex = current.index;
    if (oldIndex < lastPlacedIndex) {
      // 新列表是从左往右遍历的,上一个节点复用的DOM在本节点的右侧,
      // 那么需要将本节点往右移动才能够满足新列表的排序顺序
      // This is a move.
      newFiber.flags |= Placement;
      // 返回目前最远的下标
      return lastPlacedIndex;
    } else {
      // 返回目前最远复用的下标
      return oldIndex;
    }
  } else {
    // This is an insertion.
    newFiber.flags |= Placement;
    return lastPlacedIndex;
  }
}

上述逻辑中,只要判断当前复用节点的下标小于lastPacedIndex,那么需要将复用节点标记为需要移动。这是因为算法是从左到右遍历新列表,再从旧列表中找到能够复用的节点。如果前面能够复用的节点在靠右侧,并且下一个节点能够复用的节点在前一个能够复用节点的左侧,那么需要将下一个节点所对应的DOM节点往后挪才能够满足更新顺序。采用这种方式将下标不是递增的节点进行标志,后续进行移动,这也是递增法的思想。

接下来下面举个例子讲解第二部分的整个过程:

react fiber 第二阶段.gif

diff算法处理是在render phase下进行,在这个阶段并不会处理DOM节点,等到commit phase的时候再处理。

完整代码:React Fiber Diff

不足

React Fiber的diff算法有两个不足:

  1. 由于fiber是单向链表的缘故,所以原地复用阶段只能从原地访问前半部分,如果fiber是双向链表,那么可以复用后半部分;
  2. 在第二部分工作中判断哪些节点可以不需要移动的时候,只是很简单的从左到右判断,这样在某些极端的情况下性能并不是最佳,如上面例子中,如果原先的“2”是在最后一个元素,那么需要移动的节点就有“5”,“4”,“3”。而Vue3.0 Diff算法给出优化方案:采用最长上升子序列得到最长可以原地复用节点数组。

Vue2.0 Diff算法

算法原理

Vue的Diff算法采用了双端比较法,定义四个游标,分别是旧数组的前后和新数组的前后,进行循环操作,每一轮比较都会先比较新旧数组的头部、尾部、以及两个列表头尾交叉对比,如果都没有找到可以复用的节点,那么会进行查询操作找到对应的节点。

下面举个例子:

image.png

该算法会有四个下标,这四个下标分别是两个数组的首尾元素的下标,即oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。一开始都指向了两个数组的双端。算法开始后,会不断往内部靠拢,直到产生交叉后算法结束。

let oldStartIdx = 0;
let oldEndIdx = PreList.length - 1;
let newStartIdx = 0;
let newEndIdx = NewList.length - 1;

上述例子中,会先比较PreList和NewList的首尾元素,即图中的“1”,发现两个元素相同,那么oldStartIdx和newStartIdx会往后挪。即:

oldStartIdx++;
newStartIdx++;

接下来发现两个列表首元素不同了,那么会比较尾元素,即图中“5”,此时会将两者后指针往前挪:

oldEndIdx--;
newEndIdx--;

接下来发现剩下数组双端都无法匹配的时候,会进行首尾交叉比较,发现PreList[oldStartIdx]和NewList[newEndIdx]相同,那么此时会将两个指针往中间靠拢(不考虑挪动操作):

oldStartIdx++;
newEndIdx--;

之后如果发现双端比较、首尾比较都不成功了,那么会进入通过查询寻找可复用节点。

算法流程

上面讲解了双端比较法的原理,这里将双端比较法每一轮循环都分为两部分工作,第一部分是进行简单比较求可复用节点,第二部分工作是进行查询可复用节点:

  1. 简单比较求可复用节点实现代码如下,相比于源码,代码中少了patch等无关函数调用:
// 真实节点的插入操作
function insertBefore(parentElm, movedElm, targetElm) {
  // 本函数的作用是将movedNode移动到targetNode上一个位置
}

function nextSibling(elem) {
  // 返回elem右侧的节点
}

function isSameNode(nodeA, nodeB) {
  // 判断节点是否可以复用
}

const isUndef = (tar) => typeof tar === 'undefined';

function vueDiff(parentElm, oldList, newList) {
  let oldStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let newStartIdx = 0;
  let newEndIdx = newCh.length - 1;
  let oldStartNode = oldCh[0];
  let oldEndNode = oldCh[oldEndIdx];
  let newStartNode = newCh[0];
  let newEndNode = newCh[newEndIdx];
  
  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      // 在查询匹配节点的时候,如果查询到可复用节点,
      // 移动节点后会将该节点在oldList的位置设置为undefined,避免重复使用
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (isUndef(oldEndVnode)) {
      // 分支理由同上
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      // 将节点往右侧移动,移动到右侧第一个没有处理的节点的右侧
      insertBefore(parentElm, oldStartVnode.elm, nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      // 将节点往左侧移动到第一个没有处理的节点的左侧
      insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 查询可复用节点部分逻辑,下面会讲
    }
  }
}

上述是双端比较法理想情况下的对比,我们可以发现双端比较法的思想是从两端开始,找到可以复用的节点按顺序进行填充。在工作过程中会分为已处理区域和未处理区域:

image.png

图中灰色的部分是已经处理的区域,而具有颜色的是还没有处理的区域。根据处理逻辑,此时匹配到了“2”这个节点,那么应当如何处理呢?因为最后目的是将DOM处理成跟newCh一样的顺序,那么肯定是将oldCh中的“2”往后挪到已经被处理过的节点的左侧,即:

处理区域和非工作区域.gif

接下来举一个比较复杂的例子描述整个简单比较过程:

Tips:oldCh和newCh都是以数组形式组织,而其对应的DOM则是以链表形式组织

下面是一个更新例子,其中DOM是真实DOM节点,oldCh是对应渲染时候的Vnode节点列表,newCh是下一个状态的Vnode节点列表:

image.png

下面是只运行diff算法中简单原地复用代码,具体节点操作动画如下:

第一部分工作.gif

在本case中,先分别进行新旧首尾节点对比,其中“1”和“6”都匹配成功,这种条件下,最后渲染的位置是不会变的,所以不需要进行节点移动。当首尾无法匹配之后,进行首尾交叉匹配,此时若匹配到相应节点,需要进行节点移动操作(直接在相应的DOM节点上进行操作,Vnode上不需要进行节点移动)。

  1. 查询寻找可复用节点 第一阶段是在理想情况下进行比较,但是很多情况往往是不理想,那么在不理想情况下,需要通过查询操作查找可复用节点。Vue2.0 Diff在查询可复用节点的时候,会存在两种情况:
  • 用户编写循环渲染的时候,给列表的每个节点设置一个独一无二的key:此时双端比较法会进行优化,采用空间换时间的策略,利用Map结构将key存储起来,查询的时候根据新节点的key值寻找匹配的节点,这种情况下查询节点操作的时间复杂度是O(m),其中m <= n,n为列表总长度。
  • 用户编写循环渲染的时候,不为每个节点设置key:对于不设置key值的列表,Vue通过暴力法进行搜索比较,那么该操作的时间复杂度是O(m ^ 2),(其中m <= n,n为列表总长度。在最差的情况下,m === n,那么双端比较法最差的时间复杂度是O(n ^ 2))。

那么具体的实现代码如下:

// 真实节点的插入操作
function insertBefore(parentElm, movedElm, targetElm) {
  // 本函数的作用是将movedNode移动到targetNode上一个位置
}

function nextSibling(elem) {
  // 返回elem右侧的节点
}

function isSameNode(nodeA, nodeB) {
  // 判断节点是否可以复用
}

function createKeyToOldIdx (children, beginIdx, endIdx) {
  let i, key
  const map = {}
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key
    if (isDef(key)) map[key] = i
  }
  return map
}

function findIdxInOld (node, oldCh, start, end) {
  for (let i = start; i < end; i++) {
    const c = oldCh[i]
    if (isDef(c) && sameVnode(node, c)) return i
  }
}

const isUndef = (tar) => typeof tar === 'undefined';

function vueDiff(parentElm, oldList, newList) {
  let oldStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let newStartIdx = 0;
  let newEndIdx = newCh.length - 1;
  let oldStartNode = oldCh[0];
  let oldEndNode = oldCh[oldEndIdx];
  let newStartNode = newCh[0];
  let newEndNode = newCh[newEndIdx];
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm;
  
  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      // 在查询匹配节点的时候,如果查询到可复用节点,
      // 移动节点后会将该节点在oldList的位置设置为undefined,避免重复使用
      // 这里遇到undefined的时候,自动跳过
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (isUndef(oldEndVnode)) {
      // 分支理由同上
      oldEndVnode = oldCh[--oldEndIdx];
    } else if() {
      // 省略一大堆if else
    } else {
      if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }
      // 查询节点
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
      if (isUndef(idxInOld)) {
        // 创建新的DOM,并且在oldStartVnode对应的DOM节点前插入
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // 将该下标的值设为undefined,标记着这个Vnode已经被处理过了,避免后面重复处理
          oldCh[idxInOld] = undefined
          insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // key相同,但是元素类型不相同,同样是视为需要重新创建节点
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }
}

需要注意到的是在查询可复用节点处理逻辑中,如果找到可复用节点,进行操作过后需要将oldCh在该节点下标的值设为undefined,标志着该节点已经被复用,避免后续重复利用该节点。

前面的case经过第一阶段的运行,得到如下结果:

image.png

Tips:其中需要注意到Vnode和DOM之间是通过引用链接的,而不是通过下标匹配的,所以更新的时候是通过引用拿到对应DOM节点。(图中很容易误解是通过下标拿到的)。

vue diff动画.gif

上面将Vue2.0 Diff算法分为两部分工作,只是便于理解,并不是说分为两个节点。实际上这两部分工作是每个循环都会进行判断的。

最后附上代码链接:Vue2.0 Diff算法

Vue3.0 Diff算法

算法原理

Vue3.0的Diff算法与React Fiber的递增法有点相似但却有点不同,Vue3.0的diff算法可以分成两个阶段:

  1. 第一个阶段是从数组两侧对可以原地复用的节点进行遍历复用(这一点跟React Diff算法相似,React只是原地复用前半部分并没有尝试原地复用后半段,原因是Fiber架构是单向链表,从后往前遍历成本太大),复用完毕后,如果有一个数组是另外一个数组的子数组,那么此时会进行mount或者unmount操作并结束。
  2. 如果两个数组都不成子序列关系,即存在完全不同的序列,那么会进入第二阶段,在这个阶段复用节点的前提下,会尽可能减少DOM的移动操作(这里采用了最长上升子序列的方式帮助算法减少DOM操作)。

算法流程

话不多说,先看看整个算法的流程,这里分成两个阶段:原地复用阶段和复用需要移动节点阶段 先来看看原地复用阶段是如何处理的?

双端原地复用

function Vue3Diff(c1, c2) {
  let i = 0
  const l2 = c2.length
  let e1 = c1.length - 1
  let e2 = l2 - 1
  
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];
    
    if (isSameNode(n1, n2)) {
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else {
      // 只要不能够原地复用,那么就退出,等待第二阶段
      break;
    }
    i++;
  }
  
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];
    
    if (isSameNode(n1, n2)) {
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else {
      // 只要不能够原地复用,那么就退出,等待第二阶段
      break;
    }
    
    e1--;
    e2--;
  }
  
  // 旧列表是新列表的子数组,此时需要将新列表多出来的节点进行挂载操作
  if (i > e1) {
    if (i <= e2) {
      const newPos = e2 + 1;
      // 获取节点插入的位置
      const anchor = newPos < l2 ? (c2[nextPos]).el : parentAnchor;
      while (i <= e2) {
        // 创建节点并且挂载上去
        patch(
          null,
          (c2[i] = optimized
            ? cloneIfMounted(c2[i] as VNode)
            : normalizeVNode(c2[i])),
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        i++
      }
    }
  } else if (i > e2) {
    // 反过来如果新列表是旧列表的子数组,那么此时需要将旧列表上剩余的节点进行unmount操作
    while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
    }
  }
}

上面是第一阶段处理代码,举几个例子分别讲解以上的逻辑对应的场景:

双端遍历复用:

image.png

在上述例子中,进行双端原地复用后如下图:(灰色代表已经被复用了)

image.png

当双端复用结束后,可能会有以下两种情况

  1. 旧列表是新列表的子数组

image.png

在这种情况下,旧数组是新数组的子序列,那么此时应当将“4”和“5”挂载到DOM上:

旧数组是新数组的子序列.gif

  1. 新列表是旧列表的子数组

image.png

这种情况下,新数组是旧数组的子序列,那么此时需要将DOM节点unmount掉:

新数组是旧数组的子序列.gif

复用需要移动的节点

如果两个数组并不存在子数组关系,那么此时会通过查询的方式找到新旧可复用的节点,并且通过找到最长上升子序列的方式帮助Vue寻找操作DOM节点最少的路径。 在复用需要移动节点的阶段工作又可以分成两部分:标记可以复用的节点、移动节点。

  1. 标记可以复用的节点 这部分工作又可以分成两个动作:查找可以复用的节点、新旧复用节点标记。

a. 查找工作可以复用的节点:与Vue2.0处理方式一致,如果列表元素有key,那么采用Map,以空间换时间的方式查询,否则进行遍历查询。

b. 新旧复用节点标记:Vue3.0采用一个数组作为新旧列表匹配到的节点下标对应起来:

const newList = [a, b, c, d, e];
const oldList = [a, c, b, e, f];

// 那么map应当如下:
const newIndexToOldIndexMap = [2, 1, 4, 0];

newIndexToOldIndexMap数组中0代表当前节点无法找到可复用的节点,具体匹配结果如图:

image.png

上面解释了这部分的工作,需要注意到的是从“还没有处理的列表”中进行匹配的,可以理解为子数组中寻找匹配节点。

接下来看看代码:

// i是上一阶段遍历的后,左侧第一个不能复用的节点下标
const s1 = i // prev starting index
const s2 = i // next starting index

// 这一步是构建 key -> Vnode map
const keyToNewIndexMap: Map<string | number, number> = new Map()
for (i = s2; i <= e2; i++) {
  const nextChild = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  if (nextChild.key != null) {
    keyToNewIndexMap.set(nextChild.key, i)
  }
}


let j
// 已经patched操作的节点数
let patched = 0
// 需要patch操作的节点的长度
const toBePatched = e2 - s2 + 1
let moved = false
let maxNewIndexSoFar = 0
const newIndexToOldIndexMap = new Array(toBePatched)
// 默认每个元素都没有被复用到
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

// 这里要注意是遍历旧的列表中没有复用部分
for (i = s1; i <= e1; i++) {
  const prevChild = c1[i]
  if (patched >= toBePatched) {
    // 新列表所有节点已经patched了,但是久数组还有元素,此时只需要将旧数组元素移除即可
    // all new children have been patched so this can only be a removal
    unmount(prevChild, parentComponent, parentSuspense, true)
    continue
  }
  let newIndex
  if (prevChild.key != null) {
    // 如果设置了key,那么通过map获取key值
    newIndex = keyToNewIndexMap.get(prevChild.key)
  } else {
    // 没有key的情况下只能够通过遍历查询可复用节点
    // key-less node, try to locate a key-less node of the same type
    for (j = s2; j <= e2; j++) {
      if (
        newIndexToOldIndexMap[j - s2] === 0 &&
        isSameVNodeType(prevChild, c2[j] as VNode)
      ) {
        newIndex = j
        break
      }
    }
  }
  if (newIndex === undefined) {
    // 原有的Vnode在新列表中找不到与之匹配的Vnode的时候,将原有的Vnode移除
    unmount(prevChild, parentComponent, parentSuspense, true)
  } else {
    // 标记新列表中Vnode(下标)匹配到旧列表中的下标,注意这部分有个 + 1操作,因为0是
    newIndexToOldIndexMap[newIndex - s2] = i + 1
    if (newIndex >= maxNewIndexSoFar) {
      maxNewIndexSoFar = newIndex
    } else {
      moved = true
    }
    // patch操作会将 oldVnode上指向的DOM移动到newVnode上
    patch(
      prevChild,
      c2[newIndex] as VNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
    patched++
  }
}

上述逻辑挺很清晰,寻找新旧节点,并且使用数组将可以复用的节点进行联系起来,但是上述代码中会有一个比较疑惑的点,就是会有两个变量:maxNewIndexSoFar、moved,它们存在的意义是什么呢?

if (newIndex >= maxNewIndexSoFar) {
  maxNewIndexSoFar = newIndex
} else {
  moved = true
}

这是一种优化,使用moved变量标记后续是否需要进行移动节点。为什么这么说呢?举几个例子:

markmove.gif

在这里例子中,判断是否需要移动的逻辑一直走if分支,这是因为匹配到的节点一直都在递增,复用DOM节点操作的时候是不需要进行移动节点操作的,仅仅需要在B节点前面添加一个F节点即可(DOM是以链表形式组织节点的,所以不需要移动后面节点)。

接下来再举个需要移动节点的例子:

needMoved.gif

在这个例子中,原先B对应的DOM节点在C之前,更新后需要在C之后,那么必定要进行DOM节点的移动。而Vue则是通过判断该节点的下标与之前匹配到新列表最大下标进行比较从而得到是否需要移动。这一点跟React Fiber Diff算法中的lastReplaceIndex有异曲同工之妙。

  1. 移动节点

在标记完可以复用节点之后,需要对这些节点进行移动等操作,移动节点的路径有很多,甚至重新排序都是一种移动方式,这里Vue需要考虑的是如何尽可能少操作DOM。Vue3.0采用了最长递增子序列算法找出复用旧节点中最长升序序列,在这个序列上对应的DOM节点不需要任何移动,通过移动其他节点来完成DOM节点的更新。

先给出LeCode链接:最长递增子序列

可以先不去看LeeCode链接,下面给出一个例子:

[1, 3, 2, 4, 8, 5, 7]

该例子的最长上升子序列:
[1, 2, 4, 5, 7]

最长上升子序列是求出从左到右最长递增的子数组。接下来举个实际的例子

const oldCh = [a, b, c, d, e];
const newCh = [a, e, b, c, d];

// 那么求得
newIndexToOldIndexMap = [4, 1, 2, 3];

在上面例子中,最简单的移动方式是将e移动到b前面即可,那么Vue是怎么处理呢?先找出最长的相对升序的序列。寻找升序是因为这些节点对应的DOM节点的位置关系跟newCh中想要的相对位置关系一致,这样这些节点就不需要进行任何移动,而找出最长就是尽可能找到更多不需要移动的节点,这样能够最大限度减少DOM操作。那么先求出最长上升子序列:

const oldCh = [a, b, c, d, e];
const newCh = [a, e, b, c, d];

// 那么求得
newIndexToOldIndexMap = [4, 1, 2, 3];
// 由newIndexToOldIndexMap求出最长上升子序列
increasingNewIndexSequence = [1, 2, 3];

求得最长递增子序列中可以看出在oldCh中 bcd所对应的DOM节点是不需要改变的,只需要移动其他节点即可,即最后bcd的相对位置是保持不变的,但是可以在它们之间插入节点:

image.png

接下来看看算法的具体实现:

// 拿到最长优先子序列
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// looping backwards so that we can use last patched node as anchor
// 注意到i是递减的
for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i
  const nextChild = c2[nextIndex] as VNode
  const anchor =
    // 如果nextIndex + 1 === l2 ,那么说明后面是没有节点的,直接采用parentAnchor.appendChild
    // 否则就是插入到anchor前面
    nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
  if (newIndexToOldIndexMap[i] === 0) {
    // 没有可以复用的旧节点,那么创建新的节点。
    patch(
      null,
      nextChild,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else if (moved) {
    // move if:
    // There is no stable subsequence (e.g. a reverse)
    // OR current node is not among the stable sequence
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // 这些节点都是需要进行移动
      move(nextChild, container, anchor, MoveType.REORDER)
    } else {
      // increasingNewIndexSequence存放的是上升子序列的下标,
      // 如果 i === increasingNewIndexSequence[j],
      // 那么说明当前节点是不需要移动,跳过本次循环
      j--
    }
  }
}

Tips:上面的anchor是定位点,如果是同等级节点,那么新的节点会插入到这个锚点前面,如果是父亲节点,则会直接插入到整个DOM链表后面

移动整个过程是从右往左进行遍历移动节点,如果遇到最小上升子序列的时候,说明节点不需要移动,此时跳过本次循环。

上面有个疑惑点,为什么anchor中要选择c2数组(即newCh数组)中作为标志位锚点呢?这是因为在标记的时候,调用了patch函数,patch函数将旧Vnode节点可以复用的DOM节点的引用(但是DOM节点的实际位置并不会改变)转移到newCh对应的Vnode上了。至此,Vue3.0diff算法结束。

下面举个例子讲解这一个过程:

现有更新前后两个节点数组:

const oldCh = [a, b, c, d, e];
const newCh = [a, c, b, e, d];

image.png

经过计算得出以下数据:

const newIndexToOldIndexMap = [2, 1, 4, 3];
const increasingNewIndexSequence = [1, 3];

那么得出“b”和“d”是不需要移动的,接下来动画演示移动过程,看演示之前先看三个注意的点:

  1. 在标记阶段,patch函数会将newCh中对应的Vnode的el属性指向可以复用的DOM节点,所以本阶段是不需要使用到oldCh数组的
  2. 下标是从标记数组第一个元素开始,也就是说上述情况中,下标是从newCh中的“c”元素开始的。
  3. DOM是以链表形式组织的。

vue next diff move.gif

至此,Vue3.0 diff算法结束。

代码:Vue next diff

小结

无论是React Diff还是Vue两个版本的Diff算法,它们所围绕的问题是当Virtual DOM发生改变后,如何利用新旧两个VirtualDOM列表之间的关系,产生一条修改DOM节点的最小路径(操作DOM次数最小的)。别忘了DOM操作才是最贵的,JS内存对象操作相对开销不会很大。