Vue 2.0 patch 原理分析

本文基于vue-2.4.4源码进行分析

Vue 2.0开始,引入VirtualDOM

使用VirtualDOM而不使用真实DOM是出于性能优化的考虑。

真实DOM使用document.createElement创建DOM元素,但是这个方法会带来性能上的损失。

举个例子:

let div = document.createElement('div');
let count = 0
for(let k in div) {
    count++
}
console.log(count)  // 231

执行上面的代码,我们可以看到该方法创建的DOM元素的属性多达231个,但是我们真正需要的可能只有不到10%。

为了解决这个问题,VirtualDOM应运而生。它和真实DOM保持映射关系,每个VNode节点都存储了对应真实DOM节点的一些重要参数,当数据发生改变时,在改变真实DOM节点之前,会先比较相应的VNode的的数据,如果需要改变,才更新真实DOM。这样就可以通过操作VirtualDOM来提高直接操作DOM的效率和性能。

比较VNode数据这个操作就是我们今天要讨论的patch,在讨论之前,我们先简单说下VNode

VNode

在上篇Vue 2.0 模板编译源码分析中我们得出模板编译的结果是render function

render function的运行结果就是VNode, 参考src/core/instance/render.js

Vue.prototype._render = function (): VNode {
  ...
  const {
    render,
    staticRenderFns,
    _parentVnode
  } = vm.$options
  ... 
  vnode = render.call(vm._renderProxy, vm.$createElement)
  ...
}   

Vue 2.0中的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    // 元素标签
    this.data = data    // 属性
    this.children = children    // 子元素列表
    this.text = text
    this.elm = elm    //  对应的真实 DOM 元素
    this.ns = undefined
    this.context = context
    this.functionalContext = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false     // 是否被标记为静态节点
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }
}

它是真实DOM的简化版,与真实DOM一一对映。通过new实例化的VNode可以分为:EmptyVNode(注释节点)、TextVNode(文本节点)、ElementVNode(元素节点)、ComponentVNode(组件节点)、CloneVNode(克隆节点)等。

patch原理

再拉通一下整个思路,目前我们晓得

render function 生成 VNode,是在 vm._render 里完成的。

那么vm._render方法又是在什么时候调用的呢?

debugger一下代码,可以看到流程如下:

初始化时,通过render function 生成 VNode的同时进行Watcher的绑定。当数据发生会变化时,会执行_update方法,生成一个新的VNode对象,然后调用__patch__方法,比较新生成的VNode和旧的VNode,最后将差异(变化的节点)更新到真实的DOM树上。

patch(src/core/vdom/patch.js)所用的diff算法来源于snabbdom,只会在同层级进行比较,不会跨层级比较。图示如下:

diff algorithm (by Christopher Chedeau)

下面结合源码进行原理分析:

入参

patch方法接收6个参数:

function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
  ...
}
  • oldVnode: 旧的VNode或旧的真实DOM节点
  • vnode: 新的VNode
  • hydrating: 是否要和真实DOM混合
  • removeOnly: 特殊的flag,用于<transition-group>
  • parentElm: 父节点
  • refElm: 新节点将插入到refElm之前

流程

  1. 如果vnode不存在,但是oldVnode存在,说明是需要销毁旧节点,则调用invokeDestroyHook(oldVnode)来销毁oldVnode

    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
    
  2. 如果vnode存在,但是oldVnode不存在,说明是需要创建新节点,则调用createElm来创建新节点。

    if (isUndef(oldVnode)) {
     isInitialPatch = true  // 用于做延迟插值处理
     createElm(vnode, insertedVnodeQueue, parentElm, refElm)
    }   
    
  3. vnodeoldVnode都存在时

  • 3.1 如果oldVnode不是真实节点,并且vnodeoldVnode是同一节点时,说明是需要比较新旧节点,则调用patchVnode进行patch

  • 3.2 如果oldVnode是真实节点时

    • 3.2.1 如果oldVnode是元素节点,且含有data-server-rendered属性时,移除该属性,并设置hydratingtrue
    • 3.2.2 如果hydratingtrue时,调用hydrate方法,将Virtural DOM与真实DOM进行映射,然后将oldVnode设置为对应的Virtual DOM
  • 3.3 如果oldVnode是真实节点时或vnodeoldVnode不是同一节点时,找到oldVnode.elm的父节点,根据vnode创建一个真实的DOM节点,并插入到该父节点中的oldVnode.elm位置。如果组件根节点被替换,遍历更新父节点element。然后移除旧节点。

    {
        // 3. 当vnode和oldVnode都存在时
        const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
         // 3.1 如果oldVnode不是真实节点,并且vnode和oldVnode是同一节点时
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } else {
        if (isRealElement) {
            // 3.2 如果oldVnode是真实节点时
          // mounting to a real element
          // check if this is server-rendered content and if we can perform
          // a successful hydration.
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            // 3.2.1 如果oldVnode是元素节点,且含有`data-server-rendered`属性时
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
            // 3.2.2 如果hydrating为true时
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (process.env.NODE_ENV !== 'production') {
              warn(
                'The client-side rendered virtual DOM tree is not matching ' +
                'server-rendered content. This is likely caused by incorrect ' +
                'HTML markup, for example nesting block-level elements inside ' +
                '<p>, or missing <tbody>. Bailing hydration and performing ' +
                'full client-side render.'
              )
            }
          }
          // either not server-rendered, or hydration failed.
          // create an empty node and replace it
          oldVnode = emptyNodeAt(oldVnode)
        }
        // 3.3 
        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )
    
        if (isDef(vnode.parent)) {
          // component root element replaced.
          // update parent placeholder node element, recursively
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          while (ancestor) {
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)
            }
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)
              }
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            }
            ancestor = ancestor.parent
          }
        }
    
        if (isDef(parentElm)) {
            // 移除老节点
          removeVnodes(parentElm, [oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    
  1. 最后返回 vnode.elm

原理

由上面的流程我们知道了当vnodeoldVnode都存在、oldVnode不是真实节点,并且vnodeoldVnode是同一节点时,才会调用patchVnode进行patch

下面根据patchVnode源码分析patch的原理:

  1. 如果oldVnodevnode完全一致,则可认为没有变化,return
  2. 如果oldVnodeisAsyncPlaceholder属性为true时,跳过检查异步组件,return
  3. 如果oldVnodevnode都是静态节点,且具有相同的key,并且当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elmoldVnode.child都复制到vnode上,也不用再有其他操作,return
  4. 否则,如果vnode不是文本节点时
  • 4.1 如果vnodeoldVnode都有子节点并且两者的子节点不一致时,就调用updateChildren更新子节点。updateChildren方法详细的解析可参考解析vue2.0的diff算法,图示说明,很形象。

  • 4.2 如果只有vnode有子节点,则调用addVnodes创建子节点;

  • 4.3 如果只有oldVnode有子节点,则调用removeVnodes把这些节点都删除;

  • 4.4 如果oldVnodevnode都没有子节点,但是oldVnode是文本节点时,则把vnode.elm的文本设置为空字符串;

  1. 如果vnode是文本节点但是vnode.text != oldVnode.text时只需要更新vnode.elm的文本内容就可以。

原理流程图如下:

自此,Vue的patch原理就分析完了。

推荐阅读更多精彩内容