Vue设计与实现:双端 Diff 算法

69 阅读6分钟

简单、双端diff算法比较

简单diff算法通过双遍历需要移动两次才能完成更新 image.png 双端diff算法只需要移动一次就能完成更新 image.png

双端比较的原理

双端 Diff 算法是一种同时对新旧两组子节点的两个端点进行比较的算法,四个索引值分别指向新旧两组子节点的端点

image.png

双端比较的方式

const oldVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
    { type: 'p', children: '4', key: 4 }
  ]
}

const newVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '4', key: 4 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '3', key: 3 },
  ]
}

image.png 每一轮比较都分为四个步骤

  1. 旧子节点第一个子节点跟新子节点第一个子节点,比较key是否相同(旧新前)
  2. 旧子节点最后一个子节点跟新子节点最后一个子节点,比较key是否相同(旧新后)
  3. 旧子节点第一个子节点跟新子节点最后一个子节点,比较key是否相同(旧前新后)
  4. 旧子节点最后一个子节点跟新子节点第一个子节点,比较key是否相同(旧后新前)

旧后新前

解决思路:

  1. oldEndVNode.key === newStartVNode.key说明旧子节点最后一个子节点跟新子节点第一个子节点key相同
  2. 旧子节点最后一个子节点变成新子节点第一个子节点,可以将旧子节点最后一个子节点移动到旧子节点第一个节点
  3. 移动完更新oldEndIdx 和 newStartIdx指向下一个节点
function patchKeyedChildren(oldN, newN, container) {
    const oldChildren = oldN.children
    const newChildren = newN.children

    // 四个索引值
    let oldStartIdx = 0
    let oldEndIdx = oldChildren.length - 1
    let newStartIdx = 0
    let newEndIdx = newChildren.length - 1

    // 四个索引指向的 vnode 节点
    let oldStartVNode = oldChildren[oldStartIdx]
    let oldEndVNode = oldChildren[oldEndIdx]
    let newStartVNode = newChildren[newStartIdx]
    let newEndVNode = newChildren[newEndIdx]

    if (oldStartVNode.key === newStartVNode.key) {
      // 第一步:oldStartVNode 和 newStartVNode 比较
    } else if (oldEndVNode.key === newEndVNode.key) {
      // 第二步:oldEndVNode 和 newEndVNode 比较
    } else if (oldStartVNode.key === newEndVNode.key) {
      // 第三步:oldStartVNode 和 newEndVNode 比较
    } else if (oldEndVNode.key === newStartVNode.key) {
      // 第四步:oldEndVNode 和 newStartVNode 比较
      // 仍然需要调用 patch 函数进行打补丁
      patch(oldEndVNode, newStartVNode, container)
      // 移动 DOM 操作
      // oldEndVNode.el 移动到 oldStartVNode.el 前面
      insert(oldEndVNode.el, container, oldStartVNode.el)
      // 移动 DOM 完成后,更新索引值,并指向下一个位置
      oldEndVNode = oldChildren[--oldEndIdx]
      newStartVNode = newChildren[++newStartIdx]
    }
  }

image.png

结果:

image.png 更新后真实dom的节点

image.png

旧新后

解决思路:

  1. 由于在每一轮更新完成之后,都会更新四个索引中与当前更新轮次相关联的索引,所以整个 while 循环执行的条件是:头部索引值要小于等于尾部索引值
  2. 因为旧子节点在更新后还是在尾部,所以不需要改变真实dom的位置
  3. 只需要patch新旧尾部子节点,新旧尾部索引减一取到尾部节点的上个vnode
+   while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVNode.key === newStartVNode.key) {
        // 第一步:oldStartVNode 和 newStartVNode 比较
      } else if (oldEndVNode.key === newEndVNode.key) {
        // 第二步:oldEndVNode 和 newEndVNode 比较
+        // 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁
+        patch(oldEndVNode, newEndVNode, container)
+        // 更新索引和头尾部节点变量
+        oldEndVNode = oldChildren[--oldEndIdx]
+        newEndVNode = newChildren[--newEndIdx]
      } else if (oldStartVNode.key === newEndVNode.key) {
        // 第三步:oldStartVNode 和 newEndVNode 比较
      } else if (oldEndVNode.key === newStartVNode.key) {
        // 第四步:oldEndVNode 和 newStartVNode 比较
        // 仍然需要调用 patch 函数进行打补丁
        patch(oldEndVNode, newStartVNode, container)
        // 移动 DOM 操作
        // oldEndVNode.el 移动到 oldStartVNode.el 前面
        insert(oldEndVNode.el, container, oldStartVNode.el)
        // 移动 DOM 完成后,更新索引值,并指向下一个位置
        oldEndVNode = oldChildren[--oldEndIdx]
        newStartVNode = newChildren[++newStartIdx]
      }
    }
