Vue源码阅读(17):patch() 方法、diff 算法

 我的开源库:

虚拟 DOM 中最为核心的部分是 patch() 方法,通过该方法,Vue 可以将最新的 vnode 渲染到页面上,实现组件的重新渲染。

patch() 方法在重新渲染组件的时候,并不会使用暴力覆盖的方法,而是细心的比较新老 vnode 之间的差异,只对有差异的地方进行真实 DOM 的更新操作。这样,就可以极大的减少操作真实 DOM 的次数,提高性能。patch() 方法中使用的算法就是广为人知的 diff 算法。

diff 算法主要有四块内容,分别是:

  • 创建节点
  • 删除节点
  • 更新节点
  • 更新子节点

接下来的内容将会以上面四块进行详细分析。

patch() 方法的功能就是比较 vnode 和 oldVnode 之间的差异,然后以 vnode 为基准更新页面。

1,创建节点

 1-1,什么时候创建节点

如果某个节点在 vnode 中存在,而在 oldVnode 中不存在的话,此时则需要创建对应的节点。这句话理解起来很容易,因为页面的渲染是以 vnode 为基准进行渲染的,所以 vnode 中的某个节点在 oldVnode 中不存在的话,自然需要新创建该节点。

然后,说下创建节点常见的两个场景。

第一个是组件首次渲染的时候,此时 oldVnode 不存在而 vnode 存在,此时就需要使用 vnode 创建出所有的节点,然后将他们显示到页面上去。

还有一个很常见的情形是,当 vnode 和 oldVnode 不是同一个节点时,此时需要使用 vnode 创建出对应的节点,用它去替换掉 oldVnode 对应的节点。在这里,除了 vnode 的创建操作,还有 oldVnode 的删除操作,例如下面的例子。

<div>
  <h1 v-if="isShow">Tom</h1>
  <h2 v-if="!isShow">Jack</h2>
</div>

当 isShow 变量从 true 变成 false 的时候,oldVnode 对应 h1 元素节点,vnode 对应 h2 元素节点,这两个节点根本不是同一个节点,此时就需要创建 h2 元素节点,并将其放到 <div> 元素的下面,然后删除掉 h1 元素节点。

1-2,如何确认创建节点的类型

会被创建并插入到 DOM 中的元素类型有三种,分别是:元素节点、注释节点和文本节点。

可以通过判断 vnode 是否具有 tag 属性来判断 vnode 是不是元素节点,如果 vnode 有 tag 属性的话,则可以断定该 vnode 是元素节点,此时调用当前环境下的 createElement() 方法创建该元素节点即可。

剩下的就是判断 vnode 是不是注释节点或文本节点。如果 vnode 没有 tag 属性的话,则可以初步判断该 vnode 是注释节点或者文本节点。我们在上一篇博客说过,注释节点中的 isComment 属性为 true,因此我们可以通过 isComment 属性来区分注释节点和文本节点,所以得出如下结论:

  • 如果一个 vnode 没有 tag 属性、isComment 属性为 true,则该 vnode 是注释节点。
  • 如果一个 vnode 没有 tag 属性、isComment 属性为 false,则该 vnode 是文本节点。

1-3,元素节点的子节点

如果创建节点的类型是元素节点的话,则它还有可能有子节点,因此需要检查一下 vnode.children 是否存在,如果存在的话,则遍历子节点数组,创建出对应的子节点,子节点的父节点就是当前创建出来的这个节点,所以创建出来的子节点需要插入到当前的节点,遍历创建执行完成之后,当前节点和其子节点也就全部创建完成了,并且子节点已经被插入到了当前节点的下面。

1-4,将当前节点插入到父节点中

当前节点创建完成之后,应该将创建好的节点插入到父节点中,插入到父节点只需要调用当前环境下的 appendChild() 方法即可,如果这个父节点已经被渲染到页面中了的话,则当前创建的节点(包括当前创建节点下面的子节点)也会被渲染到页面中。

2,删除节点

如果一个节点只在 oldVnode 中存在,则这个 vnode 对应的节点就应该从 DOM 中删除。

删除节点的源码很简单,如下所示:

function removeVnodes (vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      removeNode(ch.elm)
    }
  }
}

function removeNode (el) {
  const parent = nodeOps.parentNode(el)
  // element may have already been removed due to v-html / v-text
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}

