vue 2.x内部运行机制系列文章-虚拟DOM

832 阅读10分钟

什么是虚拟DOM

vue内部运行机制系列文章-template模板编译原理 知道,template经过编译会形成render function,然后render function 会被转化成 VNode 节点。Virtual DOM 其实就是一棵以 JavaScript 对象(VNode 节点)作为基础的树,用对象属性来描述节点,它是一层对真实 DOM 的抽象。

为什么要用虚拟DOM呢?

例如,我们来执行以下一段代码。

for(var key in document.createElement('div')){console.log(key)}

通过以上代码,可以看到,我们每创建一个新的元素都需要创建数十个属性,所以说频繁的操作真实DOM是非常昂贵的操作,会减慢网页的加载速度, 因此, vue中用了虚拟DOM来减少对真实DOM的操作。

vue中的虚拟DOM(视图更新)

我们知道当我们修改数据时,会触发set方法,进而执行watcher对象的update方法来跟新视图。在这一过程中,会重新执行render function形成一颗新的newDom,这时候会和旧的oldDom进行比较,进而找出不同,并将这些不同跟更新到视图上。

那么,vue中是怎么比较新旧vDom的呢?话不多说,我们马上来介绍。

diff算法

在vue中,比较两个虚拟DOM的差异主要是用了diff算法。

vue中的diff算法有个特点,就是只能在同级比较,不能跨级比较。 即图中颜色相同部分进行比较。

image

举个例子:

<!-- 之前 -->
<div>           <!-- 层级1 -->
  <p>            <!-- 层级2 -->
    <b> aoy </b>   <!-- 层级3 -->   
    <span>diff</Span>
  </P> 
</div>

<!-- 之后 -->
<div>            <!-- 层级1 -->
  <p>             <!-- 层级2 -->
      <b> aoy </b>        <!-- 层级3 -->
  </p>
  <span>diff</Span>
</div>

我们可能期望将<span>直接移动到<p>的后边,这是最优的操作。但是实际的diff操作是移除<p>里的<span>在创建一个新的<span>插到<p>的后边。因为新加的<span>在层级2,旧的在层级3,属于不同层级的比较。vue中的diff算法可能不是最优的操作,但是在一颗虚拟DOM树比较复杂的情况下是相对比较友好的。

我们知道在vue中,执行render()可以生成虚拟DOM,首次render的执行发生在beforeMount()生命周期函数之后,作为new Watcher()的回调传入。

// 这个方法在vm.$mount()方法里调用
// src/core/instance/lifecycle.js
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  // updateComponent作为回调传入,当数据变化重新执行render
  // 首次执行时进行依赖收集,同时生成虚拟dom
  // 数据/组件更新时,生成新的虚拟DOM,并和旧的虚拟DOM比较
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        // 如果已经加载,则先执行beforeUpdate
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false
  // 如果是首次mount则执行mounted方法。
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

当数据更新时,会通过watcherrun()方法来执行这个回调,从而生成新的虚拟DOM,而我们的dom diff就发生在这里,因此,我们重点来分析下这个回调。它执行了有一个叫_update()的方法。

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    if (!prevVnode) {
      // 初始化render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // 组件跟新时
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    restoreActiveInstance()
    // update __vue__ reference
    if (prevEl) {
      prevEl.__vue__ = null
    }
    if (vm.$el) {
      vm.$el.__vue__ = vm
    }
    // if parent is an HOC, update its $el as well
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
      vm.$parent.$el = vm.$el
    }
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
  }


_ipdate()方法里主要做了一件事,就是vm.__patch__(),这个方法其实就是我们dom diff的核心。

ok,我们接下来,主要简要分析下vue当中具体的对比方法。

patch

先来介绍几个有用的api

  • invokeDestroyHook: 用来删除dom节点
  • createElm:用来创建一个节点
  • patchVnode: patch的核心方法,主要对比就发生在这个方法中。
  • invokeInsertHook: 用来插入节点。
  • removeVnodes: 用来移除旧节点。
  • sameVnode: 比较两个node的tag、isComment、inputType是否相同以及是否都有data属性。
// 这里的patch方法比较复杂,在这里我进行了一定程度的简写。
function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    if (isUndef(oldVnode)) {
      createElm(vnode, insertedVnodeQueue)
    } else {
      if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }

oldVnode: 以下表示旧节点
vnode:以下表示新节点

patch的方式主要分为4步

  • 如果oldVnode存在,vnode不存在,则是要做删除oldVnode节点的操作。
  • 如果oldVnode不存在,vnode存在,则是要做创建vnode节点的操作。
  • 如果oldVnode、vnode都存在,且标签名相同、inputType属性(若有)相同且都存在data,则执行patchVnode方法。
  • 如果oldVnode、vnode都存在,但是不满足第三步条件,则删除oldVnode节点,创建vnode节点

patchVnode

