普通节点 patch 过程

331 阅读6分钟

在上一篇文章《render 渲染原理》,讲解了如何把 Vue 实例渲染成虚拟 DOM。沿着这一思路,将讲解如何把虚拟 DOM 渲染成真实 DOM。而对于虚拟 DOM,又分为普通 VNode 和组件 VNode,本文将详细分析普通 VNode 的渲染原理。

update 内部实现

按照惯例,沿着主线将普通节点 patch 过程整理成一张图,如下:

patch.png

对于普通节点 patch 过程,其实现逻辑是定义在 Vue 原型上的方法:_update ,位于 src/core/instance/lifecycle.js ,其实现逻辑如下:

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
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    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.
 }

_update 方法接收两个参数:

  • vnode:虚拟 DOM,即 render 函数渲染生成的虚拟节点
  • hydrating:是否为服务端渲染

从代码实现中可以看出,_update 作用有两个:首次渲染和更新渲染,即数据驱动视图变更;而本文只分析初始化渲染的逻辑。

先定义变量来保存数据,比如 prevElprevVnode 及保存当前激活实例 vm 。其实现逻辑最核心的是 patch,代码片段如下:

// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}

由于首次渲染,prevVnodenull,则会进入 if 逻辑,即执行首次渲染逻辑。那么 vm.__patch__ 又是如何定义的呢?

__patch__ 是定义在 Vue 原型上,位于 src/platforms/web/runtime/index.js,即

// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

__patch__ 方法在平台 web 和 weex 其实现逻辑是不一样的;与此同时,可以看到,在 web 平台其实现还跟环境有关。如果所处环境是浏览器,则指向 patch 方法;如果所处环境是服务器,则指向空函数,因为服务器端没有真实 DOM,无需将虚拟 DOM 渲染成真实 DOM。

pathc 方法的实现位于 src/platforms/web/runtime/patch.js,即

/* @flow */

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'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })

patch 方法的定义是调用 createPatchFunction 的返回值,这里传入一个参数,是一个对象,包含两个属性,分别为:nodeOpsmodules

nodeOps 封装了操作真实 DOM 的一系列方法,比如 createElementcreateElementNScreateTextNodecreateCommentinsertBeforeappendChild 等方法;而 modules 定义一些模块的钩子函数实现,比如设置属性 attrs、事件 event、样式 style 等。

createPatchFunction 方法具体实现如下:

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

  const { modules, nodeOps } = backend

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

  function emptyNodeAt (elm) { ... }

  function createRmCb (childElm, listeners) { ... }

  function removeNode (el) { ... }

  function isUnknownElement (vnode, inVPre) { ... }

  let creatingElmInVPre = 0

  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) { ... }

  function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) { ... }

  function initComponent (vnode, insertedVnodeQueue) { ... }

  function reactivateComponent (vnode, insertedVnodeQueue, parentElm, refElm) { ... }

  function insert (parent, elm, ref) { ... }

  function createChildren (vnode, children, insertedVnodeQueue) { ... }

  function isPatchable (vnode) { ... }

  function invokeCreateHooks (vnode, insertedVnodeQueue) { ... }

  // set scope id attribute for scoped CSS.
  // this is implemented as a special case to avoid the overhead
  // of going through the normal attribute patching process.
  function setScope (vnode) { ... }

  function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) { ... }

  function invokeDestroyHook (vnode) { ... }

  function removeVnodes (vnodes, startIdx, endIdx) { ... }

  function removeAndInvokeRemoveHook (vnode, rm) { ... }

  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { ... }

  function checkDuplicateKeys (children) { ... }

  function findIdxInOld (node, oldCh, start, end) { ... }

  function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) { ... }

  let hydrationBailed = false
  // list of modules that can skip create hook during hydration because they
  // are already rendered on the client or has no need for initialization
  // Note: style is excluded because it relies on initial clone for future
  // deep updates (#7063).
  const isRenderedModule = makeMap('attrs,class,staticClass,staticStyle,key')

  // Note: this is a browser-only function so we can assume elms are DOM nodes.
  function hydrate (elm, vnode, insertedVnodeQueue, inVPre) { ... }

  function assertNodeMatch (node, vnode, inVPre) { ... }

  return function patch (oldVnode, vnode, hydrating, removeOnly) { ... }
}

