站在巨人的肩膀上看vue3-第10章 双端Diff算法

916 阅读6分钟

站在巨人的肩膀上看vue,来自霍春阳的vue设计与实现。作者以问题的形式一步步解读vue3底层的实现。思路非常的巧妙,这里会将书中的主要逻辑进行串联,也是自己读后的记录,希望通过这种形式可以和大家一起交流学习。

开篇

双端Diff算法指的是,在新旧两组节点的四个端点之间分别进行比较,并试图找到可复用的节点,相比简单Diff算法,双端Diff算法的优势在于,对于同样的更新场景,执行的DOM移动操作数会更少。

双端算法主要需要比较四次,先将新头旧头、新尾旧尾进行对比,然后新尾和旧头、新头和旧尾对比。这里就出现几种场景:

当新旧节点一样时。新头旧头、新尾旧尾相同不需要移动,只需要修改节点;当新尾和旧头相同,则需要将旧头移到到尾部,然后更新新尾和旧头的位置。同理,新头和旧尾相同,只需要将旧尾移动到前面节点,再更新位置。

当然这是最理想的状态,当第一轮四次比较都没有找到相同的节点时,这时候取新节点第一个在旧节点中移动位置,如果没有找到表明这是一个新增的节点,添加到旧节点顶部。然后在重新定义四个端点的位置。

最后一种情况可能在比较的过程中,新旧节点依然还有节点没有处理,这就需要新增或者卸载了,如果新节点多余表明需要新增,反之旧节点多余表明需要卸载。

最后这就是一个双端Diff算法的整个过程。

10.1、双端比较的原理

简单的diff算法移动操作并不是最优的,比如

新节点        旧节点
n-3          o-1
n-1   --->   o-2
n-2          o-3

在简单diff算法中更新会发生两次DOM操作,遍历新节点寻找旧节点可复用的节点:首先把o-3当作头节点,移动o-1到节点o-3下面,最后遍历到n-2把o-2移动到之前的o-1下面。

显然这并不是最优的,我们只需要将o-3移动到第一个节点就行。

双端Diff算法:同时对新旧两组节点的两个端点进行比较需要四个索引值,分别指向新旧两组节点的端点。

function patchKeyedChildren(oldChildren, newChildren, container) {
  // 四个索引
  let newStartIdx = 0
  let newEndIdx = newChildren.length - 1
  let oldStartIdx = 0
  let oldEndIdx = oldChildren.length - 1
  // 四个索引指向的vnode节点
  let newStartVNode = newChildren[newStartIdx]
  let newEndVNode = newChildren[newEndIdx]
  let oldStartVNode = oldChildren[oldStartIdx]
  let oldEndVNode = oldChildren[oldEndIdx]
}

比较过程分为四步:

  1. 新节点第一个和旧节点第一个比较
  2. 新节点最后一个和旧节点最后一个比较
  3. 新节点最后一个和旧节点第一个比较
  4. 新节点第一个和旧节点最后一个比较

节点相同的场景也因此分为四种:

第1种情况:新节点第一个和旧节点第一个相同

两者都是头部节点,不需要移动只需要打补丁更新内容

if (newStartVNode.key === oldStartVNode.key) {
  // 第一步
	patch(oldStartVNode, newStartVNode, container) // 补丁修改不同
  newStartVNode = newChildren[++newStartIdx] // 更新索引值
  oldStartVNode = oldChildren[++oldStartIdx]
} else if (newEndVNode.key === oldEndVNode.key) {
  // 第二步
} else if (newEndVNode.key === oldStartVNode.key) {
  // 第三步
} else if (newStartVNode.key = oldEndVNode.key) {
  // 第四步
}

第2种情况:新节点最后一个和旧节点最后一个相同

两者都处于尾部,因此不需要移动,只需要打补丁

if (newStartVNode.key === oldStartVNode.key) {
  // 第一步
} else if (newEndVNode.key === oldEndVNode.key) {
  // 第二步
  patch(oldEndVNode, newEndVNode, container) // 补丁修改不同
  newEndVNode = newChildren[--newEndIdx] // 更新索引值
  oldEndVNode = oldChildren[--oldEndIdx]
} else if (newEndVNode.key === oldStartVNode.key) {
  // 第三步
} else if (newStartVNode.key = oldEndVNode.key) {
  // 第四步
}

