vue源码解析虚拟dom patch的过程

1,432 阅读14分钟

本文将通过源码解析当组件挂载到页面上时以及数据更新触发组件重新渲染时,虚拟dom的patch以及生成真实dom的整个过程,并详细介绍diff算法。

1、当我们执行$mount挂载组件或组件依赖的数据更新时,会执行vue实例的_update方法,此时会有两种情况:

  • 第一种当前是首次渲染,即调用$mount首次挂载组件,这个时候没有旧的虚拟dom。
  • 第二种是数据更新,触发组件的更新,这时候存在新的虚拟dom和旧的虚拟dom。

_update主要做的就是判断这两种情况,并且调用实例的__patch__方法,关键代码如下:

代码所在vue源码目录位置: src/core/instance/lifecycle.js

  Vue.prototype._update = function (vnode) {
    const vm = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) { 
      // 如果不存在旧的虚拟dom,则说明是首次渲染,
      // 这时候__patch__方法的第一参数传的是真实dom vm.$el,
      // 最终当前新的虚拟dom,vnode会转换为真实dom并替换掉vm.$el
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // 存在旧的虚拟dom prevVnode,要进行新旧虚拟dom的patch
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
 
  }

2、vm.__patch__,该方法主要做了两个判断:

  • 一个是判断新老虚拟dom是否存在
  • 第二个是判断当前是一个组件的初始化操作还是数据更新操作。

新老虚拟dom是否存在有三种情况,是否是初始化只有是和否两种情况,以下是各种情况以及执行的操作:

  • 新的虚拟dom不存在:说明执行了删除操作,将老的节点整个删除。
  • 老的虚拟dom不存在:说明进行了增加操作,将新的虚拟dom转换为真实dom,添加到组件对应的位置
  • 新老虚拟dom都存在:这时候又存在两种情况
    • oldVnode为真实dom:说明不存在老虚拟dom,即当前是初始化过程,则将新的虚拟dom转化为真实dom并替换掉宿主元素(实际执行过程为将转化好的真实dom添加到页面中,并将宿主元素的dom删掉)
    • oldVnode为虚拟dom:这时候存在新老虚拟dom,说明执行了数据更新操作,这时候调用patchVnode方法执行diff

代码所在vue源码目录位置:src/core/vdom/patch.js

  // `isUndef`判断元素是否不存在,`isDef`判断元素是否存在。
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 如果新的虚拟dom树不存在,则删除
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    // 如果老的vdom树不存在,新增
    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
      
    } else {
      // 如果传入的是真实节点,则是初始化操作
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        // 存在新旧vdom,执行diff
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        // oldVnode传入的是真实节点,说明是初始化过程
        // 初始化过程,创建新dom树,追加到宿主元素后面
        // create new node
        createElm(
          vnode,
          insertedVnodeQueue,
          // extremely rare edge case: do not insert if old element is in a
          // leaving transition. Only happens when combining transition +
          // keep-alive + HOCs. (#4590)
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )
        // 虚拟dom转化为真实dom并添加后,删除宿主元素
        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    return vnode.elm
  }

3、patchVnodepatchVnode方法主要是对新老虚拟dom是否为元素以及是否有孩子进行判断,并调用对应的处理方法,所有的情况如下:

  • 新节点为元素(孩子即为元素的子元素)
    • 双方都有孩子:执行updateChildren方法
    • 新节点有孩子,老节点没有孩子:创建孩子并追加
    • 新节点没有孩子,老节点有孩子:删除老节点的孩子
    • 新老节点都没有孩子:如果老节点为文本则清空文本
  • 新节点为文本 - 将老节点替换为文本