const nodeOps = {
  removeChild(parent, child){
    parent.removeChild(child)
  }
}

removeVnodes() 方法能够移除 vnodes 数组中从 startIdx 到 endIdx 下标位置的节点,其内部实现也很简单,就是遍历 vnodes 数组 startIdx 到 endIdx,调用 removeNode(ch.elm),ch.elm 是 ch 节点对应的真实 DOM 的节点。

removeNode() 方法能够将指定的 DOM 节点从他的父节点中删除,内部实现是借助了 nodeOps.removeChild(parent, el),nodeOps 是对 DOM 操作的集合封装。

使用 nodeOps 的好处是:可以将不同平台对 DOM 的操作集合封装起来,在不同的平台调用其对应的 nodeOps,以此实现框架渲染机制和 DOM 操作的解耦。

3,更新节点

如果对比的两个节点 vnode 和 oldVnode 是同一节点的话,则需要进行节点的更新操作,这里的更新并不是简单的使用新节点覆盖旧节点,而是会对新旧两个节点进行细致的比较,然后针对不一样的地方进行更新。

更新节点在源码中对应的方法是 patchVnode,简要代码如下所示:

// 更新节点的方法
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  // 如果 oldVnode、vnode 是同一节点的话,则直接 return 即可,不同进行更新操作
  if (oldVnode === vnode) {
    return
  }

  // 获取对比 vnode 对应的真实的 DOM 节点
  const elm = vnode.elm = oldVnode.elm

  // 判断 oldVnode 和 vnode 是不是静态节点,如果是静态节点的话,直接 return
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance
    return
  }

  // 获取新旧 vnode 的 children
  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text)
  }
}

接下来,以 patchVnode 方法的源码为主线讲解更新节点的逻辑。

3-1,首先判断 vnode 和 oldVnode 是不是同一个节点

if (oldVnode === vnode) {
  return
}

如果 vnode 和 oldVnode 变量是对同一个 vnode 对象不同的引用的话,说明 vnode 和 oldVnode 是完全相同的,不需要进行更新操作,在这里,直接 return 即可。

3-2,判断 vnode 和 oldVnode 是不是静态节点

