Vue 原理之图解 Diff 流程

1,886 阅读9分钟

写在前面

本文衔接上篇 Vue 原理之从新建实例到 Diff ,对 Vue Diff 算法进行深入解析。

通过三个典型的例子,来理解 Diff 算法对不同情况的处理流程(多图预警)。最后结合源码进行疑点分析。

Diff 算法基本逻辑

作用:找到最小差异部分的 DOM 进行更新,减少 DOM 重绘重排。

比较对象:新旧节点中父节点是相同节点的那一层 子节点

核心:在父节点相同的前提下,找到相同节点进行复用。

比较的过程中,不会对新旧 vnode 进行修改,而是以比较的结果直接修改真实 DOM。

处理流程 算法中使用新旧各两个索引、两个 vnode 节点作为参数进行循环比较,其中:

  • 每次比较后,对索引进行加减移动,并对 vnode 节点进行更新赋值:
    • 头部索引后移
      • oldStartVnode = oldCh[++oldStartIdx]
      • newStartVnode = newCh[++newStartIdx]
    • 尾部索引前移
      • oldEndVnode = oldCh[--oldEndIdx]
      • newEndVnode = newCh[--newEndIdx]
  • 仍然使用sameVnode方法判断新旧节点是否相同,同则进行patchVnode递归比较,直至最后执行到文本节点或空节点结束递归(patch 流程
  • 关于新节点与真实 DOM 的关联:
    • patchVnode时,vnode.elm = oldVnode.elm
    • 新增新节点时,createElm: vnode.elm = nodeOps.create...

以上为基本的理论基础,接下来通过示例作具体描述。

示例图解

从上文中 Diff 流程可得知,Diff 算法中提取出了新旧子节点进行比较的四种特殊情况,本节示例以此为基础,图解不同情况的处理流程。

图中省略了索引的走向,以四个节点参数作为代表:

  • 旧头节点oldStartVnode、尾节点oldEndVnode
  • 新头节点newStartVnode、尾节点newEndVnode

例 1. 旧头=新头、旧尾=新尾

本例中新旧的头部节点相同,尾部节点相同。处理过程:

  1. 第一轮循环,判断得知 oldStartVnode (1) 与 newStartVnode (1) 相同
    • 将 newVnode-1 的 elm 指向 DOM-1,并以 newVnode-1 内容更新 DOM-1
    • 然后 oldStartVnode 与 newStartVnode 赋值为子节点数组中的下一节点(2;2)
  2. 第二轮循环,同第一轮,oldStartVnode (2) 与 newStartVnode (2) 相同
    • 将 newVnode-2 的 elm 指向 DOM-2,并以 newVnode-2 内容更新 DOM-2
    • oldStartVnode 与 newStartVnode 指向子节点数组中的下一节点(3;5)
  3. 第三轮循环,判断得 oldStartVnode(3) 与 newStartVnode(5) 不同,但 oldEndVnode (4) 与 newEndVnode (4) 相同
    • 将 newVnode-4 的 elm 指向 DOM-4,并以 newVnode-4 内容更新 DOM-4
    • oldEndVnode 与 newEndVnode 指向子节点数组中的上一节点(3;3)
  4. 第四轮循环,同第三轮,oldEndVnode (3) 与 newEndVnode (3) 相同
    • 将 newVnode-3 的 elm 指向 DOM-3,并以 newVnode-3 内容更新 DOM-3
    • oldEndVnode 与 newEndVnode 指向子节点数组中的上一节点(2;5)
  5. 此时,旧子节点数组的头部索引已经大于尾部索引,循环结束
    • 新增新子节点数组两索引之间的节点 newVnode-5

此过程中未对新旧节点数组进行遍历比较。

例 2. 旧头=新尾、旧尾=新头

本例中,最初旧头节点与新尾节点相同,旧尾节点与新头节点相同,但处理几轮后会发现情况发生了变化。过程:

  1. 第一轮循环,oldStartVnode (1) 与 newEndVnode (1) 相同
    • 将 newVnode-1 的 elm 指向 DOM-1,以 newVnode-1 内容更新 DOM-1
    • 移动 DOM-1
    • oldStartVnode 向后移动(2);newEndVnode 向前移动(2)
  2. 第二轮循环,同第一轮,oldStartVnode (2) 与 newEndVnode (2) 相同
    • 将 newVnode-2 的 elm 指向 DOM-2,以 newVnode-2 内容更新 DOM-2
    • 移动 DOM-2
    • oldStartVnode 向后移动(5);newEndVnode 向前移动(3)
  3. 第三轮循环,尾部节点 oldEndVnode (4) 与头部节点 newStartVnode (4) 相同
    • 将 newVnode-4 的 elm 指向 DOM-4,以 newVnode-4 内容更新 DOM-4
    • 移动 DOM-4
    • oldEndVnode 向前移动(3);newStartVnode 向后移动(3)
  4. 第四轮循环,此时,newStartVnode 与 newEndVnode 指向了同一位置,先匹配上的条件是 oldEndVnode (3) 与 newEndVnode (3) 相同
    • 将 newVnode-3 的 elm 指向 DOM-3,并以 newVnode-3 内容更新 DOM-3
    • oldEndVnode 与 newEndVnode 指向子节点数组中的上一节点(5;4)
  5. 此时,新子节点数组的头部索引已经大于尾部索引,循环结束
    • 删除旧子节点数组两索引之间的节点 oldVnode-5

此过程中也未对新旧节点数组进行遍历比较。

例 3. else 流程 > 查找相同节点

本例中,最初没有特殊情况可以匹配,但处理后又会发现情况有所变化。过程:

  1. 第一轮循环,无特殊情况,else 流程,查找与 newStartVnode (3) 相同的节点
    • 找到相同的旧节点 oldVnode-3
    • 将 newVnode-3 的 elm 指向 DOM-3,并以 newVnode-3 内容更新 DOM-3
    • oldCn[idxInOld]置为 undefined,移动 DOM-3
    • newStartVnode 向后移动(4)
  2. 第二轮循环,出现特殊情况,oldEndVnode (4) 与 newStartVnode (4) 相同
    • 将 newVnode-4 的 elm 指向 DOM-4,以 newVnode-4 内容更新 DOM-4
    • 移动 DOM-4
    • oldEndVnode 向前移动(3);newStartVnode 向后移动(5)
  3. 第三轮循环,图中省略了此轮,oldEndVnode 指向的节点为 undefined,所以继续将 oldEndVnode 向前移动(2)
  4. 第四轮循环,此时又有特殊情况,oldEndVnode (2) 与 newEndVnode (2) 相同
    • 将 newVnode-2 的 elm 指向 DOM-2,并以 newVnode-2 内容更新 DOM-2
    • oldEndVnode 与 newEndVnode 指向子节点数组中的上一节点(1;5)
  5. 第五轮循环,无特殊情况,查找与 newStartVnode (5) 相同的节点
    • 未找到相同的旧节点,新增此新子节点 newVnode-5
    • newStartVnode 向后移动(2)
  6. 此时,新子节点数组的头部索引已经大于尾部索引,循环结束
    • 删除旧子节点数组两索引之间的节点 oldVnode-1

此过程中,对旧子节点数组进行了两次匹配查找,但遍历次数可能只有一次。

疑点分析

上文示例主要介绍 DIff 算法的处理流程,其中处理的细节需结合源码进行疑点分析。

1. 如何将新节点内容更新至 DOM

if (sameVnode(oldStartVnode, newStartVnode)) {
  patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
  oldStartVnode = oldCh[++oldStartIdx];
  newStartVnode = newCh[++newStartIdx];
  // ...
}

使用sameVnode方法判断新旧节点相同后,继续通过patchVnode进一步间接递归比较:

  • 若新旧节点都有子节点,继续 updateChildren diff
  • 若是文本节点或空节点,更新 DOM 文本内容,结束处理

2. 如何移动 DOM

因为 patchVnode 方法中将 vnode 的 elm 属性指向其对应的 DOM 节点,所以使用 vnode.elm 进行 DOM 操作。

特殊情况下的移动
if (isUndef(oldStartVnode)) {
  // else if ....
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
  patchVnode(oldStartVnode, newEndVnode, /*...*/);
  // 在 oldEndVnode.elm 的下一节点前移动已存在的节点 oldStartVnode.elm
  nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
  oldStartVnode = oldCh[++oldStartIdx];
  newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
  patchVnode(oldEndVnode, newStartVnode, /*...*/);
  // 在 oldStartVnode.elm 前移动已存在的节点 oldEndVnode.elm 
  nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
  oldEndVnode = oldCh[--oldEndIdx];
  newStartVnode = newCh[++newStartIdx];
} else {
  // ...
}
// nodeOps.insertBefore
function insertBefore(parentNode, newNode, referenceNode) {
  // 原生 insertBefore,在指定的已有子节点之前插入新的子节点,或移动/移除已有的子节点。
  parentNode.insertBefore(newNode, referenceNode);
}

其中,nodeOps.insertBefore封装了原生 DOM 操作方法,nextSibling返回下一个兄弟节点

  • 旧头部节点 oldStartVnode 与新尾部节点 newEndVnode 相同
    • 对应的 DOM 节点需要向后(向右)移动至 oldEndVnode 节点后的位置
    • oldEndVnode 后若有节点,都已比较并更新
  • 旧尾部节点 oldEndVnode 与新头部节点 newStartVnode 相同
    • 对应的 DOM 节点需要向前(向左)移动至 oldStartVnode 节点前的位置
    • oldStartVnode 前若有节点,都已比较并更新
else 流程下的移动
if (isUndef(oldStartVnode)) {
  // else if ....
} else {
  // ...
  if (isUndef(idxInOld)) { // New element
    // ...
  } else {
    vnodeToMove = oldCh[idxInOld];
    if (sameVnode(vnodeToMove, newStartVnode)) {
      patchVnode(vnodeToMove, newStartVnode, /*...*/);
      oldCh[idxInOld] = undefined;
  		// 在 oldStartVnode.elm 前移动已存在的节点 vnodeToMove.elm 
      nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
    } else {
      // same key but different element. treat as new element
      // createElm...
    }
  }
  newStartVnode = newCh[++newStartIdx];
}

同理,因为以 newStartVnode 作为比较对象,所以将vnodeToMove.elm移至oldStartVnode.elm前。

3. else 流程如何查找相同节点

if (isUndef(oldStartVnode)) {
  // else if ....
} else {
  if (isUndef(oldKeyToIdx)) {
    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
  }
  idxInOld = isDef(newStartVnode.key)
    ? oldKeyToIdx[newStartVnode.key]
    : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
  // ...
  newStartVnode = newCh[++newStartIdx];
}

其中,oldKeyToIdx存储通过createKeyToOldIdx方法转换的旧子节点数组节点key与索引index的map表 {key: index}。oldKeyToIdx 只赋值一次,此为 else 流程中对旧子节点数组的一次遍历。

如此以对象索引查找替代数组遍历,

  • 可快速通过新子节点的 key 查找到与其 key 相同的旧子节点
  • 此流程只查找到 key 相同的节点,后续还需进行sameVNode比较

但如果新子节点没有定义 key 属性,则需通过findIdxInOld遍历旧子节点数组查找相同的节点。

4. 循环后新增或删除节点

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  // ...
}
if (oldStartIdx > oldEndIdx) {
  // 旧子节点数组先比较结束,新增新子节点数组中未比较的节点
  refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
  // 旧子节点数组先比较结束,删除旧子节点数组中未比较的节点
  removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}