function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    // 完全相同,则什么都不做
    if (oldVnode === vnode) {
      return
    }

    const elm = vnode.elm = oldVnode.elm
    // 都是静态节点且key相同,且当vnode是克隆节点或是v-once指令控制的节点
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      vnode.componentInstance = oldVnode.componentInstance
      return
    }

    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)) {
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        removeVnodes(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方法主要分为以下步骤:

  • vnodeoldVnode完全相同,则不需要做任何事情
  • vnodeoldVnode都是静态节点,且具有相同的key,则当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elmoldVnode.child都复制到vnode上,也不用再有其他操作。
  • vnodeoldVnode不是文本节点或注释节点时
    1. 如果oldVnodevnode都有子节点,且2方的子节点不完全一致,就执行更新子节点的操作(这一部分其实是在updateChildren函数中实现,之后会介绍)。
    2. 如果只有oldVnode有子节点,那就把这些节点都删除
    3. 如果只有vnode有子节点,那就创建这些子节点
    4. 如果oldVnodevnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串
  • 如果vnode是文本节点或注释节点,但是vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以.

updateChildren

ok,接下来就介绍updateChildren函数

  /**
  * parentElm:父级元素节点
  * oldCh: oldVnode的children
  * newCh: vnode的children
  **/
  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // 定义4个索引
    let oldStartIdx = 0  // 旧头
    let newStartIdx = 0  // 新头
    let oldEndIdx = oldCh.length - 1 // 旧尾
    let oldStartVnode = oldCh[0]  // oldStartIdx对应的node
    let oldEndVnode = oldCh[oldEndIdx] // oldEndIdx对应的node
    let newEndIdx = newCh.length - 1  // 新尾
    let newStartVnode = newCh[0] // newStartIdx对应的node
    let newEndVnode = newCh[newEndIdx] // newEndIdx对应的node
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

   
    // 当vnode和oldVnode在下标之间有node存在时
    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, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        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, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }


首先我们定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引,同时oldStartVnode、newStartVnode、oldEndVnode 以及 newEndVnode 分别指向这几个索引对应的 VNode 节点。

我们举个例子

假设现在oldch、newCh分别如上图所示。那么接下来就要执行

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)

在这个循环中,oldStartIdx、oldEndIdxnewStartIdx、newEndIdx分别从两边向中间移动,直到有其中一个存在交叉部分(startIdx>=endIdx)

  • 首先当 oldStartVnode 或者 oldEndVnode 不存在的时候,oldStartIdxoldEndIdx 继续向中间靠拢,并更新对应的 oldStartVnodeoldEndVnode 的指向,这里需要注意就是伴随着Idx移动,其对应的指向node也发生变化
if (isUndef(oldStartVnode)) {
    oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
    oldEndVnode = oldCh[--oldEndIdx]
} 
  • 接下来就是新旧vnode的首首、首尾、尾尾、尾首对比的过程,即oldStartVnode、newStartVnodeoldEndVnode、newEndVnode两两之间执行patchVnode,同时Idx向中间移动
else if (sameVnode(oldStartVnode, newStartVnode)) { // 
    patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
    oldStartVnode = oldCh[++oldStartIdx]
    newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
    oldEndVnode = oldCh[--oldEndIdx]
    newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
    patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
    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, newCh, newStartIdx)
    canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
    oldEndVnode = oldCh[--oldEndIdx]
    newStartVnode = newCh[++newStartIdx]
}

ok,接下来我们来分别分析

  • 首先如果oldStartVnode、newStartVnode符合 sameVnode 时,说明oldVnode 节点的头部与 vNode 节点的头部是相同的VNode节点,直接进行 patchVnode,同时 oldStartIdxnewStartIdx 向后移动一位。

  • oldEndVnode、newEndVnode同理,若两者符合 sameVnode,直接进行 patchVnode,同时 oldEndIdxnewEndIdx 向前移动一位。

  • 接下来比较oldStartVnode、newEndVnode,若两者符合 sameVnode,也就是老 oldVnode 节点的头部与新 vNode 节点的尾部是同一节点的时候,将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。

  • 同理,oldEndVnodenewStartVnode 符合 sameVnode 时,也就是老 oldVnode 节点的尾部与新 vNode 节点的头部是同一节点的时候,将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。同样的,oldEndIdx 向前移动一位,newStartIdx 向后移动一位。

  • 若以上几种情况都不满足,则进行以下操作
else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
      

// 
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
}

createKeyToOldIdx 的作用是产生 keyindex 索引对应的一个 map 表。比如说有这么一个oldChild(为举例,格式不正确):

[
    {xx: xx, key: 'key0'},
    {xx: xx, key: 'key1'}, 
    {xx: xx, key: 'key2'}
]

经过createKeyToOldIdx转换后就会变为

{
    key0: 0, 
    key1: 1, 
    key2: 2
}

通过这种方式,就可以在oldCh中快速找到与当前节点(newStartVnode) key相同的节点的索引idxInOld.

if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
  • 如果没有找到这个idxInOld,则通过 createElm 创建一个新节点,并将 newStartIdx 向后移动一位。
if (isUndef(idxInOld)) { // 创建新节点
  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}

  • 如果存在这个idxInOld,且符合sameVnode,则执行patchVnode并将oldCh[idxInOld] = undefined,最后将newStartIdx 向后移动一位。
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
    patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
    oldCh[idxInOld] = undefined
    canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}

  • 如果不符合 sameVnode,只能创建一个新节点插入到 parentElm 的子节点中,newStartIdx 往后移动一位。

最后,当while循环执行完成,会有两种情况

  1. 如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。
  2. 如果 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes 批量删除即可。