Vue2 源码系列之虚拟DOM

322 阅读6分钟

本文是记录我在看vue源码对vdom这块的理解,如果有不正确的地方,还望带佬指点一哈。

什么是虚拟DOM?

虚拟dom即Virtual DOM 本质上是一个js对象,用来描述真实的dom节点,vue中实现了一个vnode类,用来表示虚拟节点。
源码路径:\src\core\vdom\vnode.js

export default class VNode {
 constructor (
   tag?: string,
   data?: VNodeData,
   children?: ?Array<VNode>,
   text?: string,
   elm?: Node,
   context?: Component,
   componentOptions?: VNodeComponentOptions,
   asyncFactory?: Function
 ) {
   /*当前节点的标签名*/
   this.tag = tag 
   /* 包含当前节点的一些数据信息,类型是vnodeData,详情参考vnodeData中的数据信息 */
   this.data = data
   /* 当前节点的子节点,是个数组 */
   this.children = children
   /* 当前节点的文本 */
   this.text = text
   /* 当前虚拟节点的真实dom节点 */
   this.elm = elm
   /* 当前节点的命名空间 */
   this.ns = undefined
   /* 编译作用域 */
   this.context = context
   /* 函数组件作用域 */
   this.fnContext = undefined
   /* 函数组件的option选项 */
   this.fnOptions = undefined
   this.fnScopeId = undefined
   /* 节点的key属性,作为节点的标识,patch时用来优化 */
   this.key = data && data.key
   /* 组件的option选项 */
   this.componentOptions = componentOptions
   /* 当前节点对应的组件实例 */
   this.componentInstance = undefined
   /* 当前节点的父节点 */
   this.parent = undefined
   /*是否为原生HTML或普通文本,innerHTML的时候为true,textContent的时候为false*/
   this.raw = false
   /*静态节点的标志*/
   this.isStatic = false
   /*是否作为根节点插入*/
   this.isRootInsert = true
   /*是否是注释节点*/
   this.isComment = false
   /*是否是克隆节点*/
   this.isCloned = false
   /*是否有v-once指令*/
   this.isOnce = false
   this.asyncFactory = asyncFactory
   this.asyncMeta = undefined
   this.isAsyncPlaceholder = false
 }

 // DEPRECATED: alias for componentInstance for backwards compat.
 /* istanbul ignore next */
 get child (): Component | void {
   return this.componentInstance
 }
}

这里我移除了一些源码对参数类型的校验。

为什么使用虚拟DOM?

因为DOM操作的执行速度远不如JavaScript的运算速度快,所以将大量的DOM操作转变为JavaScript运算,使用diff算法来计算出真正需要更新的节点,最大限度的减少DOM操作,从而达到性能的提升。虚拟DOM最核心的部分是patch,它可以将vnode渲染成真实的DOM,所以这里我们主要来看vue中patch的过程。

patch过程

patch的本质是将新旧vnode进行比较,创建、删除或者更新DOM节点/组件实例。对比两个vnode之间的差异只是patch的一部分。patch的目的其实是修改DOM节点,也可以理解为渲染视图。vue中主要通过以下三个方法实现:pathch、pa,chVnode和updateChildren ,其中我们常提到的diff过程在updateChildren方法里,现在我们重点看一下这三个方法。
源码路径:\src\core\vdom\patch.js

patch方法

这里先来看一下patch方法的过程。
源码比较多,这里我就不贴了,有兴趣的小伙伴自己clone一份源码结合着看一看。 上面流程图提到当oldVnode和vnode“相同”时,会调用patchVnode进行详细对比,这里顺带提一嘴,vue中判断两个vnode是否“相同”,调用的是sameVnode方法,该方法会去比较两个vnode的key是否相等并且tag是否相等、和其他判断条件。这也是为什么v-for渲染元素列表时,需要给一个唯一值key。

patchVnode方法

这是patchVnode方法的整体流程,建议结合源码使用更佳。 上图提到当oldVnode和vnode都存在子节点且不相等时,调用updateChildren方法更新子节点,敲黑板,到这里才是真正的重点。

updateChildren方法

我们来看下updateChildren方法,虽然看起来挺唬人的,但是建议静下心来过一遍,再结合后面的图看一下,相信聪明的你是能够理解的。

 function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    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

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

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

我们可以看到程序一开始会在oldChildren、newChildren的头尾各创建一个指针,然后开始遍历,而整个循环的条件是oldStartIdx >= oldEndIdx并且newStartIdx >= newEndIdx,如果有一个条件不满足,说明有一个数组已经遍历完了,循环体内则进行各种情况的判断。

1、旧头和新头相同时

当旧头和新头相同时,递归调用patchVnode更新节点,然后将oldStartIdx、newStartIdx向后移,节点位置不需要移动。

2、旧尾和新尾相同时

当旧尾和旧头相同时,递归调用patchVnode更新节点,然后将oldEndIdx、newEndIdx向前移,节点位置不需要移动。

3、旧头和新尾相同时

当旧头和新尾相同时,递归调用patchVnode更新节点,然后再调用insertBefore钩子将oldStartVnode.elm(对应的真实DOM节点)移动到oldEndVnode.elm的后面,web平台对应方法是insertBefore。最后将oldStartIdx后移一位、newEndIdx前移一位。

4、旧尾和新头相同时

当旧尾和新头相同时,递归调用patchVnode更新节点,然后再调用insertBefore钩子将oldEndVnode.elm移动到oldStartVnode.elm的前面,最后将oldEndIdx前移一位、newStartIdx后移一位。

5、不满足以上条件的

当出现这种不满足以上条件的,会用oldChildren创建一个哈希表,其中vnode的key作为键,下标作为值。如果newStartVnode.key存在就会在哈希表中查找,不存在的话就遍历oldChildren查找。如果没有找到对应的,则用newStartVnode创建一个新的真实节点放到oldStartVnode.elm的前面,找到对应的则调用sameVnode判断新旧是否相同,相同调用patchVnode更新节点并移动该节点到oldStartVnode.elm的前面,不相同则新建节点。最后将newStartIdx后移一位。

到这里我们的循环体内的判断结束了,但是如果oldChildren和newChildren长度不一样,则其中一个循环结束了,另一个还会有剩余节点。所以接下来我们要对这部分进行判断。

如果oldChildren先循环结束,newChildren还有剩余节点,此时oldStartIdx必然大于oldEndIdx,我们将newStartIdx于newEndIdx之间的vnode创建真实节点添加到视图中即可。同理,当newChildren先循环结束,oldChildren还有剩余节点时 ,此时newStartIdx必然大于newEndIdx,我们移除掉oldStartIdx与oldEndIdx之间的节点即可。

为什么要设置key属性以及为什么不建议用数组的index作为key?

因为key会作为节点的唯一id,更新子节点时,如果不存在key会遍历查找oldChildren,如果设置了key则会去哈希表中查找,会提升一定的性能。那为什么不建议使用index作为key呢?这里我们要去看sameVnode方法,它在判断两个vnode是否相同时主要就是去比较key和tag。当我们使用index作为key时,例如:oldChildrenKeyArr: 0 , 1 , 2 , 3 如果我们删除了数组的第一项,按道理来说我们只需要移除第一个节点就可以了。但是实际上删除之后是这样的newChildrenKeyArr: 0 , 1 , 2 再调用updateChildren更新子节点,遍历结束反而删除的是最后一项,造成性能的浪费。如果使用id(唯一值)就不会出现这种情况。

写在最后

至此,vue的虚拟DOM就结束了,希望大家从这篇文章中能有所收获。
灵魂拷问: 你学废了吗?(手动狗头)