代码所在vue源码目录位置:src/core/vdom/patch.js

  function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    // 新旧节点是否存在孩子
    const oldCh = oldVnode.children
    const ch = vnode.children
    
    // 属性更新 <div style="color:blue">  <div style="color:red">
    if (isDef(data) && isPatchable(vnode)) {
      // cbs中关于属性更新的数组拿出来[attrFn,classFn,...]都执行一遍
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }

    // 新节点为元素
    if (isUndef(vnode.text)) {
      // 双方都有孩子
      if (isDef(oldCh) && isDef(ch)) {
        // 比孩子,reorder
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        // 新节点有孩子,老节点没有孩子
        if (process.env.NODE_ENV !== 'production') {
          checkDuplicateKeys(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)
    }
  }

4、updateChildren,在上面的patchVnode方法中,如果新旧虚拟dom都有孩子,那么就会进入比较孩子的重排算法。网上对虚拟dom patch介绍的比较多的也是这个方法。该方法主要是通过对新老虚拟dom的比较,对真实dom进行重排、增加、删除操作。

对新老虚拟dom进行比较的过程,需要对每个孩子节点逐一进行比较,找到相同的节点,然后根据节点在新虚拟dom中的位置,对页面中对应的元素进行重排操作。这里的关键就是如何找到新老虚拟dom中相同的节点,因为只有找到了相同的节点,才能知道该节点在老的虚拟dom中位于什么位置,在新的虚拟dom中位于什么位置,然后将该节点从老虚拟dom中的位置移动到新虚拟dom中的位置。对所有的孩子节点遍历进行了这个操作后,即完成了重排。

当然这里不一定都能在新老虚拟dom中找到相同的节点,可能某个节点只有在新虚拟dom里有,即在查找相同节点的时候,没有在老的虚拟dom里找到这个节点,则需要执行增加操作。可能某个节点只在老的虚拟dom里有,则说明在新的虚拟dom里把这个节点删了,则将实际dom中的该元素删除即可。

所以这里的重中之重,即如何查找新老虚拟dom中相同的元素,如果找到相同的元素则进行重排,没找到相同的元素则进行增加或删除操作。

查找算法(重排算法)如下:

  • 创建4个指针分别指向新老虚拟dom的开头和结尾
  • 创建4个节点分别对应上面4个指针的内容

循环执行以下步骤,直到新虚拟dom遍历完或老虚拟dom遍完:

  • 比较新老虚拟dom的开头节点是否相同
    • 相同:对该两个节点执行patchVnode方法,并且两个开头指针分别往后移动一位
    • 不相同:进入下一个判断
  • 比较新老虚拟dom的结尾节点是否相同
    • 相同:对该两个节点执行patchVnode方法,并且两个结尾指针分别往前移动一位
    • 不相同:进入下一个判断
  • 比较老虚拟dom的开头节点和新虚拟dom的结尾节点是否相同
    • 相同:
      • 对该两个节点执行patchVnode方法
      • 将老虚拟dom的开头节点移动到结尾
      • 老虚拟dom开头指针往后移动一位,新虚拟dom结尾指针往前移动一位
    • 不相同:进入下一个判断
  • 比较老虚拟dom的结尾节点和新虚拟dom的开头节点是否相同
    • 相同:
      • 对该两个节点执行patchVnode方法
      • 将老虚拟dom的结尾节点移动到开头
      • 老虚拟dom结尾指针往前移动一位,新虚拟dom开头指针往后移动一位
    • 不相同:进入下一个判断
  • 以上4中情况都没有相同的,则将新虚拟dom的开头节点分别与老虚拟dom中未比较过的节点进行比较,看能否找到相同的节点
    • 找到相同的节点:
      • 对该两个节点执行patchVnode方法
      • 将老虚拟dom的结尾节点移动到开头
    • 未找到相同的节点
      • 根据新的虚拟dom创建新的元素并增加到开头
    • 不管是否找到相同的节点,新虚拟dom的开头指针往后移动一位

循环比较完新老虚拟dom的孩子之后,还有一些剩下的整理工作,因为我们的循环条件是新虚拟dom开头节点<=新虚拟dom的结尾节点并且老虚拟dom的开头节点<=老虚拟dom的结尾节点。所以当循环结束后会剩下两种情况:

  • 老虚拟dom的开头节点 > 老虚拟dom的结尾节点:这种情况说明老虚拟dom的节点都处理完了,新虚拟dom的节点可能没处理完,则将新虚拟dom没处理的节点都统一创建并添加到页面中
  • 老虚拟dom的开头节点 < 老虚拟dom的结尾节点:这种情况说明新虚拟dom的节点都处理完了,老虚拟dom的节点还没处理完,则将老虚拟dom还没处理的节点统一删除

算法的代码如下(如果看不太懂,看注释理解原理即可): 代码所在vue源码目录位置:src/core/vdom/patch.js

  // 重排算法
  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // 4个指针
    let oldStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let newStartIdx = 0
    let newEndIdx = newCh.length - 1
    

    // 4个节点
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]

    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // 循环条件:开始索引不能大于结束索引
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    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 {

        // 4中猜想之后没有找到相同的,不得不开始循环查找
       ...在老虚拟dom中查找与新虚拟dom开头相同的节点,该过程省略,不重要
       
        if (isUndef(idxInOld)) { // New element
          // 没找到则创建新元素,并添加到队首
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 找到除了打补丁,还要移动到队首
          vnodeToMove = oldCh[idxInOld]
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
 
        }
        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)
    }
  }