+  }

image.png

结果:

image.png 更新后真实dom的节点

image.png

旧前新后

解决思路:

  1. 先用patch比较新旧子节点
  2. 因为旧子节点是头部节点,新子节点是尾部节点,所以将旧头部子节点的真实dom移到旧尾部子节点真实dom的下个节点之前就变成尾部节点
  3. 最后更新相关索引到下一个位置
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVNode.key === newStartVNode.key) {
        // 第一步:oldStartVNode 和 newStartVNode 比较
        break
      } else if (oldEndVNode.key === newEndVNode.key) {
        // 第二步:oldEndVNode 和 newEndVNode 比较
        patch(oldEndVNode, newEndVNode, container)
        // 更新索引和头尾部节点变量
        oldEndVNode = oldChildren[--oldEndIdx]
        newEndVNode = newChildren[--newEndIdx]
        // 更新索引和头尾部节点变量
      } else if (oldStartVNode.key === newEndVNode.key) {
        // 第三步:oldStartVNode 和 newEndVNode 比较
+        patch(oldStartVNode, newEndVNode, container)
+        // 将旧的一组子节点的头部节点对应的真实 DOM 节点 oldStartVNode.el移动到旧的一组子节点的尾部节点对应的真实 DOM 节点后面
+        insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
+        // 更新相关索引到下一个位置
+        oldStartVNode = oldChildren[++oldStartIdx]
+        newEndVNode = newChildren[--newEndIdx]
      } else if (oldEndVNode.key === newStartVNode.key) {
        // 第四步:oldEndVNode 和 newStartVNode 比较
        // 仍然需要调用 patch 函数进行打补丁
        patch(oldEndVNode, newStartVNode, container)
        // 移动 DOM 操作
        // oldEndVNode.el 移动到 oldStartVNode.el 前面
        insert(oldEndVNode.el, container, oldStartVNode.el)
        // 移动 DOM 完成后,更新索引值,并指向下一个位置
        oldEndVNode = oldChildren[--oldEndIdx]
        newStartVNode = newChildren[++newStartIdx]
      }
    }

image.png

结果:

image.png 更新后真实dom的节点 image.png

旧新前

解决思路:

旧子节点跟新子节点都是头部节点,不需要移动位置只需要patch比较新旧子节点

 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 第一步:oldStartVNode 和 newStartVNode 比较
      if (oldStartVNode.key === newStartVNode.key) {
+        // 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁
+        patch(oldStartVNode, newStartVNode, container)
+        // 更新相关索引,指向下一个位置
+        oldStartVNode = oldChildren[++oldStartIdx]
+        newStartVNode = newChildren[++newStartIdx]
      } else if (oldEndVNode.key === newEndVNode.key) {
        // 第二步:oldEndVNode 和 newEndVNode 比较
        patch(oldEndVNode, newEndVNode, container)
        // 更新索引和头尾部节点变量
        oldEndVNode = oldChildren[--oldEndIdx]
        newEndVNode = newChildren[--newEndIdx]
        // 更新索引和头尾部节点变量
      } else if (oldStartVNode.key === newEndVNode.key) {
        // 第三步:oldStartVNode 和 newEndVNode 比较
        patch(oldStartVNode, newEndVNode, container)
        // 将旧的一组子节点的头部节点对应的真实 DOM 节点 oldStartVNode.el移动到旧的一组子节点的尾部节点对应的真实 DOM 节点后面
        insert(oldStartVNode.el, container, newEndVNode.el.nextSibling)
        // 更新相关索引到下一个位置
        oldStartVNode = oldChildren[++oldStartIdx]
        newEndVNode = newChildren[--newEndIdx]
      } else if (oldEndVNode.key === newStartVNode.key) {
        // 第四步:oldEndVNode 和 newStartVNode 比较
        // 仍然需要调用 patch 函数进行打补丁
        patch(oldEndVNode, newStartVNode, container)
        // 移动 DOM 操作
        // oldEndVNode.el 移动到 oldStartVNode.el 前面
        insert(oldEndVNode.el, container, oldStartVNode.el)
        // 移动 DOM 完成后,更新索引值,并指向下一个位置
        oldEndVNode = oldChildren[--oldEndIdx]
        newStartVNode = newChildren[++newStartIdx]
      }
    }