// 判断 oldVnode 和 vnode 是不是静态节点,如果是静态节点的话,直接 return
if (isTrue(vnode.isStatic) &&
  isTrue(oldVnode.isStatic) &&
  vnode.key === oldVnode.key &&
  (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
  vnode.componentInstance = oldVnode.componentInstance
  return
}

说下什么是静态节点。

静态节点是指那些渲染到页面上,即使状态发生改变,组件重新渲染,都不会发生任何改变的节点。

例如下面的静态节点。

<h1>我是静态节点,我没有使用任何的状态。</h1>

我们可以发现这个节点并没有使用任何的状态,所以无论状态怎么改变,这个节点都不会重新渲染。因此,如果 vnode 和 oldVnode 是静态节点的话,则直接 return,不需要更新该节点。

3-3,vnode 的 text 属性未定义

接下来,会进行如下的代码判断,如果代码执行进入 if 代码块中的话,说明 vnode 是元素节点或者 text 属性为空的文本节点

if (isUndef(vnode.text)) {
  ......
}

3-3-1,如果 vnode 和 oldVnode 是有子节点的元素节点的话

if (isUndef(vnode.text)) {
  if (isDef(oldCh) && isDef(ch)) {
    if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
  }
}

如果 vnode 和 oldVnode 都是元素节点,并且都有子节点的话,则会进行更新子节点的逻辑。更新子节点会在下面有专门的小节进行讲解,这里就不赘述了。

3-3-2,如果 vnode 和 oldVnode 都是元素节点,但只有 vnode 有子节点的话

if (isUndef(vnode.text)) {
  if (isDef(ch)) {
    addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  }
}

组件重新渲染的时候,应该以 vnode 为基准进行组件渲染,所以在这里,调用 addVnodes() 方法,将 vnode 的子节点创建出来,并插入到 elm 的下面。

3-3-3,如果 vnode 和 oldVnode 都是元素节点,但只有 oldVnode 有子节点的话

if (isUndef(vnode.text)) {
  if (isDef(oldCh)) {
    removeVnodes(elm, oldCh, 0, oldCh.length - 1)
  }
}

这种情况,将 elm 的子节点移除即可。

3-3-4,如果 vnode 和 oldVnode 都是文本节点,但只有 oldVnode.text 被定义的话

if (isUndef(vnode.text)) {
  if (isDef(oldVnode.text)) {
    nodeOps.setTextContent(elm, '')
  }
}

这种情况,将 elm 中的文本清空即可。

3-4,vnode.text 属性存在

if (isUndef(vnode.text)) {
  ......
} else if (oldVnode.text !== vnode.text) {
  nodeOps.setTextContent(elm, vnode.text)
}

如果 vnode.text 属性存在的话,说明 vnode 和 oldVnode 是文本节点。此时就有两种情况:

  • vnode 和 oldVnode 的 text 属性相同
  • vnode 和 oldVnode 的 text 属性不同

如果 vnode 和 oldVnode 的 text 属性相同的话,不需要做任何操作;

如果 vnode 和 oldVnode 的 text 属性不同的话,需要更新 elm 节点中的文本,我们可以看到,在上面的代码中,使用 nodeOps.setTextContent() 方法将 elm 中的文本更改为 vnode.text;

4,更新子节点

上一小节讨论了更新节点的逻辑,如果 vnode 和 oldVnode 都是元素节点,而且都有子节点,并且子节点不相等的情况下,就需要进行更新子节点的操作。

更新子节点大概有 4 中操作:新增节点、删除节点、更新节点、移动节点,下面讲讲什么时候进行这 4 种操作。

  • 新增节点:如果某个 vnode 在 newChildren(新子节点列表)中存在,在 oldChildren(旧子节点列表)中不存在的话,则需要新增节点。
  • 删除节点:如果某个 vnode 在 newChildren 中不存在,在 oldChildren 中存在的话,则需要删除节点。
  • 更新节点:如果某个 vnode 在 newChildren 和 oldChildren 中都存在的话,则需要进行更新节点的操作,更新节点就是上文第 3 小节的内容。
  • 移动节点:如果某个 vnode 在 newChildren 和 oldChildren 中都存在,但是位置不一样的话,此时说明节点的位置发生了变化,此时需要进行移动节点的操作。

在这里,说下更新子节点操作的主线逻辑,为下文的理解打下一个基础。更新子节点就是对比 oldChildren 和 newChildren 两个子节点列表,对比的方法是循环 newChildren(新子节点列表),每循环到一个新的子节点,就去 oldChildren(旧的子节点列表)中循环查找与其相同的那个旧节点。如果在 oldChildren 中找不到的话,则说明当前循环的新子节点是新增节点,此时需要进行创建新节点并插入到视图的操作。如果找到了与当前循环节点相同的那个旧节点,此时需要进行更新节点的操作。另外还需要说明的一点是,如果找到了与当前循环节点相同的那个旧节点,但是位置不一样的话,除了做更新操作,还需要对节点的位置进行移动。

接下来,开始对更新子节点的 4 种操作进行详细的论述。

4-1,新增节点

如果当前循环处理的新子节点在 oldChildren 中找不到对应的旧节点的话,说明这个新子节点是新增节点。此时需要执行的操作是创建出这个新子节点,然后将创建出来的真实节点插入到 oldChildren 中所有未处理节点对应的真实 DOM 节点的前面,当节点成功插入到 DOM 后,这一轮循环的操作也就结束了。

这里有两个问题需要着重解释一下。

  • oldChildren 中未处理的节点是什么?
  • 为什么要将创建的新节点插入到 oldChildren 中所有未处理节点对应的真实 DOM 节点的前面?插入到 oldChildren 中已处理节点对应的真实 DOM 节点的后面行不行?

首先说下 oldChildren 中未处理的节点是什么?上面说了,更新子节点的主线逻辑是循环新子节点列表,以当前循环的新子节点去旧子节点列表中寻找与之相同的旧子节点,然后进行处理。此时 oldChildren 中已经处理的节点就是已处理节点,还未处理到的节点就是未处理的节点。

接下来解释第二个问题,为什么需要插入到未处理节点的前面,这主要是为了解决有多个新增节点的情况,我们用图来解释该问题。

1,当新增节点只有一个时,插入到已处理节点的后面和插入未处理节点的前面效果是一样的。

 2,当新增节点有多个时,插入到 oldChildren 中已处理节点的后面就会出现问题。通过下图可以发现,新节点本应该排在第四位,却因为插入到已处理节点的后面而排在了第三位。如果插入到未处理节点 dom3 的前面则不会有任何问题。

 4-2,更新子节点

如果某个 vnode 在 newChildren 和 oldChildren 两个子节点列表中都存在,并且位置相同,这种情况需要更新该子节点,更新节点的内容在上面第 3 小节已经详细论述过了,这里就不过多赘述了。

 4-3,移动子节点

如果某个 vnode 在 newChildren 和 oldChildren 两个子节点列表中都存在,但是位置不相同,此时除了做更新子节点的操作外,还需要移动子节点的位置。

这里需要说明的一点是,这个节点需要移动到什么位置?通过上文可知,更新子节点就是在不断地循环 newChildren,所以当前处理节点的左面都是已经处理的节点,当前的节点是所有未处理节点的第一个。因此,将当前的节点移到 oldChildren 中所有未处理节点的最前面,就可以实现我们的目的。

4-4,删除子节点

删除子节点的基本逻辑是:如果某个子节点在 oldChildren 中存在,而在 newChildren 中不存在的话,该子节点就应该被删除。

通过上文可知,更新子节点的主线流程是循环 newChildren,对当前循环的新子节点到 oldChildren 中寻找对应的旧子节点,然后进行相应的处理。如果某个节点在 oldChildren 中存在,在 newChildren 中不存在,则这个节点在 newChildren 循环完成之后,一定会以未处理的状态存在于 oldChildren 中,所以最终的结论是:newChildren 循环完成之后,如果在 oldChildren 中还存在未处理节点的话,则这些节点对应的真实 DOM 节点就应该从页面中删除。

4-5,更新子节点的优化策略1:目标old节点的位置预测

通过上文我们得知:在 newChildren 中循环到某个节点时,就会到 oldChildren 旧子节点列表中循环查找对应的旧子节点。

这种循环旧子节点列表查找目标旧子节点的方案在性能上就不是最优的,存在优化的地方。Vue 优化的方案是先到 oldChildren 中预测的位置(列表下标)查看预测的旧子节点是不是当前要查找的目标旧子节点,如果是的话,直接进行更新节点的操作,如果预测位置的旧子节点不是要查找的目标节点的话,再用循环 oldChildren 的方式查找目标节点。我们可以发现,如果预测的位置是目标节点的话,就少做了一次循环 oldChildren 的操作,以此来提高性能。

因为在大部分的业务场景中,子节点的位置都是不变化的,所以就可以预测目标节点和当前节点是同一位置,这样就可以大大的减少循环 oldChildren 的操作。

Vue 有四种预测的方式,分别是:

  • 新前和旧前比较
  • 新后和旧后比较
  • 新后和旧前比较
  • 新前和旧后比较

先解释下上面四种预测方式中的 新前、新后、旧前、旧后 是什么意思。

  • 新前:newChildren 中所有未处理的第一个节点。
  • 新后:newChildren 中所有未处理的最后一个节点。
  • 旧前:oldChildren 中所有未处理的第一个节点。
  • 旧后:oldChildren 中所有未处理的最后一个节点。

文字的解释不太清晰,可以看下面的图,一看便知。

 这一优化策略对应的源码如下所示:

if (sameVnode(oldStartVnode, newStartVnode)) {
  patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
  oldStartVnode = oldCh[++oldStartIdx]
  newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
  patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
  oldEndVnode = oldCh[--oldEndIdx]
  newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
  patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
  canMove && 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, insertedVnodeQueue)
  canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
  oldEndVnode = oldCh[--oldEndIdx]
  newStartVnode = newCh[++newStartIdx]
}