一些比较重要的点

为什么新老虚拟dom的diff算法,采用头尾4个指针,并向中间靠拢的这种方式来查找新老虚拟dom中相同的节点

因为我们在实际需求中,对页面进行的操作往往是在列表的开头或结尾增加了一个元素或者删除了一个元素,所以我们要查找新老两个虚拟dom中相同的节点的时候,可以先分别查一下:

  • 新虚拟dom的开头节点和老虚拟dom的开头节点是否相同
  • 新虚拟dom的结尾节点和老虚拟dom的结尾节点是否相同
  • 新虚拟dom的开头节点和老虚拟dom的结尾节点是否相同
  • 新虚拟dom的结尾节点和老虚拟dom的开头节点是否相同

一般通过以上的查找就能吵到相同的节点了,如果还查不到,则只能采用暴力循环的方法,将新虚拟dom中的开头节点与老虚拟dom中的节点一一比较,来查找相同的节点。所以采用两个双指针的方法可以减少很多循环,提升页面性能。

重排的过程中发生了什么

当我们在新老虚拟dom中找到了相同的元素,并且要根据该元素在新虚拟dom中的位置进行移动重排的时候,我们重排的过程不是操作的虚拟dom,而是直接操作真实dom。虚拟dom的内容不会改变,改变的是真实dom,我们只是通过比较虚拟dom来知道如何改动真实dom,每当处理完一个虚拟dom的child,虚拟dom的指针就向中间靠拢一格,刚才处理的那个虚拟dom的节点,其真实dom操作已经做完了。

我们上面说的要将某个元素移动到队首或队尾,说的是移动到当前头尾指针的位置。头尾指针对应的元素内容是要移动的元素的参考节点,移动到队首即移动到当前头指针对应的真实dom元素的前面,移动到队尾即移动到当前尾指针对应的真实dom元素的后面。新老虚拟dom diff完成了之后,真实的dom操作也就都已经完成了。

深度优先,同级比较

我们在比较新老虚拟dom的节点的时候,如果发现新老虚拟dom中两个孩子节点相同,则会对这两个虚拟dom节点执行patchVnode方法,对这两个虚拟dom节点执行patchVnode方法的过程中,又会找到相同的虚拟dom节点,则继续调用patchVnode方法,这样一层层向下遍历执行patchVnode,即为深度优先,目的是向下处理完该虚拟dom节点的所有子孙元素,再往后处理其同级的虚拟dom。同级比较则为字面意思,在新旧虚拟dom中同级的两个孩子才能进行比较。

虚拟dom diff过程中执行的真实dom操作方法

