[Vue源码学习] _update(上)

371 阅读5分钟

系列文章

前言

在上一章节中,通过调用_render方法,最终生成了一个VNode节点,那么接下来,就会调用_update方法,Vue会根据这个VNode渲染成真实的DOM

_update

_update的代码如下所示:

/* 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方法中,Vue会根据是否是初次渲染,使用不同的参数调用__patch__方法,那么接下来,我们就来详细看看其内部是如何实现的。

patch

在看patch的实现之前,需要了解patch方法是如何构建出来的,因为对于VNode来说,它只是一个节点的描述符,而不同的平台需要使用各自原生的方法对VNode进行渲染,而对于Web平台来说,patch方法是在引入Vue时添加到原型上的,代码如下所示:

/* platforms/web/runtime/patch.js */
export const patch: Function = createPatchFunction({ nodeOps, modules })

/* platforms/web/runtime/index.js */
Vue.prototype.__patch__ = inBrowser ? patch : noop

可以看到,patch又是通过调用createPatchFunction方法构建的,而这里的nodeOpsmodules,都是与当前平台相关的代码,所以不同的平台只需要使用与之对应的nodeOpsmodules,就可以使用同一个createPatchFunction方法构建出适合当前平台的patch方法。createPatchFunction的源码在core/vdom/patch.js中,最终返回的patch方法如下所示:

/* core/vdom/patch.js */
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) {
        // ...
        // 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方法还是比较复杂的,因为里面包含了创建、更新、删除等逻辑,我们可以将其分为以下四种情况:

  1. 卸载组件:如果vnode不存在,说明当前组件没有需要渲染的内容,同时,如果oldVnode存在,就调用invokeDestroyHook方法,执行oldVnode的卸载逻辑:

    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
    
    function invokeDestroyHook(vnode) {
      let i, j
      const data = vnode.data
      if (isDef(data)) {
        if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
        for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
      }
      if (isDef(i = vnode.children)) {
        for (j = 0; j < vnode.children.length; ++j) {
          invokeDestroyHook(vnode.children[j])
        }
      }
    }
    
  2. 组件的首次渲染:如果oldVnode不存在,说明是组件的首次渲染,首先将标志位isInitialPatch设置为true,然后调用createElm方法,根据vnode生成真实的DOM,最后还会调用invokeInsertHook方法,将其内部包含的子组件添加到父占位符节点的data.pendingInsert中:

    let isInitialPatch = false
    const insertedVnodeQueue = []
    
    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    }
    
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    
    function invokeInsertHook(vnode, queue, initial) {
      // delay insert hooks for component root nodes, invoke them after the
      // element is really inserted
      if (isTrue(initial) && isDef(vnode.parent)) {
        vnode.parent.data.pendingInsert = queue
      } else {
        // ...
      }
    }
    

    当程序回到组件的父占位符节点时,又会调用initComponent方法,将pendingInsert中的节点提取到insertedVnodeQueue中:

    function initComponent(vnode, insertedVnodeQueue) {
      if (isDef(vnode.data.pendingInsert)) {
        insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert)
        vnode.data.pendingInsert = null
      }
      // ...
    }
    

    经过这样一层层的提取,就可以在根节点的insertedVnodeQueue中,统一执行insert钩子函数。

  3. 组件的对比更新:如果新旧节点都存在,并且通过sameVnode检测到它们是相同的节点,那么就不需要重新创建一个全新的DOM了,而是复用此DOM,然后通过对比vnodeoldVnode中的datachildren,做更新操作:

    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    }
    
    function sameVnode(a, b) {
      return (
        a.key === b.key && (
          (
            a.tag === b.tag &&
            a.isComment === b.isComment &&
            isDef(a.data) === isDef(b.data) &&
            sameInputType(a, b)
          ) || (
            isTrue(a.isAsyncPlaceholder) &&
            a.asyncFactory === b.asyncFactory &&
            isUndef(b.asyncFactory.error)
          )
        )
      )
    }
    
  4. 创建新节点并移除旧节点:如果以上的情况都不满足,说明需要根据vnode重新创建新的节点,然后根据oldVnode删除旧的节点,从而达到页面更新的逻辑。

从上面的代码中可以看出,patch中最关键的两个方法是createElmpatchVnode,这两个方法会在之后的两小节中详细介绍。在这之前,我们先来看看在执行patch的过程中,都运行了哪些hook

hooks

patch的过程中,其实包含了两种类型的hook,一种是与VNode相关的hook,一种是modules相关的hook

VNode相关的hook很好理解,在之前创建组件VNode时就已经见过了,这种类型的hook都是与VNode的操作相关的。

modules相关的hook主要是用来处理vnode.data中的数据,它会在创建patch方法时,进行预处理操作:

/* platforms/web/runtime/patch.js */
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

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

由于这里modules代表的是平台相关的模块,所以对于Web平台来说中,Vue支持对以下8种模块的处理,如下所示:

modules-hooks

可以看到,这里的模块都可以从VNodeData中找到映射,而且这些modules钩子函数也是伴随VNode hook进行处理的。

总结

patch方法中,Vue会根据新旧vnode的状态,执行不同的操作,同时在创建、更新、删除节点的时候,执行相应的VNode钩子函数,同时也会根据modules钩子函数处理vnode.data中的数据,将其作用在DOM上。