虚拟 DOM 和 diff 算法 -04

583 阅读6分钟

本文是关于虚拟 DOM 和 diff 算法的学习笔记,目的在于更好的理解 Vue 的底层原理,篇幅较长,故而拆分为几篇,此篇为完结篇。
之前内容传送门:
《虚拟 DOM 和 diff 算法 -01》
《虚拟 DOM 和 diff 算法 -02》
《虚拟 DOM 和 diff 算法 -03》

承接上篇,当遇到新旧节点均为 children 属性有值的情况,继续分析四种命中查找的后两种:

3. 新后与旧前

yuque_diagram.jpg
先判断新前与旧前,不命中;再判断新后与旧后,不命中;再判断新后与旧前,发现命中,先进行 patchVnode 处理,再移动节点,把旧前指向的节点移动到旧后指向的节点的后面,然后旧前指针下移一位改变所指的节点,新后指针上移一位改变所指的节点:

yuque_diagram (1).jpg
此时又是新后与旧前命中,则新后继续上移,旧前继续下移:

yuque_diagram (2).jpg
以此类推,最终跳出 while 循环 。

// 3. 新后与旧前
if (sameVnode(oldStartVnode, newEndVnode)) {
  patchVnode(oldStartVnode, newEndVnode)
  if(newEndVnode) newEndVnode.elm = oldStartVnode?.elm
  // 把旧前指向的节点移动到旧后指向的节点的后面
  parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
  oldStartVnode = oldCh[++oldStartIdx]
  newEndVnode = newCh[--newEndIdx]
}

注意这里用的是 insertBefore 而不是 appendChild,因为旧后指针指向的节点不一定是 parentElm 的最后一位(比如之前的操作已经有节点被移动到后面来了)。

4. 新前与旧后

yuque_diagram (3).jpg
依次经过新前与旧前、新后与旧后、新后与旧前的判断都没命中,在新前与旧后命中,先进行 patchVnode 处理,再移动节点,将旧后指向的节点移动到旧前的前面,然后旧后指针上移一位,新前指针下移一位:

yuque_diagram (4).jpg
接下去就是新前与旧前命中了。

// 新前与旧后
if (sameVnode(oldEndVnode, newStartVnode)) {
  patchVnode(oldEndVnode, newStartVnode)
  if(newStartVnode) newStartVnode.elm = oldEndVnode?.elm
  // 将旧后指向的节点移动到旧前的前面
  parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
  oldEndVnode = oldCh[--oldEndIdx]
  newStartVnode = newCh[++newStartIdx]
}

四种命中之外的情况

我们需要循环遍历 oldVnode.children 中的旧前指针到旧后指针之前的节点,看看有没有 key 值与新前的 key 值是一样的(这里不考虑 sel 的值不一样的情况)。

// 创建一个 key 的映射对象,方便新节点在旧节点中寻找是否有相同的 key
const keyMap = {}
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
  const key = oldCh[i].key
  if (key) keyMap[key] = i
}
const idxInOld = keyMap[newStartVnode.key] // 在旧节点中寻找新前指向的节点

如果 idxInOld 有值

说明此时的新前指向的节点在旧节点中存在 key 值相同的节点,只需要移动该旧节点位置。 比如下面这种情况,一开始的这个新前 h('li', { key: 'B'}, 'BBB') 不满足四种命中任何一种,但是在旧节点中存在 sel 为 li,key 为 B 的节点。

yuque_diagram (5).jpg
在旧节点中找到后,用 elmToMove 变量保存它,再进行新旧同一节点的 patchVnode,然后要把处理过的旧节点赋值 undefined(因为一个节点不能同时位于文档的两个点中),最后用 insertBefore 移动 elmToMove:

const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
// 处理过的节点赋值为 undefined
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)

处理完就要移动新前指针,让新前指针指向的节点改为下一个 newStartVnode = newCh[++newStartIdx]

此时情况如下图,新前指针来到了 h('li', { key: 'A'}, 'AAA'),此时新前与旧前命中,则同时下移:

yuque_diagram (6).jpg
当旧前移动到 undefined 的位置,则跳过继续下移一位,所以 while 循环最开始的时候要先有相应判断,再去匹配四种命中:

if (oldStartVnode === undefined) { // 有可能已经是处理过的情况
  oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode === undefined) {
  oldEndVnode = oldCh[--oldEndIdx]
}

则接下去的情况如下图,剩余操作的图示省略

yuque_diagram.png

如果 idxInOld 没有值

说明是个新节点:parentElm.insertBefore(creatElement(newStartVnode), oldStartVnode.elm)

while 循环之后分析