for 循环遍历,进行新增或删除:

function addVnodes(parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {
  for (; startIdx <= endIdx; ++startIdx) {
    createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm, false, vnodes, startIdx);
  }
}
function removeVnodes(vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    var ch = vnodes[startIdx];
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch);
        invokeDestroyHook(ch);
      } else { // Text node
        removeNode(ch.elm);
      }
    }
  }
}

其中,新增流程中refElm获取的是 newEndIdx+1位即 newEndVnode 后一位的节点,在此位置前插入新的节点。newEndVnode 后若有节点,都已比较并更新。

小结

3个示例中:

  • 示例 1 没有对节点数组进行遍历,对已有的 DOM 节点只进行了内容更新
  • 示例 2 同样没有对节点数组进行遍历,对需保留的 DOM 节点移动三次后就可以只更新内容
  • 示例 3 从无特殊匹配的情况,逐渐向极限情况的处理靠近

可见 Diff 算法在比较两组子节点和减少 DOM 操作方面的巧妙性。

此外,以对象索引查找替代数组遍历,也是典型的减少数组遍历的方法;else 流程查找到相同的节点后,将旧子节点数组中此节点置为 undefined,方便后续循环时直接移动索引进行流转;使用 elm 属性将新旧节点 vnode 与 DOM 进行关联......都是很好的处理做法。

学到的东西会用才是自己的 🆙


2020中国年前的最后一篇博文终于码出来了😶

图解过程不知是否容易理解。文中如有疏漏,欢迎指出 🤝