在上面对新老虚拟dom进行比较的过程中,如果需要移动dom的位置,会调用类似nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)这样的方法。上面这个方法的作用为将要移动的节点(vnodeToMove.elm)移动到老的虚拟dom开头节点对应的真实dom元素(oldStartVnode.elm)的前面。 这些方法为真实dom操作方法,对应的实际代码如下:

代码所在vue源码目录位置: src/platforms/web/runtime/node-ops.js

// 这里仅为操作真实dom的部分代码
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}

export function removeChild (node: Node, child: Node) {
  node.removeChild(child)
}

export function appendChild (node: Node, child: Node) {
  node.appendChild(child)
}

dom的属性如何更新

在以上的讲解过程中,我们在执行patchVnode方法时,会对元素进行重排,修改文本内容,增加、删除这些操作,但是没有讲解属性更新的操作。属性更新的操作也在patchVnode方法中,patchVnode方法会传进来老虚拟dom和新虚拟dom。在patchVnode方法中,我们会获取属性更新相关的所有方法并全部执行一遍,这些方法全都执行完成之后,属性就更新完成了,执行属性更新方法的时候会把新老虚拟dom作为参数传进去,关键代码如下:

patchVnode代码在:src/core/vdom/patch.js 属性更新相关的代码在:src/platforms/web/runtime/modules

  function patchVnode (
    oldVnode,
    vnode
  ) {
      // 属性更新
      // cbs中关于属性更新的方法都拿出来[updateAttrs,updateClass,...]执行一遍
      // 即执行updateAttrs(oldVnode, vnode),updateClass(oldVnode, vnode),updateDOMListeners(oldVnode, vnode)
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      
    ...
    对新老虚拟dom的各种情况进行判断的其它代码
    
  }

挑几个属性更新的方法列一下

  • updateAttrs:对一些普通属性进行更新
  • updateClass:对class进行更新
  • updateDOMListeners:对监听事件进行更新

updateAttrs,取出虚拟dom中的data.attrs里的内容,进行属性的更新

代码目录:src/platforms/web/runtime/modules/attrs.js

function updateAttrs (oldVnode: VNodeWithData, vnode: VNodeWithData) {

  let key, cur, old
  const elm = vnode.elm
  const oldAttrs = oldVnode.data.attrs || {}
  let attrs: any = vnode.data.attrs || {}
  // clone observed objects, as the user probably wants to mutate it
  if (isDef(attrs.__ob__)) {
    attrs = vnode.data.attrs = extend({}, attrs)
  }

  // 根据新虚拟dom中的属性进行更新
  for (key in attrs) {
    cur = attrs[key]
    old = oldAttrs[key]
    if (old !== cur) {
      setAttr(elm, key, cur)
    }
  }

  // 删除在新虚拟dom中不存在的属性
  for (key in oldAttrs) {
    if (isUndef(attrs[key])) {
      if (isXlink(key)) {
        elm.removeAttributeNS(xlinkNS, getXlinkProp(key))
      } else if (!isEnumeratedAttr(key)) {
        elm.removeAttribute(key)
      }
    }
  }
}

updateClass,更新元素的class

代码目录:src/platforms/web/runtime/modules/class.js

function updateClass (oldVnode: any, vnode: any) {
  const el = vnode.elm

  // 根据新虚拟dom的内容生成class
  // 该方法会从vnode.data.staticClass和vnode.data.class获取要更新的class
  let cls = genClassForVnode(vnode)

  // 如果class有变化,则用新class替换之前的class
  // set the class
  if (cls !== el._prevClass) {
    el.setAttribute('class', cls)
    el._prevClass = cls
  }
}

updateDOMListeners,取出虚拟dom中data.on里的内容,对元素的监听事件进行更新:

代码目录:src/platforms/web/runtime/modules/events.js

function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  const on = vnode.data.on || {}
  const oldOn = oldVnode.data.on || {}
  target = vnode.elm
  normalizeEvents(on)
  
  // 根据虚拟dom on属性里的内容,对监听的事件进行更新、取消监听等操作
  updateListeners(on, oldOn, add, remove, createOnceHandler, vnode.context)
  target = undefined
}