接下来,就分别说说这四种预测方式。

4-5-1,新前和旧前比较(new1 和 old1 进行比较)

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

首先进行的是新前和旧前的比较,由于在大部分的业务场景中,vnode 和 oldVnode 的子节点位置都是不变的,所以在这一步的预测中,就有极大的概率命中目标节点,极大的提高了查找目标节点的效率。

如果新前和旧前是同一个节点的话,则需要进行新前和旧前的更新节点操作,节点更新完毕后,将新前和旧前重新赋值成节点列表中的下一个节点,这一轮的操作就完成了。

4-5-2,新后和旧后比较(new3 和 old3 进行比较)

if (sameVnode(oldEndVnode, newEndVnode)) {
  patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
  oldEndVnode = oldCh[--oldEndIdx]
  newEndVnode = newCh[--newEndIdx]
}

如果新前和旧前节点的比较是不一样的话,接下来则进行新后和旧后的比较。因为在某些场景中,用户可能将数组的前半部分数据移除掉,所以通过该数组渲染出来的 vnode,通过新前和旧前的比较是无法命中目标节点的,但是 newChildren 和 oldChildren 后面的部分是相同的。因此,新后和旧后的比较也有一定的概率命中,提高寻找目标旧子节点的效率。

如果新后和旧后是同一个节点的话,则需要进行新后和旧后的更新节点操作,节点更新完毕后,将新后和旧后重新赋值成节点列表中的上一个节点,这一轮的操作就完成了。