while 循环结束的条件只有两种:

1. oldStartIdx > oldEndIdx

旧节点先处理完毕,说明新节点还有指针没指到并处理的节点,新节点有增加,比如下图这种情况:

yuque_diagram.jpg

2. newStartIdx > newEndIdx

新节点先处理完毕,说明新节点有删除,图略。

if (oldStartIdx > oldEndIdx) { // 新节点有增加
  // 这里不能用真实 DOM 才有的属性 nextSibling
  const before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    parentElm.insertBefore(creatElement(newCh[i]), before)
  }
} else { // 新节点有删除
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    parentElm.removeChild(oldCh[i].elm)
  }
}

注意,这里用到了 newCh[newEndIdx + 1].elm, 所以前面四种命中的情况要给新节点的 elm 赋上相应的值 新旧节点均为 children 的情况至此我们分析完了,流程图继如下:

yuque_diagram (1).jpg
抽离成 updateChildren 函数,文件如下:

// updateChildren.js
import patchVnode from './patchVnode.js'
import creatElement from './creatElement.js'

// 判断两个虚拟节点是否为同一节点
function sameVnode(vnode1, vnode2) {
  return vnode1.sel === vnode2.sel && vnode1.key === vnode2.key
}

export default (parentElm, oldCh, newCh) => {
  let oldStartIdx = 0 // 旧前指针
  let oldEndIdx = oldCh.length - 1 // 旧后指针
  let newStartIdx = 0 // 新前指针
  let newEndIdx = newCh.length - 1 // 新后指针
  
  let oldStartVnode = oldCh[0] // 初始时旧前指针指向的虚拟节点
  let oldEndVnode = oldCh[oldEndIdx] // 初始时旧后指针指向的虚拟节点
  let newStartVnode = newCh[0] // 初始时新前指针指向的虚拟节点
  let newEndVnode = newCh[newEndIdx] // 初始时新后指针指向的虚拟节点
  
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
    if (oldStartVnode === undefined) { // 有可能已经是处理过的情况
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldEndVnode === undefined) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 1. 新前与旧前
      patchVnode(oldStartVnode, newStartVnode)
      if(newStartVnode) newStartVnode.elm = oldStartVnode?.elm
      oldStartVnode = oldCh[++oldStartIdx] 
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 2. 新后与旧后
      patchVnode(oldEndVnode, newEndVnode)
      if(newEndVnode) newEndVnode.elm = oldEndVnode?.elm
      console.log(oldEndVnode.elm, newEndVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      // 3. 新后与旧前
      patchVnode(oldStartVnode, newEndVnode)
      if(newEndVnode) newEndVnode.elm = oldStartVnode?.elm
      // 把旧前指向的节点移动到旧后指向的节点的后面
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      // 新前与旧后
      patchVnode(oldEndVnode, newStartVnode)
      if(newStartVnode) newStartVnode.elm = oldEndVnode?.elm
      // 将旧后指向的节点移动到旧前的前面
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 四种命中都没有成功
      // 创建一个 key 的映射对象,方便新节点在旧节点中寻找是否有相同的 key
      const keyMap = {}
      for (let i = oldStartIdx; i <= oldEndIdx; i++) {
        const key = oldCh[i]?.key
        if (key) keyMap[key] = i
      }
      const idxInOld = keyMap[newStartVnode.key] // 在旧节点中寻找新前指向的节点
      if (idxInOld) {
        // 如果有,说明该节点在旧节点中存在,只需要移动节点位置
        const elmToMove = oldCh[idxInOld]
        patchVnode(elmToMove, newStartVnode)
        // 处理过的节点赋值为 undefined
        oldCh[idxInOld] = undefined
        parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
      } else {
        // 如果没有,说明是个新节点
        parentElm.insertBefore(creatElement(newStartVnode), oldStartVnode.elm)
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
  /**
   * while 循环结束的条件只有两种
   * 1. oldStartIdx > oldEndIdx 
   *    旧节点先处理完毕,说明新节点还有指针没指到并处理的节点,新节点有增加
   * 2. newStartIdx > newEndIdx 
   *    新节点先处理完毕,说明新节点有删除
   */
  if (oldStartIdx > oldEndIdx) { // 新节点有增加
    // 这里不能用真实 DOM 才有的属性 nextSibling
    const before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null
    console.log(newCh[newEndIdx + 1],newCh[newEndIdx + 1].elm)
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      parentElm.insertBefore(creatElement(newCh[i]), before)
    }
  } else { // 新节点有删除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      parentElm.removeChild(oldCh[i].elm)
    }
  }
}

感谢.gif

点赞.png