Vue源码解析(三):Vue是如何挂载Dom的(patch篇)

327 阅读6分钟

上一篇:Vue源码解析(二):Vue是如何挂载Dom的(render篇) 上一节通过_render方法已经把render函数转换成VNode了,那VNode又是怎么样转成真实Dom的呢?

export function mountComponent(vm, el) {
  vm.$el = el
  ...
  callHook(vm, 'beforeMount')
  ...
  const updateComponent = function () {
    vm._update(vm._render())
  }
  ...
}

我们知道vm._render()返回的是一个VNode,而updateComponent方法里的_update方法就是把传进去的VNode转为真实Dom,它里面有两种场景:

  • 首次渲染 当执行new Vue到此时就是首次渲染了,会将传入的VNode对象映射为真实的Dom
  • 更新页面 数据变化会驱动页面发生变化,这也是vue最独特的特性之一,数据改变之前和之后会生成两份VNode进行比较,然后再做最小的改动去渲染页面,这样一个diff算法还是挺复杂的,后面再看。
Vue.prototype._update = function(vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el      // 上一次的 $el
  const prevVnode = vm._vnode    // 上一次的 vnode
  const restoreActiveInstance = setActiveInstance(vm)
  vm._vnode = vnode   // 再把最新的重新赋值到_vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  ... 首次渲染
  if (!prevVnode) {    // 没有上一次的vnode,则说明是首次渲染
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates     // 否则是更新
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  ...
}

patch

patch的作用

主要是比较VNodeoldVNode,再以VNode为标准的情况下在oldVNode上做小的改动,完成VNode对应的Dom渲染。

继续,这里的vm.$el是之前在mountComponent方法内挂载的,是一个真实Dom元素。vm.__patch__返回的也是一个真实Dom元素,把之前那个给覆盖掉了。
首次渲染会传入vm.$el以及得到的VNode,所以看下vm.__patch__定义:

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

Ps: 这里modules属性内的钩子方法是区分平台的,web、weex以及SSR它们调用VNode方法方式并不相同,比如这里是web, 所以vue在这里又使用了函数柯里化这个骚操作,在createPatchFunction内将平台的差异化抹平,从而__patch__方法只用接收新旧node即可。

// platformModules = [
//   attrs,
//   klass,
//   events,
//   domProps,
//   style,
//   transition
// ]

// baseModules = [
//   ref,
//   directives
// ]

const modules = platformModules.concat(baseModules)

Vue.prototype.__patch__ = createPatchFunction({ nodeOps, modules }) 

__patch__createPatchFunction方法内部返回的一个方法,它接受一个对象:

  • nodeOps属性:封装了操作原生Dom的一些方法的集合,如创建、插入、移除这些,再使用到的地方再详解。

  • modules属性:创建真实Dom也需要生成它的如class/attrs/style等属性。modules是一个数组集合,数组的每一项都是这些属性对应的钩子方法,这些属性的创建、更新、销毁等都有对应钩子方法,当某一时刻需要做某件事,执行对应的钩子即可。比如它们都有create这个钩子方法,如将这些create钩子收集到一个数组内,需要在真实Dom上创建这些属性时,依次执行数组的每一项,也就是依次创建了它们

来看一下createPatchFunction返回的patch方法是什么样的:

export function createPatchFunction (backend) {
  let i, j
  const cbs = {}

  const { modules, nodeOps } = backend    // 结构出 modules nodeOps

  // const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
  // modules = [
  //   {create: ƒ, update: ƒ}
  //   {create: ƒ, update: ƒ}
  //   {create: ƒ, update: ƒ}
  //   {create: ƒ, update: ƒ}
  //   {create: ƒ, update: ƒ}
  //   {create: ƒ, activate: ƒ, remove: ƒ}
  //   {create: ƒ, update: ƒ, destroy: ƒ}
  //   {create: ƒ, update: ƒ, destroy: ƒ}
  // ]     顺序可以看上一段代码传进来之前用concat拼接的

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  // 经过处理后的cbs长这样
  // {
  //   activate: [ƒ]
  //   create: (8) [ƒ, ƒ, ƒ, ƒ, ƒ, ƒ, ƒ, ƒ]
  //   destroy: (2) [ƒ, ƒ]
  //   remove: [ƒ]
  //   update: (7) [ƒ, ƒ, ƒ, ƒ, ƒ, ƒ, ƒ]
  // }
    

  ...
  ...
  ...

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {   // 没有新节点,有老节点,说明老节点被删除了,则直接销毁
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []
    if (isUndef(oldVnode)) {  // 有新节点,没有老节点,则直接创建新节点
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      const isRealElement = isDef(oldVnode.nodeType)   //  老节点是否是真实dom
      if (!isRealElement && sameVnode(oldVnode, vnode)) {      // 不是真实dom且老节点与新节点是同一个节点,则进行 patchVnode
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        首次渲染 走这里
        if (isRealElement) {   // 首次渲染传的是一个真实dom
          ...
          oldVnode = emptyNodeAt(oldVnode)    // 转为VNode格式覆盖自己
        }

        // replacing existing element
        const oldElm = oldVnode.elm    // 获取包装后的真实 Dom <div id='app'></div>
        const parentElm = nodeOps.parentNode(oldElm)   // 首次父节点为<body></body>
        ...
        // create new node
        createElm(  // 创建真实Dom
          vnode,    // 新 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)   //  下一个节点
        )
        // 将旧的节点从父节点中移除
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }

      }
    }
    ...
    return vnode.elm
  }
}

简单解释一下返回的patch方法里代码:

1、patch逻辑

  • 如果vnode不存在,而oldVnode存在,则调用invodeDestoryHook进行销毁旧的节点
  • 如果oldVnode不存在,而vnode存在,则调用createElm创建新的节点
  • 如果oldVnodevnode都存在  1)如果oldVnode不是真实节点且和vnode是相同节点(调用sameVnode比较),则调用patchVnode进行patch  2)如果oldVnode是真实DOM节点,则先把真实DOM节点转为Vnode,再调用createElm创建新的DOM节点,并插入到真实的父节点中,同时调用removeVnodes将旧的节点从父节点中移除。

人话就是,在patch方法里,之前有的东西现在没了,就删除;之前没的东西,现在有了,就创建;之前有现在也有的,就判断是否是相同的节点,不是相同的节点则创建新节点把之前老节点废弃;是相同节点则进入patchVode进行patch

判断两个节点是否为同一个节点,内部是这样定义的:

function sameVnode (a, b) {  // 是否是相同的VNode节点
  return (
    a.key === b.key && (  // 如平时v-for内写的key
      (
        a.tag === b.tag &&   // tag相同
        a.isComment === b.isComment &&  // 注释节点
        isDef(a.data) === isDef(b.data) &&  // 都有data属性
        sameInputType(a, b)  // 相同的input类型
      ) || (
        isTrue(a.isAsyncPlaceholder) &&  // 是异步占位符节点
        a.asyncFactory === b.asyncFactory &&  // 异步工厂方法
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

2.patchVnode逻辑

相同的节点会通过patchVnode来处理,那patchVnode里面是做啥。

function patchVnode (
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  // 如果这两个节点完全相同,则没必要patch了 直接返回
  if (oldVnode === vnode) {
    return
  }

  const elm = vnode.elm = oldVnode.elm

  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    // 静态节点把组件实例覆盖就行
    vnode.componentInstance = oldVnode.componentInstance
    return
  }

  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    // 调用组件的prepatch方法
    i(oldVnode, vnode)
  }

  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isDef(data) && isPatchable(vnode)) {
    // 是否可以 patch 里面主要判断vnode.tag是否存在
    // 这里做了一个 data 属性的全量更新
    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)) {
    // vnode没有text属性
    if (isDef(oldCh) && isDef(ch)) {
      // 都有子节点,且子节点不同,则调updateChildren更新子节点
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      // 只有新节点有子节点
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(ch)
      }
      // 新节点没有 text,老节点有text,则text更新设置为空
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      // 向oldVnode空的标签内插入vnode的children的真实dom
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      // 只有老节点有子节点,则把老节点全部移除
      removeVnodes(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 新节点没有text,老节点有text,则text更新设置为空
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // 老节点的text属性不等于新的text属性,则用新的替换掉老的
    nodeOps.setTextContent(elm, vnode.text)
  }
}

所以归纳一下patchVnode逻辑:

  • 如果vnodeoldVnode完全一致,则什么都不做处理,直接返回
  • 如果oldVnodevnode都是静态节点,且具有相同的key,并且当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnodeelmoldVnode.children都复制到vnode上即可
  • 如果vnode不是文本节点或注释节点  1)如果vnodechildrenoldVnodechildren都存在,且不完全相等,则调用updateChildren更新子节点  2)如果只有vnode存在子节点,则调用addVnodes添加这些子节点  3)如果只有oldVnode存在子节点,则调用removeVnodes移除这些子节点  4)如果oldVnodevnode都不存在子节点,但是oldVnode为文本节点或注释节点,则把oldVnode.elm的文本内容置为空
  • 如果vnode是文本节点或注释节点,并且vnode.textoldVnode.text不相等,则更新oldVnode的文本内容为vnode.text

3.updateChildren逻辑(重点中的重点)

如果vnode不是文本节点或注释节点,并且vnodechildrenoldVnodechildren都存在且不完全相等,则调用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)
  }

  // 定义了4种猜想,分别是:
  //   1.老节点的头和新节点的头是同一个节点
  //   2.老节点的尾和新节点的尾是同一个节点
  //   3.老节点的头和新节点的尾是同一个节点
  //   4.老节点的尾和新节点的头是同一个节点
  // 只要这4种情况命中,就能少一次while循环。如果都没命中,就只能双循环去找了
  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)) {   // 老开始节点和新开始节点相同 则patch这两个节点
      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 {
      // oldKeyToIdx = {
      //   '节点中的key0':0,
      //   '节点中的key1':1,
      //   ...
      // }
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      // 通过key 去找新子节点在老子节点中的位置
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]        // 新子节点在老子节点中的位置,也可能是 undefined
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)   // 遍历找新子节点在老子节点中的位置
      if (isUndef(idxInOld)) { // New element
        // 没有找到新子节点在老子节点里的位置,则代表没有,执行创建
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        // 找到了新子节点在老子节点里的位置
        vnodeToMove = oldCh[idxInOld]
        // 然后对比老子节点和新子节点是否是同一个节点,是就patch,不是就创建,如此递归
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          oldCh[idxInOld] = undefined      // 找到了对应的旧子节点 旧子节点中设置为 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)
  }
}

具体的演示流程推荐看这篇文章:Vue原理解析(八):一起搞明白令人头疼的diff算法
作者的图画得非常的清晰明了!

diff流程图

以上。

对数据渲染的过程有了更深的一层理解,从new Vue()开始,创建了一个vue是对象,会先进行

init初始化——>$mount()——>compile(若已经是render则该过程不需要)——>render——>创建VNode——>patch过程——>生成真实的DOM

参考:

Vue原理解析