第3种情况:新节点最后一个和旧节点第一个相同

需要将旧的第一个节点移动到尾部,更新对应的索引位置

if (newStartVNode.key === oldStartVNode.key) {
  // 第一步
} else if (newEndVNode.key === oldEndVNode.key) {
  // 第二步
} else if (newEndVNode.key === oldStartVNode.key) {
  // 第三步
	patch(oldStartVNode, newEndVNode, container) // 补丁修改不同
  insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling) // 插入到最后一个节点兄弟节点的前面
  newStartVNode = newChildren[++newStartIdx] // // 更新索引值
  oldEndVNode = oldChildren[--oldEndIdx]
} else if (newStartVNode.key = oldEndVNode.key) {
  // 第四步
}

第4种情况:新节点第一个和旧节点最后一个相同

只需要通过DOM移动操作完成更新,将索引值为oldEndIdx 指向的虚拟节点所对应的真实的DOM移动到索引oldStartIdx 指向的虚拟节点所对应的真实的DOM前面(最后一个移动到最前面)

if (newStartVNode.key === oldStartVNode.key) {
  // 第一步
} else if (newEndVNode.key === oldEndVNode.key) {
  // 第二步
} else if (newEndVNode.key === oldStartVNode.key) {
  // 第三步
} else if (newStartVNode.key = oldEndVNode.key) {
  // 第四步
  patch(oldEndVNode, newStartVNode, container) // 补丁修改不同
  insert(oldEndVNode.el, container, oldStartVNode.el) // 移动dom到旧节点第一个前面
  newStartVNode = newChildren[++newStartIdx] // 更新索引值,指向下一个节点
  oldEndVNode = oldChildren[--oldEndIdx]
}

10.2-10.3、双端比较的优势和非理想状态处理方式

采用简单Diff算法移动元素位置如开头描述一样,会有很多不必要的操作,而使用双端diff算法可以对其进行优化。

非理想的状态1:在经过四次对比都找不到相同节点该怎么处理?

解决方法:

  1. 拿新节点的第一个去旧节点中寻找
  2. 将找到的旧节点的索引存储到变量idxInOld中
  3. 将该节点放到旧节点的第一个位置上
if (newStartVNode.key === oldStartVNode.key) {
  // 第一步
} else if (newEndVNode.key === oldEndVNode.key) {
  // 第二步
} else if (newEndVNode.key === oldStartVNode.key) {
  // 第三步
} else if (newStartVNode.key = oldEndVNode.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) // 移动dom到旧节点第一个前面
    oldChildren[idxInOld] = undefined // 将旧节点设置为undefined
    newStartVNode = newChildren[++newStartIdx] // 更新索引值,指向下一个节点
  }
}

当新节点第一个元素在旧节点中找到的话移动设置为undefined,这时下次遍历的时候也需要处理一下:

如果头尾部节点为undefined,说明该节点已经被处理过了,直接跳到下一个位置。

if (!oldStartVNode) {
  oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
  oldEndVNode = newChildren[--oldEndIdx]
}

10.4、添加新元素

非理想状态2: 新节点第一个元素在旧节点中找不到

说明这是一个新增的节点,只需要将它挂载到当前头部节点之前即可

// 特殊情况
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) // 移动dom到旧节点第一个前面
  oldChildren[idxInOld] = undefined // 将旧节点设置为undefined
} else {
  patch(null, newStartVNode, container, oldStartVNode.el)
}
newStartVNode = newChildren[++newStartIdx]

非理想状态3: 更新过程中,新节点比旧节点多,会遗漏节点

当旧节点所有的节点更新完之后,oldStartIdx 大于 oldEndIdx值,上面的while循环会停止更新,但是新节点还有数据则需要增加

if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
  // 新的节点添加
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    patch(null, newChildren[i], container, oldStartVNode.el)
  }
}

10.5、移除不存在的元素

同理,当新节点所有的节点更新完之后,newStartIdx 大于 newEndIdx值,上面的while循环会停止更新,但是旧节点还有数据则需要移除

if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
    // 新的节点添加
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      patch(null, newChildren[i], container, oldStartVNode.el)
    }
  } else if (newStartIdx > newEndIdx && oldStartIdx <= oldEndIdx) {
    // 移除操作
    for (let i = oldStartIdx; i < oldEndIdx; i++) {
      unmount(oldChildren[i])
    }
  }