新前和旧前操作完成之后,需要后移一个节点;

新后和旧后操作完成之后,需要前移一个节点;

4-5-3,新后和旧前比较(new3 和 old1进行比较)

if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
  patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
  canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
  oldStartVnode = oldCh[++oldStartIdx]
  newEndVnode = newCh[--newEndIdx]
}

因为用户有可能对数组数据进行倒序的操作,所以创建出来的 vnode 通过新后和旧前的比较就有可能命中目标旧子节点,提高寻找目标旧子节点的效率。

如果新后和旧前是同一个节点的话,除了需要做更新节点的操作外,还需要将旧前节点移动到 oldChildren 中所有未处理节点的最后面。更新节点和移动节点的操作完成之后,将旧前后移一个节点,将新后前移一个节点,这一轮的操作接完成了。

4-5-4,新前和旧后比较(new1 和 old3 进行比较)

if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
  patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
  canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
  oldEndVnode = oldCh[--oldEndIdx]
  newStartVnode = newCh[++newStartIdx]
}

用户有可能将数组数据的前半部分移除掉,再进行倒序,所以创建出来的 vnode 通过新前和旧后的比较就有可能命中目标旧子节点,提高寻找目标旧子节点的效率。

如果新前和旧后是同一节点的话,首先做更新节点的操作,然后将节点移动到 oldChildren 中所有未处理节点的最前面。更新节点和移动节点的操作完成之后,将新前后移一个节点,将旧后前移一个节点,这一轮的操作就完成了。

4-6,更新子节点的优化策略2:key

两种优化策略的目的都是尽快的找到目标旧子节点,如果通过上一种优化策略没有找到目标旧子节点的话,Vue 还有第二种优化策略 — key。

key 优化策略要求用户在渲染子节点的时候为节点绑定唯一的 key 字符串,这个 key 类似于该子节点的 id,能够唯一标识该节点。

key 优化策略的底层原理是,Vue 会为旧子节点的 key 生成一个 map 对象,map 对象的键是旧子节点所绑定的 key,值是该旧子节点在 oldChildren 中的下标,例如如下的模板字符串。

<ul>
  <li key='0C3trgdH'>列表1</li>  
  <li key='5JKjGENP'>列表2</li>
  <li key='7jrHo4Ld'>列表3</li>
  <li key='81nBGMq7'>列表4</li>
  <li key='89aiicni'>列表5</li>
</ul>

对应的 map 对象如下所示。

let map = {
  '0C3trgdH': 0,
  '5JKjGENP': 1,
  '7jrHo4Ld': 2,
  '81nBGMq7': 3,
  '89aiicni': 4
}

生成了如上所示的 map 对象之后,如果想寻找某个新子节点所对应的旧子节点的话,就拿这个新子节点的 key 属性到 map 对象中获取对应旧子节点在 oldChildren 列表中的下标,对应旧子节点的下标获取到了,然后直接通过 oldChildren[oldIndex] 就可以获取到目标旧子节点,接下来进行节点的更新和移动操作就可以了。

在真实的 Vue 源码中,如果通过上面两种优化策略都无法寻找到目标旧子节点的话,才会进行循环 oldChildren 寻找目标节点的操作。

4-7,更新子节点真实的主线逻辑