image.png

结果:

image.png 更新后真实dom的节点 image.png

非理想状况的处理方式

非理想状况无法命中四个步骤中的任何一步

可复用的节点不是头尾部节点

const oldVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
    { type: 'p', children: '4', key: 4 }
  ]
}

const newVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '4', key: 4 },
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '3', key: 3 },
  ]
}

解决思路:

  1. 既然两个头部和两个尾部的四个节点中都没有可复用的节点,那么看看非头部、非尾部的节点能否复用
  2. 因为移动的是旧子节点所以在旧子节点中找到跟新头部节点是否有相同key的子节点索引
  3. 如果idxInOld大于0就说明有可复用得节点,先patch比较一下有相同key的新旧子节点
  4. 因为移动后的新子节点是头部节点,所以将需要移动的旧子节点移动到旧子节点的头部节点
  5. 因为移动的旧子节点不是头尾部节点,所以不需要更新oldStartIdx、oldEndIdx、oldStartVNode、oldEndVNode,将移动的子节点设置为undefined
  6. 因为匹配到的是新子节点的头部节点,所以需要更新 newStartIdx 到下一个位置
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVNode.key === newStartVNode.key) {
      // 省略部分代码
    } else if (oldEndVNode.key === newEndVNode.key) {
      // 省略部分代码
    } else if (oldStartVNode.key === newEndVNode.key) {
      // 省略部分代码
    } else if (oldEndVNode.key === newStartVNode.key) {
      // 省略部分代码
+    } else {
+      // 遍历旧 children,试图寻找与 newStartVNode 拥有相同 key 值的元素
+      const idxInOld = oldChildren.findIndex(
+        node => node.key === newStartVNode.key
+      )
+      // idxInOld 大于 0,说明找到了可复用的节点,并且需要将其对应的真实DOM 移动到头部
+      if (idxInOld > 0) {
+        // idxInOld 位置对应的 vnode 就是需要移动的节点
+        const vnodeToMove = oldChildren[idxInOld]
+        // 不要忘记除移动操作外还应该打补丁
+        patch(vnodeToMove, newStartVNode, container)
+        // 将 vnodeToMove.el 移动到头部节点 oldStartVNode.el 之前,因此使用后者作为锚点
+        insert(vnodeToMove.el, container, oldStartVNode.el)
+        // 由于位置 idxInOld 处的节点所对应的真实 DOM 已经移动到了别处,因此将其设置为 undefined
+        oldChildren[idxInOld] = undefined
+        // 最后更新 newStartIdx 到下一个位置
+        newStartVNode = newChildren[++newStartIdx]
+      }
+    }
  }

image.png

结果:

更新后真实dom的节点 image.png

头部节点是 undefined

上面的处理使不是旧头尾节点却需要移动的子节点设置成undefine,在后续遍历中会报错,因为是处理过的节点,所以直接跳过该节点 image.png

解决思路:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
+  // 增加两个判断分支,如果头尾部节点为 undefined,则说明该节点已经被处理过了,直接跳到下一个位置
+  if (!oldStartVNode) {
+    oldStartVNode = oldChildren[++oldStartIdx]
+  } else if (!oldEndVNode) {
+    oldEndVNode = oldChildren[--oldEndIdx]
+  } else if (oldStartVNode.key === newStartVNode.key) {
      // 省略部分代码
    } else if (oldEndVNode.key === newEndVNode.key) {
      // 省略部分代码
    } else if (oldStartVNode.key === newEndVNode.key) {
      // 省略部分代码
    } else if (oldEndVNode.key === newStartVNode.key) {
      // 省略部分代码
    } else {
      const idxInOld = oldChildren.findIndex(
        node => node.key === newStartVNode.key
      )
      if (idxInOld > 0) {
        const vnodeToMove = oldChildren[idxInOld]
        patch(vnodeToMove, newStartVNode, container)
        insert(vnodeToMove.el, container, oldStartVNode.el)
        oldChildren[idxInOld] = undefined
        newStartVNode = newChildren[++newStartIdx]
      }

    }
  }