createPatchFunction 定义一系列辅助方法,最终返回 patch 方法。也就是说,vm._update 函数里调用的 vm.__patch__,实际上调用此处的方法 patch,即 patch 过程的核心逻辑实现。

patch 内部实现

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)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        if (isRealElement) {
          // 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)) {
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
            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)
        }

        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

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

        // update parent placeholder node element, recursively
        if (isDef(vnode.parent)) {
          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)
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              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]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }

        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

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

patch 方法接收四个参数:

  • oldVnode:表示旧的 VNode,它可以是一个 DOM 对象,也可以是不存在的
  • vnode :表示执行 vm._render 返回的 VNode 节点
  • hydrating:表示是否为服务端渲染
  • removeOnly:表示给 transition-group 使用

patch 的执行逻辑相对复杂,不同的场景有不同的处理方法。下面结合例子来分析首次渲染调用时的逻辑实现

new Vue({
    render: h => h('div', {
        attrs: {
            id: 'app'
        }
    }, [
        h('h1', '我是标题1'),
        h('h2', '我是标题2')
    ]),
}).$mount('#app')

回顾下在 _update 方法调用 __patch__

vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)

在执行 patch 时,传入的参数 vm.$elidapp 的 DOM 对象,而 vm.$el 的赋值是在 mountComponent 函数里面发生的;vnode 是执行 vm._render 函数渲染生成 VNode;hydrating 在非服务端情况下为 falseremoveOnlyfalse

确定入参后,我们就可以来看 patch 内部是如何实现的?

if (isUndef(vnode)) {
  if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
  return
}

由于传入的参数 vnode 是上一步 vm._render 渲染生成的 VNode,此处不为空,跳过此逻辑。

传入的 oldVnode 是不为空,if 分支执行后为 false,则进入到 else 分支。此时 oldVnode 是一个真实 DOM,isRealElement 则为 true ,进入 else 分支。

if (isRealElement) {
  ...
  // either not server-rendered, or hydration failed.
  // create an empty node and replace it
  oldVnode = emptyNodeAt(oldVnode)
}

// replacing existing element
const oldElm = oldVnode.elm    // 保存真实 DOM 节点 div#app
const parentElm = nodeOps.parentNode(oldElm)    // 保存当前节的父节点,此处指的是 body

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

通过方法 emptyNodeAtoldVnode 转换为一个 VNode 对象;声明变量 oldElmparentElm 保存值;调用方法 createElm 创建新节点,此方法为核心方法,稍后分析。

// destroy old node
if (isDef(parentElm)) {
  removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
  invokeDestroyHook(oldVnode)
}

invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)

由于创建了新节点,需要将旧节点删除。根据 createElm 递归生成的 vnode 插入顺序队列,执行相关的 insert 函数。

createElm 内部实现

  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) { ... }

该方法接收 7 个参数:

  • vnode :表示执行 vm._render 返回的 VNode 节点
  • insertedVnodeQueue :用来保存 vnode 已经转换为真实 DOM 的 vnode 队列
  • parentElm :表示父节点
  • refElm 表示参考节点
  • nested :表示是否嵌套
  • ownerArray:数组节点,用于保存节点
  • index :索引

作用是把虚拟节点 VNode 转换为真实的 DOM 节点,并且插入它的父节点。


if (isDef(vnode.elm) && isDef(ownerArray)) {
  // This vnode was used in a previous render!
  // now it's used as a new node, overwriting its elm would cause
  // potential patch errors down the road when it's used as an insertion
  // reference node. Instead, we clone the node on-demand before creating
  // associated DOM element for it.
  vnode = ownerArray[index] = cloneVNode(vnode)
}

在方法 patch 调用 createElm 时,没有传入参数 ownerArray,因此不满足条件,跳过此逻辑。

if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
  return
}

尝试创建子组件,而在此场景下其值为 false,因此跳过此逻辑。