我们上面说更新子节点的主线逻辑是循环 newChildren(新子节点列表),每循环到一个新的子节点,就去 oldChildren(旧的子节点列表)中循环查找与其相同的那个旧节点。如果没有上面两种优化策略的话,更新子节点的确如此,但由于上面两种优化策略的存在,源码中更新子节点的逻辑则大不一样,真实的源码如下所示(这里,我写了大量的注释,看注释即可理解)。

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // 标识 "旧前" 的下标
  let oldStartIdx = 0
  // 标识 "新前" 的下标
  let newStartIdx = 0
  // 标识 "旧后" 的下标
  let oldEndIdx = oldCh.length - 1
  // 标识 "新后" 的下标
  let newEndIdx = newCh.length - 1

  // "旧前" 节点
  let oldStartVnode = oldCh[0]
  // "旧后" 节点
  let oldEndVnode = oldCh[oldEndIdx]
  // "新前" 节点
  let newStartVnode = newCh[0]
  // "新后" 节点
  let newEndVnode = newCh[newEndIdx]

  let oldKeyToIdx, idxInOld, vnodeToMove, refElm

  // removeOnly is a special flag used only by <transition-group>
  // to ensure removed elements stay in correct relative positions
  // during leaving transitions
  const canMove = !removeOnly

  // 因为优化策略 1 的存在,所以循环的方向就不能是从左到右,而是从节点列表的两边到中间
  // 这里使用四个变量实现从两边到中间的逻辑,
  // 每处理一组 "新老” 节点,就会将 "前" 节点向后移动一位,将 "后" 节点向前移动一位。
  // 如果 newChildren 和 oldChildren 两个节点有一个循环完毕,while() 会就结束,此时就有两种情况需要说明。
  // 1,如果 while() 循环结束,newChildren 还有未处理的节点,则这些未处理的节点都是新增节点,需要进行创建和插入的操作。
  // 2,如果 while() 循环结束,oldChildren 还有未处理的节点,则这些未处理的节点都是要删除的节点,从 DOM 中将它们删除即可。
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 新前和旧前比较
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 新后和旧后比较
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 新后和旧前比较
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      canMove && 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, insertedVnodeQueue)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 如果上面的优化策略 1 没有找到目标旧子节点的话,则开始进行第二种优化策略
      // oldKeyToIdx 就是上文所说的 map 对象,它是由 createKeyToOldIdx 方法生成的
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      // idxInOld 是目标旧子节点在 oldChildren 中的下标
      idxInOld = isDef(newStartVnode.key)
        // 如果新子节点中定义了 key 属性的话,则直接从 oldKeyToIdx 对象中获取对应旧子节点在 oldChildren 中的下标
        ? oldKeyToIdx[newStartVnode.key]
        // 如果新子节点中没有定义 key 属性的话,此时说明第二种优化策略也无法实现效果了
        // 此时需要循环旧子节点列表寻找目标旧子节点,实现的逻辑在 findIdxInOld 方法中
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      if (isUndef(idxInOld)) { // New element
        // 如果目标旧子节点的下标没有找到的话,说明当前循环的节点是新增节点,进行创建和插入操作即可
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
      } else {
        // 如果获取到下标的话,从 oldChildren 中获取目标旧子节点 — vnodeToMove
        vnodeToMove = oldCh[idxInOld]
        // 判断对比的新老节点是不是同一节点
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // 如果是的话,进行更新节点的操作
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
          // 因为旧子节点已经处理过了,所以需要将 oldCh[idxInOld] 设置为 undefined,防止出现重复处理的情况
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 如果程序员给子节点标注的 key 属性不规范的话,就有可能出现 key 相同,但根本不是同一节点的情况,
          // 此时将当前循环的新子节点当做新增节点接口
          // same key but different element. treat as new element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }

  if (oldStartIdx > oldEndIdx) {
    // 1,如果 while() 循环结束,newChildren 还有未处理的节点,则这些未处理的节点都是新增节点,需要进行创建和插入的操作。
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    // 2,如果 while() 循环结束,oldChildren 还有未处理的节点,则这些未处理的节点都是要删除的节点,从 DOM 中将它们删除即可。
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

// 创建 key map 对象的方法
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
}

// 循环 oldChildren 旧子节点列表,查找目标旧子节点
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
  }
}

5,结语

patch() 方法中的逻辑挺多的,写了挺长时间,如果觉得对你有帮助的话,请多多点赞(^∀^)。

  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值