image.png

结果:

更新后真实dom的节点 image.png

添加新元素

未中四步骤

新头部子节点在旧子节点中未找到具有相同key值的节点,说明该新子节点是新增节点

const oldVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
  ]
}

const newVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '4', key: 4 },
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '3', key: 3 },
    { type: 'p', children: '2', key: 2 },
  ]
}

解决思路:

  1. 因为新节点头部节点在旧子节点中没有,并且又是头部节点不需要移动位置
  2. 只需要用patch,创建newStartVNode对应的真实dom,插到oldStartVNode得真实dom前面
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 增加两个判断分支,如果头尾部节点为 undefined,则说明该节点已经被处理过了,直接跳到下一个位置
    if (!oldStartVNode) {
      oldStartVNode = oldChildren[++oldStartIdx]
    } else if (!oldEndVNode) {
      oldEndVNode = newChildren[--oldEndIdx]
    } else if (oldStartVNode.key === newStartVNode.key) {
      // 省略部分代码
    } else if (oldEndVNode.key === newEndVNode.key) {
      // 省略部分代码
    } else if (oldStartVNode.key === newEndVNode.key) {
      // 省略部分代码
    } else if (oldEndVNode.key === newStartVNode.key) {
      // 省略部分代码
    } else {
      const idxInOld = oldChildren.findIndex(
        node => node.key === newStartVNode.key
      )
      if (idxInOld > 0) {
        const vnodeToMove = oldChildren[idxInOld]
        patch(vnodeToMove, newStartVNode, container)
        insert(vnodeToMove.el, container, oldStartVNode.el)
        oldChildren[idxInOld] = undefined
-      newStartVNode = newChildren[++newStartIdx]
+      } else {
+        // 将 newStartVNode 作为新节点挂载到头部,使用当前头部节点oldStartVNode.el 作为锚点
+        patch(null, newStartVNode, container, oldStartVNode.el)
+      }
+      newStartVNode = newChildren[++newStartIdx]
    }
  }

image.png

结果:

image.png 更新后真实dom的节点

image.png

命中四步骤

新增新子节点命中四步骤,遍历到最后会被遗漏

const oldVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
  ]
}

const newVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '4', key: 4 },
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
  ]
}

image.png

解决思路:

  1. 当oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx说明旧子节点已经遍历完新子节点还没遍历完,并且是新增子节点,所以用newChildren的子节点
  2. 因为新子节点还没遍历完,所以继续从最新的newStartIdx开始遍历到newEndIdx
  3. 如果新增是新尾部子节点,newStartIdx会超过oldChildren的长度,oldnStartVNode为undefined,所以判断newChildren[newEndIdx + 1]是否为null
  4. 如果是null就说明是新尾部子节点,就向patch第四个参数传递,一直传递到insert,insert的anchor参数为null时就会将节点插入尾部
  5. 如果不是null就说明不是尾部节点,以newChildren[newEndIdx + 1].el为锚点插入新增子节点
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 省略部分代码
  }

+  // 循环结束后检查索引值的情况,
+  if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
+    // 如果满足条件,则说明有新的节点遗留,需要挂载它们
+    for (let i = newStartIdx; i <= newEndIdx; i++) {
+      const ele = newChildren[newEndIdx + 1] == null ? null : newChildren[newEndIdx + 1].el;
+      patch(null, newChildren[i], container, ele)
+    }
+  }

结果:

头部子节点 image.png 尾部子节点

image.png

移除不存在的元素

const oldVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
  ]
}

const newVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '3', key: 3 },
  ]
}

image.png

image.png

image.png

解决思路:

  1. 因为新子节点比旧子节点少,所以会导致比对之后newEndIdx < newStartIdx,但是oldStartIdx <= oldEndIdx
  2. 在新子节点遍历完的情况下,旧子节点oldStartIdx到oldEndIdx都算要移除的节点
  3. 遍历oldStartIdx到oldEndIdx的子节点,使用unmount来卸载oldChildren[i]对应的节点
 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 省略部分代码03 }

    if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
      // 添加新节点
      // 省略部分代码
+    } else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
+      // 移除操作
+      for (let i = oldStartIdx; i <= oldEndIdx; i++) {
+        unmount(oldChildren[i])
+      }
+    }
  }

image.png

结果:

image.png