接着有三个判断逻辑,分别为:

  • 判断 vnode 是否包含 tag ,如果包含,则在开发环境中简单对其做合法性校验,且调用平台 DOM 的操作方法去创建一个占位符元素
  • 判断 vnode 是否为注释节点,如果是的话,则调用平台 DOM 操作方法创建注释节点,且将其插入到父元素中
  • 如果以上两种都不满足的话,那么 vnode 是一个文本节点,则调用平台 DOM 操作方法创建文本节点,并将其插入到父元素中

在此 case 中,tagdiv,则进入到 tag 逻辑,代码实现如下:

if (isDef(tag)) {
  if (process.env.NODE_ENV !== 'production') {
    if (data && data.pre) {
      creatingElmInVPre++
    }
    if (isUnknownElement(vnode, creatingElmInVPre)) {
      warn(
        'Unknown custom element: <' + tag + '> - did you ' +
        'register the component correctly? For recursive components, ' +
        'make sure to provide the "name" option.',
        vnode.context
      )
    }
  }
  
  vnode.elm = vnode.ns
    ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
        setScope(vnode)
  
  /* istanbul ignore if */
  if (__WEEX__) {
    // in Weex, the default insertion order is parent-first.
    // List items can be optimized to use children-first insertion
    // with append="tree".
    const appendAsTree = isDef(data) && isTrue(data.appendAsTree)
    if (!appendAsTree) {
      if (isDef(data)) {
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      insert(parentElm, vnode.elm, refElm)
    }
    createChildren(vnode, children, insertedVnodeQueue)
    if (appendAsTree) {
      if (isDef(data)) {
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      insert(parentElm, vnode.elm, refElm)
    }
  } else {
    createChildren(vnode, children, insertedVnodeQueue)
    if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
    insert(parentElm, vnode.elm, refElm)
  }
  
  if (process.env.NODE_ENV !== 'production' && data && data.pre) {
    creatingElmInVPre--
  }
} 

在开发环境中,对 tag 做一些合法性校验,比如使用的组件是否注册,不符合的话则会在控制台抛出告警。接着调用平台操作 DOM 的方法创建一个占位符元素,赋值给 vnode.elm 。由于分析的平台是 web ,因此跳过 __WEEX__ 逻辑,直接进入 else 分支逻辑。

首先调用的方法是 createChildren ,具体实现如下:

function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(children)
    }
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

该方法接收三个参数:

  • vnode:表示执行 vm._render 返回的 VNode 节点
  • childrenvnode 子节点
  • insertedVnodeQueue:用来保存 vnode 已经转换为真实 DOM 的 vnode 队列

作用是遍历 vnode 子节点,递归调用方法 createElm 创建真实的 DOM,采用的是深度优先遍历算法。

这里有一处简单的校验,即在开发环境中,遍历检查节点设置的 key(如使用指令 v-for,需要对每个元素设置 key)是否重复。如果重复的话,则在控制台抛出告警。

调用 invokeCreateHooks 方法执行所有的 create 的钩子函数,并将已经转换为真实 DOM 的 vnode 保存到 insertedVnodeQueue 队列中,具体实现如下:

function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    if (isDef(i.create)) i.create(emptyNode, vnode)
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}

最后调用方法 insert 将真实 DOM 插入到父节点中,由于是递归调用,子节点会优先于父节点 insert,所以整个 vnode 树的插入顺序是先子后父。 看下方法 insert 的具体实现:

function insert (parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}

该方法接收三个参数:

  • parent:父节点
  • elmvnode 转换成真实的 DOM
  • ref:参考节点

该方法的实现逻辑挺简单的,即在满足判断条件时,调用 nodeOps 的辅助方法将子节点插入到父节点中,辅助方法位于 src/platform/web/runtime/node-ops.js,具体实现如下:

export function parentNode (node: Node): ?Node {
    return node.parentNode
}

export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
    parentNode.insertBefore(newNode, referenceNode)
}

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

从源码实现可以看出,创建真实 DOM 的过程中,其实调用的是原生 DOM 的 API 来操作的。

至此,我们就简单地分析了普通节点 patch 过程;同时数据和模板如何渲染成最终 DOM 也分析完了,借助 update 里提供的图片更直观地来描述从初始化 Vue 到整个渲染完成的过程:

data-template-to-dom.png

参考链接