# patch介绍

patch通过对比新旧两个vnode之间的不同找出需要更新的节点进行更新。之所以使用它主要是出于性能方面的考虑,因为DOM操作的执行速度远没有JS的运算速度快,这也是为什么会使用虚拟DOM的原因。

# 新增节点

想想什么情况需要新增节点呢?大体有下面两种情况:

  • 页面首次渲染时 页面首次渲染时,不存在任何DOM节点,此时就需要通过vnode新增节点插入到视图中

  • 新老节点完全不一样时 新老节点完全不一样,说明vnode和oldVnode完全不一样,这时需要用vnode创建一个新DOM节点,用它去替换oldVnode所对应的真实DOM节点

# 删除节点

当oldVnode和vnode完全不是同一个节点时,在DOM中需要使用vnode创建的新节点替换oldVnode所对应的旧节点,而替换过程是将新创建的DOM节点插入到旧节点的旁边,然后再将旧节点删除,从而完成替换过程。

# 更新节点

当节点是同一个节点,但oldVnode和vnode存在差异时,此时需要更新节点。

<div id="app">
  {{text}}
</div>
<script>
  new Vue({
    el: '#app',
    data() {
      return {
        text: 'hello'
      }
    }
  })
  setTimeout(()=>{
    vm.text = 'hello world'
  },1000)
</script>

上述代码页面中文本节点的内容为hello,1s后将text改为 hello world,改变后的状态生会生成新的vnode,对比新老vnode发现是同一个节点,然后再进行更细的比对发现是文本内容发生了变化,将文本更新为hello world

# 创建节点

三种类型的节点会插入到DOM中

  • 元素节点

如果vnode具有tag属性说明是元素节点,可以通过createElement方法创建元素节点,如果这个元素节点下有子节点(vnode的children属性保存了当前节点的所有子虚拟节点)我们只需要遍历所有子节点逐一创建节点即可,然后将创建的节点插入到指定的父节点下就行。

  • 注释节点

如果vnode中有isComment属性,可以通过createComment创建真实的注释节点并将其插入到指定的父节点中。

  • 文本节点

如果节点没有tag属性且没有isComment属性,可以判断为文本节点,可以通过createTextNode创建真实的文本节点并将其插入到指定的父节点中。

# 删除节点

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

removeVnodes用于删除一组指定的节点,从代码中可以看出主要从vnodes数组中删除从startIdx指定的位置到endIdx指定位置的内容

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

function removeNode (el) {
  const parent = nodeOps.parentNode(el)
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}

removeNode用于删除单个节点。

# 更新节点

静态节点指的是一旦渲染到视图后无论状态怎么变都不会变化的节点,静态节点无需更新,要跳过更新节点的操作。

如果创建的虚拟节点有text属性且和老的虚拟节点的文本属性不一样时,直接将旧节点的内容通过setTextContent方法改为新虚拟节点的文本即可。

如果创建的新元素节点没有children属性,说明是一个空节点,这时需要将旧节点中的所有内容删除。

如果新创建的元素节点有children属性,oldVnode中没有children属性且不为空,需要清空后将newVnode中的children逐个创建真实DOM进行插入。

如果newVnode有children属性,oldVnode也有children属性,需要将两个children进行对比更新。

# 更新子节点

将新旧两个节点通过循环进行比对,如果newChildren中有一个节点在oldChildren中找不到相同的节点,此时需要进行新增节点操作。对于新增节点我们要执行创建节点操作并将新创建的节点插入到oldChildren中所有未处理节点的前面。

为什么要插入到oldChildren中所有未处理节点的前面呢?

因为我们是使用虚拟节点进行对比,而不是真实DOM节点做对比,所以newVnode和oldVnode进行对比,而oldVnode表示已处理的节点只有两个,不包括我们新插入的节点,所以用插入到已处理节点后面这样的逻辑来插入节点,就会插入一个错误的位置。

两个节点是同一个节点并且位置相同,这种情况下只需要进行更新节点的操作即可。

newVnode和oldVnode位置不同时,需要更新并移动操作。

通过Node.insertBefore()方法,我们可以成功地将一个已有节点移动到一个指定的位置。

怎么知道应该把节点移动到哪里呢?

对比两个子节点列表是通过从左到右循环newChildren这个列表,然后每循环一个节点,就去oldChildren中寻找与这个节点相同的节点进行处理。也就是说,newChildren中当前被循环到的这个节点的左边都是被处理过的。那就不难发现,这个节点的位置是所有未处理节点的第一个节点。

如果oldChildren中存在但newChildren中不存在的节点就需要删除。

# 优化策略

我们是在循环中逐个对比新旧子节点的差异的,如果子节点非常多,逐个对比对性能不是很好,那有什么可以优化的方法吗?

实际项目中可能只是某个子节点发生了变化,对于那些位置不变或者位置可预测的节点可以不用循环来查找。如我们只是修改数据并不对数据进行增删操作。

在子节点集中我们最容易确定的两个节点就是第一个和最后一个节点。

我们可以先拿newChildren的开始和结尾和oldChildren的开始和结尾进行对比。

这样就会有四种组合:

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

可将新前和旧前、新后和旧后节点进行对比如果是同一个节点,直接对比更新即可。

“新后”与“旧前”是同一个节点时,在真实DOM中除了做更新操作外,还需要将节点移动到oldChildren中所有未处理节点的最后面。

为什么移动到oldChildren中所有未处理节点的最后面。 如图所示上面的DOM是真实DOM,左下角为新生成的虚拟DOM,右下角为老的虚拟DOM。在没有新虚拟DOM前,老的虚拟DOM和真实DOM的节点位置是一一对应的。当有了新的虚拟DOM后,新后和旧前的DOM节点都是3,但是位置不同,此时真实DOM需要按照新的虚拟DOM进行更新,图上也就是3要移动到真是DOM节点的最后面,因为在操作真实DOM前会先对比新老虚拟DOM,老虚拟DOM和真实DOM是一一对应的,所以就是将3节点移动到oldChildren所有未处理节点的最后面。

当“新前”与“旧后”是同一个节点时,在真实DOM中除了做更新操作外,还需要将节点移动到oldChildren中所有未处理节点的最前面。逻辑和“新后”“旧前”一样,不再赘述。

新后和旧前、新前和旧后对比

# 哪些节点是未处理过的

所有的对比都是针对未处理过的节点,那么应该如何判断哪些节点是未处理过的呢?

如果是普通的循环逻辑,那就没什么好说的了,从前往后循环,没有循环到的就是未处理的。但由于优化策略的存在,有可能先处理最后一个,打破了这种顺序处理的逻辑,这时就需要从两边往中间处理,也就是需要用到双指针法。

我们需要准4个变量:oldStartIdx(oldVnode开始的下标)、oldEndIdx(oldVnode结束的下标)、newStartIdx(newVnode开始的下标)和newEndIdx(newVnode结束的下标)。

开始位置所表示的节点被处理后,就向后移动一个位置;结束位置的节点被处理后,则向前移动一个位置。也就是,oldStartIdx和newStartIdx只能向后移动,而oldEndIdx和newEndIdx只能向前移动。


while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  // Todo
}

如果oldChildren先循环完,newChildren中还有剩余节点,这些节点就是需要新增的节点。

如果newChildren先循环完,oldChildren中还有剩余节点,这些节点就是需要删除的节点。