Vue2.0源码分析:组件化(下)

746 阅读11分钟

组件化

如果觉得写得不错,请到GitHub给我一个Star

上一篇:Vue2.0源码分析:组件化(上)
下一篇:Vue2.0源码分析:编译原理(上)

由于掘金文章字数限制,不得不拆分上、下两篇文章。

update和patch

来回顾一下之前提到的mountComponent方法,它有这样一段代码:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

在之前的章节中,我们介绍了_render方法及其createElementcreateComponent的逻辑,知道_render返回的是一个VNode树形结构。而_update方法的作用就是利用这个VNode树形结构,来生成真正DOM节点。

这一章节,我们来分析一下update方法的实现以及其中patch的逻辑。

$forceUpdate

在正式介绍update/patch之前,我们先来认识一个API方法:$forceUpdate,这个方法是用来强制组件重新渲染的。在开发Vue应用的时候,我们有可能遇到过虽然我们的响应式数据更改了,但组件并没有正确渲染。当遇到这种情况的时候,我们可以调用$forceUpdate来强制组件重新进行渲染。它的实现代码如下:

Vue.prototype.$forceUpdate = function () {
  const vm: Component = this
  if (vm._watcher) {
    vm._watcher.update()
  }
}

我们可以看到$forceUpdate方法的代码非常简单,它首先判断了vm._watcher是否存在,也就是判断当前组件的render watcher是否存在,如果存在则调用render watcherupdate方法。在调用update方法后,这里的过程就跟派发更新的过程相同 ,因为这里是render watcher,因此它最后会调用下面这段代码,也就是我们这个章节的核心update/patch

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

update

_update其实是一个内部私有方法,它的调用时机有两个:初始化挂载阶段派发更新阶段,其代码是在lifecycleMixin方法中被定义的,如下:

export function lifecycleMixin (Vue: Class<Component>) {
  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代码并不是很多,其核心就是调用__patch__方法。在介绍__patch__之前,为了更好的理解后续相关的逻辑,我们先介绍几个小的知识点。

  • activeInstance:从命名可以看出来,它的意思是当前激活的实例对象。我们知道组件渲染是一个递归的过程,渲染顺序是先子后父。那么在这种递归渲染的过程中,我们必须正确保证一对引用关系:当前渲染的组件实例以及其父级组件实例。activeInstance就是当前渲染的组件实例,它是一个模块变量:
export let activeInstance: any = null

_update方法中,它使用setActiveInstance来设置当前激活的实例,使用restoreActiveInstance来恢复,setActiveInstance方法定义如下:

const restoreActiveInstance = setActiveInstance(vm)
export function setActiveInstance(vm: Component) {
  const prevActiveInstance = activeInstance
  activeInstance = vm
  return () => {
    activeInstance = prevActiveInstance
  }
}

我们可以看到在setActiveInstance中,它首先定义了闭包变量保存了当前激活的实例,然后把activeInstance设置为当前的参数vm,最后返回了一个函数,这个函数的目的是用来恢复activeInstance到上一个缓存下来的激活实例,也就是调用restoreActiveInstance方法。

既然当前渲染的实例已经解决了,那么我们来看一下父级在这个过程中是如何保证的。在initLifecycle的过程中,有这样一段代码:

export function initLifecycle (vm: Component) {
  const options = vm.$options
  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }
  vm.$parent = parent
  vm.$root = parent ? parent.$root : vm
  // ...省略代码
}

initLifecycle方法执行的过程中,通过while循环来保存parentchildren父子关系,对于父级来说,$children中存储了它所有的子节点,对于子级来说,可以通过vm.$parent获取到它的父级。

  • _vnode和$vnode_vnode$vnode也是一对父子关系,其中_vnode表示当前VNode节点,$vnode表示其父节点。我们来回顾一下_render方法,它有这样几段代码:
Vue.prototype._render = function () {
  // ...省略代码
  const { render, _parentVnode } = vm.$options
  vm.$vnode = _parentVnode
  let vnode
  try {
    vnode = render.call(vm._renderProxy, vm.$createElement)
  }
  vnode.parent = _parentVnode
  return vnode
}

在介绍完这两组对应关系后,我们来看一下最核心的__patch__方法的实现,这个方法是多平台公用方法,它在src/platforms/web/runtime/index.jssrc/platforms/weex/runtime/index.js文件中都有定义,我们主要看第一种,其定义代码如下:

import { patch } from './patch'
Vue.prototype.__patch__ = inBrowser ? patch : noop

在以上代码中,它使用inBrowser判断了当前是否处于浏览器环境,如果是则赋值为path,否则就是noop空函数。这样判断是因为Vue还可以运行在node服务端。接下来,我们在path.js中看一下path方法是如何定义的:

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'

const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })

在这里我们可以看到,patch赋值的是createPatchFunction方法的调用结果,我们先不看createPatchFunction是如何定义的,我们先看来一下它传递的参数。

  • nodeOps: nodeOps是引入了web/runtime/node-ops.js文件中的内容,我们选取一部分来举例说明它到底是什么。
export function createElement (tagName: string, vnode: VNode): Element {
  const elm = document.createElement(tagName)
  if (tagName !== 'select') {
    return elm
  }
  // false or null will remove the attribute but undefined will not
  if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
    elm.setAttribute('multiple', 'multiple')
  }
  return elm
}
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)
}

我们可以发现,node-ops.js文件中封装的方法,实际上就是对真实DOM操作的一层封装,传递nodeOps的目的是为了在虚拟DOM转成真实DOM节点的过程中提供便利。

  • modules: modulesplatformModulesbaseModules两个数组合并的结果,其中baseModules是对模板标签上refdirectives各种操作的封装。platformModules是对模板标签上classstyleattr以及events等操作的封装。

小结

  1. update这一节,我们知道了首次渲染和派发更新重新渲染的patch是有一点差异的,其差异为首次渲染时提供的根节点是一个真实的DOM元素,在派发更新重新渲染时提供的是一个VNode,这里差异的逻辑是在下面这段代码中:
if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}
  1. 在父子组件递归渲染的时候,首先渲染子组件,子组件渲染完毕后才会去渲染父组件,在这个递归的过程中,activeInstance始终指向当前渲染的组件实例。同时根据父子组件递归渲染的顺序,我们可以知道父子组件关于createmount两个生命周期的执行顺序:
// parent beforeCreate
// parent created
// parent beforeMount
// child beforeCreate
// child created
// child beforeMount
// child mounted
// parent mounted
  1. render函数执行会得到一个VNode的树形结构,update的作用就是把这个虚拟DOM节点树转换成真实的DOM节点树。因此结合前面介绍的所有内容,我们可以得到一个从实例初始化到最终渲染成真实DOM到视图的一个主线流程图。
流程图

patch

在上一个章节,我们遗留了一个createPatchFunction方法还没有分析,在patch这个章节,我们主要任务就是弄清楚createPatchFunction的实现原理。

因为在v2.6.11版本中,createPatchFunction方法代码非常多,因此我们采取分段来说明,建议一边看文章一边对照源码学习。

hooks钩子函数

createPatchFunction最开始,它首先处理了一些hooks钩子函数,代码如下:

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

注意:这里定义的hooks与我们组件的生命周期钩子函数很相似,但它并不是处理组件生命周期的,它们是在VNode钩子函数执行阶段或者其它时机调用的,例如:在VNode插入的时候,需要执行created相关的钩子函数,在VNode移除的时候,需要执行remove/destroy相关的钩子函数。

代码分析:

  • 首先通过解构获取到modules,它是一个数组,每个数组元素都有可能定义createupdateremove以及destroy等钩子函数,它可能是下面这样:
const modules = [
  {
    created: function () {},
    update: function () {}
  },
  {
    update: function () {},
    remove: function () {}
  },
  {
    remove: function () {},
    destroy: function () {}
  }
]
  • 解构获取到modules后,使用for循环来遍历modules,目的是要把hooks当做keyhooks的函数当做value,循环遍历完毕后,它可能是下面这样:
// 遍历前
const cbs = {}

// 遍历后
const cbs = {
  create: [ function () {}, function () {} ],
  activate: [],
  update: [ function () {}, function () {}, function () {} ],
  remove: [ function () {} ],
  destroy: [ function () {} ]
}
  • create钩子函数为例,它会在合适的时机调用,调用create的代码如下:
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)
  }
}

invokeCreateHooks方法中,它通过for来遍历cbs.create钩子函数数组,然后依次调用这里面的每一个方法。在方法的最后,它还调用了VNode的2个钩子函数,在createComponent章节中我们提到过vnode.data.hook

const componentVNodeHooks = {
  init: function () {},     // 初始化时触发
  prepatch: function () {}, // patch之前触发
  insert: function () {},   // 插入到DOM时触发
  destroy: function () {}   // 节点移除之前触发
  ...
}

返回patch函数

我们回顾一下之前的代码:

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

我们可以发现,patch赋值的是createPatchFunction方法调用的结果,那么我们看一下createPatchFunction方法内部是如何定义这个返回函数的:

export function createPatchFunction (backend) {
  // ...
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) { 
      // ...
    } else {
      // ...
    }
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
}

patch返回函数的最开始,它判断了vnode是否为undefined或者null,如果是并且oldVnode条件判断为真,那么它会调用invokeDestroyHook。执行invokeDestroyHook是为了触发子节点的销毁动作,那么很显然这段代码会在组件销毁的时候执行,我们可以在$destroy方法中看到下面这段代码($destroy方法我们会在组件生命周期小节中介绍):

Vue.prototype.$destroy = function () {
  // ...
  vm.__patch__(vm._vnode, null)
  // ...
}

判断完vnode后,我们发现它对oldVnode也进行了判断,因此会有一个if/else分支逻辑。那么什么时候走if分支逻辑?什么时候走else分支逻辑?

当使用isUndef方法对oldVnode逻辑判断为真时,证明此时的oldVnode没有,那么它表示组件是首次渲染,因此会走if分支逻辑。当挂载根实例或者派发更新的时候,此时的oldVnode存在,它会走else分支逻辑。由于这两块的分支逻辑相对来说比较复杂,因此我们会在后续单独划分模块说明。

在返回函数patch的最后,它调用了invokeInsertHook,这个方法的目的是为了触发VNodeinsert钩子函数,其代码如下:

 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 {
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}

对于VNodeinsert钩子函数而言,它主要做的事情就是触发组件的mounted钩子函数。对于组件一系列生命周期,我们会在下一个章节中介绍,这里只做了解。

根实例patch

我们回顾一下_update方法,它有这样一段代码:

if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}

在首次渲染的时候,对于根实例而言,它传递的是一个真实的DOM节点,也就是说在patch返回函数中,第一个参数oldVnode不仅为真值,并且它还是一个真实的DOM节点。因此在patch返回函数中,它走下面这些代码:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...
  if (isUndef(oldVnode)) {
    // ...
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // ...
    } 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)
      )
      // ...

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

因为oldVnode参数为一个真实的DOM节点,所以isRealElement变量为true,它会调用emptyNodeAt。这个方法的作用是把一个真实DOM转换成一个VNode实例,其代码如下:

function emptyNodeAt (elm) {
  return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}

紧接着调用createElm方法,这个方法的主要作用就是把VNode实例转换成真实的DOM节点,我们会在后面的章节中单独介绍createElm,这里只做了解。

在代码最后,它通过判断parentElm是否为真,来调用不同的方法,我们以Vue-Cli脚手架生成的App.vue组件为例。对于根实例而言,它的挂载节点为<div id='app'></div>,因此此时的parentElbody节点,条件判断为真,因此调用removeVnodes方法,我们看一下这个方法的实现代码:

function removeVnodes (vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        removeNode(ch.elm)
      }
    }
  }
}
function removeAndInvokeRemoveHook (vnode, rm) {
  if (isDef(rm) || isDef(vnode.data)) {
    let i
    const listeners = cbs.remove.length + 1
    if (isDef(rm)) {
      // we have a recursively passed down rm callback
      // increase the listeners count
      rm.listeners += listeners
    } else {
      // directly removing
      rm = createRmCb(vnode.elm, listeners)
    }
    // recursively invoke hooks on child component root node
    if (isDef(i = vnode.componentInstance) && isDef(i = i._vnode) && isDef(i.data)) {
      removeAndInvokeRemoveHook(i, rm)
    }
    for (i = 0; i < cbs.remove.length; ++i) {
      cbs.remove[i](vnode, rm)
    }
    if (isDef(i = vnode.data.hook) && isDef(i = i.remove)) {
      i(vnode, rm)
    } else {
      rm()
    }
  } else {
    removeNode(vnode.elm)
  }
}

我们可以看到,在removeVnodes方法中,它会删除旧的id等于app的节点,然后创建一个新的idapp的节点。看到这里,我们就能明白Vue官网上面这样一段话了: 所有的挂载元素会被 Vue 生成的 DOM 替换。因此不推荐挂载 root 实例到 html 或者 body 上

组件patch

patch返回函数中,对于组件的首次渲染和派发更新渲染处理逻辑是不相同的,其中的差别体现在如下代码:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...
  if (isUndef(oldVnode)) {
    // 组件首次渲染
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 组件派发更新渲染
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // ...
    }
  }
}

在组件update/patch章节,我们不会分析patchVnode方法,而是把它放在后面编译章节中。在这里,我们只需要看组件首次渲染即可。对于组件的首次渲染而言,依然还是调用createElm方法,不过这里要注意,它只传递了2个参数。

createElm

在上面两个小节,我们都提到过createElm,也知道它的主要作用是把VNode实例转换成真实的DOM节点,在这个小节我们来详细介绍createElm方法。

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...
  vnode.isRootInsert = !nested // for transition enter check
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.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__) {
      // ...
    } 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--
    }
  } else if (isTrue(vnode.isComment)) {
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}

我们可以在上面看到createElm方法的精简代码,它有几个主要的步骤:创建组件节点创建普通节点创建注释节点以及创建文本节点

  • 创建组件节点:在createElm方法的最开始,它通过调用createComponent方法尝试创建一个组件节点,如果vnode是一个组件vnode则返回true,并且提前return终止createElm方法,否则返回false。我们来看一下createComponent方法的实现代码:
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode, false /* hydrating */)
    }
    // after calling the init hook, if the vnode is a child component
    // it should've created a child instance and mounted it. the child
    // component also has set the placeholder vnode's elm.
    // in that case we can just return the element and be done.
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

对于组件vnode而言,判断其data条件是满足的,条件满足以后它处理了i,将其赋值为i.init。其实这里的init就是组件vnodeinit钩子函数,其代码如下:

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    } else {
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
  }
  // ...
}

i方法执行时,它会在代码中通过createComponentInstanceForVnode来创建Vue实例,然后调用实例的$mount方法来挂载子组件。因为在这里调用了子组件的$mount方法,所以子组件会从头开始递归走一遍update/patch的过程,当子组件处理完毕后,会把子组件对应的真实DOM节点树插入到父级中该组件占位符的位置。这样经过一层层递归,反复执行update/patch的方式就可以构建成一个完整的组件树形结构。

  • 创建普通节点:如果VNode实例的tag属性为真,则首先校验一遍tag是否为正确的标签,如果不是则会提示非法标签。如果是则先调用createChildren方法处理其子节点,子节点处理完毕后再调用insert直接插入到父级中。
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)))
  }
}

我们可以看到在createChildren方法中,它首先判断了VNodechildren子节点是否为数组,如果不是则直接创建一个文本节点插入到父级中,如果是则遍历子节点数组,然后递归调用createElm。根据以上分析,我们可以知道创建节点的过程是一个深度优先遍历的过程,子节点首先会被创建然后插入到其父级下面,最后才是父节点。因为子节点会优先创建并被插入,因此子节点会首先调用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)
    }
  }
}

其中insertBeforeappendChild是对真实DOM的一层封装。

export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}
export function appendChild (node: Node, child: Node) {
  node.appendChild(child)
}
  • 创建注释节点和创建文本节点:创建注释节点和创建文本节点非常简单,它们分别调用了createCommentcreateTextNode,其实这两个方法是对原始DOM操作的一层封装而已:
export function createTextNode (text: string): Text {
  return document.createTextNode(text)
}
export function createComment (text: string): Comment {
  return document.createComment(text)
}

组件生命周期

在介绍完组件patch章节后,从new Vue实例化到最终渲染成真实DOM到视图的主线过程我们已经介绍完毕了,那么我们回顾一下这个过程,再看组件生命周期,在Vue.js官网中有这样一张组件生命周期流程图。

组件生命周期

callhook

在介绍生命周期函数之前,我们先来看一下callHook方法的实现,它是定义在src/core/instance/lifecycle.js文件中的一个方法,其代码如下:

export function callHook (vm: Component, hook: string) {
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget()
  const handlers = vm.$options[hook]
  const info = `${hook} hook`
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      invokeWithErrorHandling(handlers[i], vm, null, vm, info)
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
  popTarget()
}

代码分析:

  • 我们可以看到在for遍历之前,使用了pushTarget,在遍历之后使用了popTargetpushTargetpopTarget在之前的章节中我们介绍过,这里主要提一个issue 7573,你在这个issue上面可以看到为什么要添加这两段代码。
  • 通过在this.$options对象上拿到hook参数对应callback数组,然后使用for循环遍历,在每个循环中通过invokeWithErrorHandling来触发回调函数。invokeWithErrorHandling方法是定义在src/core/util/error.js文件中的一个方法,其代码如下:
export function invokeWithErrorHandling (
  handler: Function,
  context: any,
  args: null | any[],
  vm: any,
  info: string
) {
  let res
  try {
    res = args ? handler.apply(context, args) : handler.call(context)
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(e => handleError(e, vm, info + ` (Promise/async)`))
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true
    }
  } catch (e) {
    handleError(e, vm, info)
  }
  return res
}

我们可以看到invokeWithErrorHandling方法的代码不是很多,核心就是下面这段代码,其它属于异常处理。

res = args ? handler.apply(context, args) : handler.call(context)
  • for循环遍历之后,它判断了vm._hasHookEvent,你可能会很好奇这个内部属性在哪里定义的?是做什么的?在initEvents方法中,首先默认设置这个属性为false,代码如下:
export function initEvents (vm: Component) {
  // ...
  vm._hasHookEvent = false
  // ...
}

在事件中心$on方法中,它根据正则条件判断,如果判断为真则赋值为true,代码如下:

Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
  const vm: Component = this
  if (Array.isArray(event)) {
    for (let i = 0, l = event.length; i < l; i++) {
      vm.$on(event[i], fn)
    }
  } else {
    (vm._events[event] || (vm._events[event] = [])).push(fn)
    // optimize hook:event cost by using a boolean flag marked at registration
    // instead of a hash lookup
    if (hookRE.test(event)) {
      vm._hasHookEvent = true
    }
  }
  return vm
}

_hasHookEvent属性为真,组件会触发对应的生命周期钩子函数,那么我们可以利用这个功能做两件事情:监听子组件生命周期监听组件自身生命周期

假设我们有如下组件:

<template>
  <div id="app">
    <hello-world @hook:created="handleChildCreated" :msg="msg" />
  </div>
</template>
<script>
export default {
  name: 'App',
  data () {
    return {
      msg: 'message'
    }
  },
  methods: {
    handleChildCreated () {
      console.log('child created hook callback')
    }
  },
  created () {
    const listenResize = () => {
      console.log('window resize callback')
    }
    window.addEventListener('resize', listenResize)
    this.$on('hook:destroyed', () => {
      window.removeEventListener('resize', listenResize)
    })
  }
}
</script>

代码分析:

  • template模板中,我们可以使用@hook:xxx的形式来监听子组件对应的生命周期,当对应的生命周期函数被触发的时候,会执行我们提供的回调函数,这种做法对于需要监听子组件某个生命周期的需求来说十分有用。
  • 在撰写Vue应用的时候,我们经常需要在created/mounted等生命周期中监听resize/scroll等事件,然后在beforeDestroy/destroyed生命周期中移除。对于这种需求,我们可以把逻辑写在同一个地方,而不是分散在两个生命周期中,这对于需要监听自身生命周期的需要来说也十分有用。

生命周期

beforeCreate和created

我们先来看beforeCreatecreated这一对钩子函数,它们是在this._init方法中被触发的:

Vue.prototype._init = function () {
  // ...
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  callHook(vm, 'created')
  // ...
}

beforeCreatecreated生命周期中间,它调用了三个方法,这几个方法是用来初始化injectdatapropsmethodscomputedwatch以及provide等这些配置选项的。那么我们可以得出一个结论,以上这些属性我们只有在created中才可以访问到,在beforeCreate中访问不到,因为还没有初始化。

beforeMount和mounted

在前面介绍$mount方法的时候,我们提到过beforeMountmounted这两个方法,它们是在mountComponent中被触发的,代码如下:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // ...
  callHook(vm, 'beforeMount')
  let updateComponent
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    // ...
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  // ...
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

我们可以看到,在mountComponent方法的最前面,它首先调用了beforeMount方法,然后开始执行vm._update(),这个方法在组件首次渲染和派发更新时递归渲染父子组件的时候被调用。

在渲染完毕后,它判断了vm.$vode == null,如果条件满足才会触发mounted方法。你可能会很奇怪为什么这样做?在之前介绍update/path章节的时候,我们提到过一对父子关系:vm._vnodevm.$vnode,其中vm.$vnode表示父级的vnode。那么什么时候vm.$vnode会为null呢?答案是只有根实例,因为只有根实例才会满足这个条件,也就是说这里触发的是根实例的mounted方法,而不是组件的mounted方法。

根据beforeMountmounted的调用时机,我们可以知道:beforeMount生命周期是在vm._update()之前调用的,因此在这个生命周期的时候,我们还无法获取到正确的DOM。而mounted生命周期是在vm._update()方法之后执行的,所以我们可以在这个生命周期获取到正确的DOM

patch的时候,我们提到过VNode有一些钩子函数,我们来回顾一下:

const componentVNodeHooks = {
  init: function () {},
  prepatch: function () {},
  insert: function (vnode) {
    const { context, componentInstance } = vnode
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      callHook(componentInstance, 'mounted')
    }
    // ...
  },
  destroy: function () {}
}

其中,在insert钩子函数被触发的时候,它也触发了其组件的mounted方法,因此组件的mounted生命周期是在VNode触发insert钩子函数的时候被调用的。

beforeUpdate和updated

beforeUpdateupdated这一对生命周期钩子函数,是在派发更新的过程中被触发的。我们回顾一下依赖收集/派发更新这两个小节的内容,当某个响应式变量值更新的时候,会触发setter

Object.defineProperty(obj, key {
  set: function reactiveSetter (newVal) {
    // ...
    dep.notify()
  }
})

setter中会调用dep.notify()方法,去通知观察者更新,在notify实现方法中,它遍历了其subs数组,然后依次调用update()方法。

export default class Dep {
  // ...
  notify () {
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

这些Watcher实例的update最后会走到flushSchedulerQueue方法,在这个方法中会调用一个callUpdatedHooks方法

function flushSchedulerQueue () {
  // ...
  callUpdatedHooks(updatedQueue)
}
function callUpdatedHooks (queue) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'updated')
    }
  }
}

callUpdatedHooks这个方法里面,它会遍历queueWatcher实例队列,在每个遍历的过程中,会触发vmupdated方法。当updated钩子函数被触发后,就代表派发更新阶段已经完成。

以上是对updated钩子函数的介绍,那么beforeUpdate呢,其实它是在实例化render watcher的时候被处理的。

export function mountComponent () {
  // ...
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
}

我们可以看到,在实例化render watcher的时候,它给第四个参数传对象递了一个before属性,这个属性会被赋值到Watcher实例的before属性上。然后在flushSchedulerQueue方法遍历queue队列的时候,它首先判断了watcher.before是否存在,存在则调用这这个方法。

function flushSchedulerQueue () {
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    // ...
  }
  // ...
  callUpdatedHooks(updatedQueue)
}

beforeDestroy和destroyed

无论是beforeDestroy还是destroyed生命周期,都是在vm.$destroy实例方法中被触发的,这个方法它是在lifecycleMixin中被定义的,其代码如下:

export function lifecycleMixin (Vue) {
  // ..
  Vue.prototype.$destroy = function () {
    const vm: Component = this
    if (vm._isBeingDestroyed) {
      return
    }
    callHook(vm, 'beforeDestroy')
    vm._isBeingDestroyed = true
    // remove self from parent
    const parent = vm.$parent
    if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
      remove(parent.$children, vm)
    }
    // teardown watchers
    if (vm._watcher) {
      vm._watcher.teardown()
    }
    let i = vm._watchers.length
    while (i--) {
      vm._watchers[i].teardown()
    }
    // remove reference from data ob
    // frozen object may not have observer.
    if (vm._data.__ob__) {
      vm._data.__ob__.vmCount--
    }
    // call the last hook...
    vm._isDestroyed = true
    // invoke destroy hooks on current rendered tree
    vm.__patch__(vm._vnode, null)
    // fire destroyed hook
    callHook(vm, 'destroyed')
    // turn off all instance listeners.
    vm.$off()
    // remove __vue__ reference
    if (vm.$el) {
      vm.$el.__vue__ = null
    }
    // release circular reference (#6759)
    if (vm.$vnode) {
      vm.$vnode.parent = null
    }
  }
}

我们可以看到,在$destroy方法的最开始,它首先触发了beforeDestroy生命周期,随后又处理了一些其它操作:在父组件的$children移除自身移除自身依赖触发子组件销毁动作以及移除事件监听等。

接下来,我们以上面这几个步骤来说明:

  • 在父组件的children移除自身:当某个组件销毁的时候,我们需要从其父组件的$children列表中移除自身,以下面代码为例:
<template>
  <div class="parent">
    <child-component />
  </div>
</template>

ChildComponent组件销毁之前,ParentComponent组件的$children数组保存了其引用关系,当ChildComponent销毁的时候,为了正确保持这种引用关系,我们需要从$children列表中移除。

// 展示使用,实际为vm实例
// 移除前
const $children = ['child-component', ...]

// 移除后
const $children = [...]
  • 移除自身依赖:在之前,我们提到过vm._watchers维护了一份观察者数组,它们都是Watcher实例,另外一个vm._watcher指的是当前组件的render watcher。当组件销毁的时候,需要把这些观察者移除掉,它们都通过Watcher实例的teardown方法来实现,其代码如下:
export default class Watcher {
  // ...
  teardown () {
    if (this.active) {
      // remove self from vm's watcher list
      // this is a somewhat expensive operation so we skip it
      // if the vm is being destroyed.
      if (!this.vm._isBeingDestroyed) {
        remove(this.vm._watchers, this)
      }
      let i = this.deps.length
      while (i--) {
        this.deps[i].removeSub(this)
      }
      this.active = false
    }
  }
}
  • 触发子组件销毁动作:在移除Watcher以后,它随后调用了vm.__patch__方法,我们在之前update/patch章节介绍过这个方法,这里注意它第二个参数传递了null,我们回顾一下patch方法的实现:
export function createPatchFunction (backend) {
  // ...
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
    // ...
  }
}

patch方法中,当我们传递的第二个参数vnodenull的时候,它会调用invokeDestroyHook方法,这个方法的代码如下:

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

这个方法的主要作用就是递归调用子组件VNodedestroy钩子函数,我们来看一下VNode钩子函数destroy具体做了哪些事情:

const componentVNodeHooks = {
  // ...
  destroy (vnode: MountedComponentVNode) {
    const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy()
      } else {
        deactivateChildComponent(componentInstance, true /* direct */)
      }
    }
  }
}

我们可以看到,在destroy钩子函数中,如果忽略keep-alive相关的逻辑,它的核心还是调用组件的$destroy()方法。

小结:组件销毁的过程,应该是从父组件开始,然后递归销毁子组件,当子组件都销毁完毕时,父组件基本完成了销毁动作。因此父子组件关于beforeDestroydestroyed这两个生命周期钩子函数的执行顺序为:

// parent beforeDestroy
// child beforeDestroy
// child destroyed
// parent destroyed
  • 移除事件监听:在前面我们提到当子组件完成销毁动作时,父组件基本也完成了销毁动作。这是因为,在使用callHook触发destroyed生命周期钩子函数之后,我们还需要移除相关的事件监听,它使用$off来实现,我们回顾一下代码:
Vue.prototype.$off = function (
  event?: string | Array<string>,
  fn?: Function
): Component {
  const vm: Component = this
  // all
  if (!arguments.length) {
    vm._events = Object.create(null)
    return vm
  }
  // ...
  return vm
}

当我们不传递任何参数的时候,它直接把vm._events赋值为一个空对象,这样就达到了移除事件监听的目的。

activated和deactivated

这两个生命周期方法是与keep-alive内置组件强相关的生命周期钩子函数,因此我们会把这两个钩子函数的介绍放在之后的keep-alive小节。

组件注册

在开发Vue应用的时候,我们通常有两种注册组件的方式:全局注册和局部注册。这两种注册组件的方式结果是不同的,全局注册的组件可以在整个应用中直接使用,局部注册的组件只能在当前组件中使用。在这一章节,我们来分析一下在Vue中,是如何局部注册和全局注册组件的。

注意:Vue中有一些组件不需要进行注册就可以直接使用,这些组件就是内置组件:keep-alive, transitiontransition-group以及component。对于这些内置组件,我们在这个章节并不会去介绍,而是在后面的章节中单独划分一个章节去分析。

对于需要全局注册的组件而言,我们使用Vue.component方法来注册我们的组件,这个方法其实是在src/core/global-api/assets.js文件中的initAssetRegisters被定义的,其代码如下:

export const ASSET_TYPES = ['component', 'directive','filter']
export function initAssetRegisters (Vue: GlobalAPI) {
  ASSET_TYPES.forEach(type => {
    Vue[type] = function (
      id: string,
      definition: Function | Object
    ): Function | Object | void {
      if (!definition) {
        return this.options[type + 's'][id]
      } else {
        /* istanbul ignore if */
        if (process.env.NODE_ENV !== 'production' && type === 'component') {
          validateComponentName(id)
        }
        if (type === 'component' && isPlainObject(definition)) {
          definition.name = definition.name || id
          definition = this.options._base.extend(definition)
        }
        if (type === 'directive' && typeof definition === 'function') {
          definition = { bind: definition, update: definition }
        }
        this.options[type + 's'][id] = definition
        return definition
      }
    }
  })
}

代码分析:当我们正确给Vue.component传递参数的时候,它会走else分支逻辑,在else分支逻辑中,对于组件而言它首先使用validateComponentName来校验组件名是否合法,其代码如下:

export function validateComponentName (name: string) {
  if (!new RegExp(`^[a-zA-Z][\\-\\.0-9_${unicodeRegExp.source}]*$`).test(name)) {
    warn(
      'Invalid component name: "' + name + '". Component names ' +
      'should conform to valid custom element name in html5 specification.'
    )
  }
  if (isBuiltInTag(name) || config.isReservedTag(name)) {
    warn(
      'Do not use built-in or reserved HTML elements as component ' +
      'id: ' + name
    )
  }
}

对于组件名而言,一方面它需要合法,另外一方面不能是内置或保留html标签。在校验通过后,它调用this.options._base.extend方法,实际上相当于调用Vue.extend方法来让一个组件对象转换成构造函数形式,extend方法的具体实现我们在之前已经详细介绍过。在转换成构造函数完毕后,又在其对应的options上进行了赋值。根据Vue.component方法的实现,我们可以使用如下案例来表示:

import Vue from 'vue'
import HelloWorld from '@/components/HelloWorld.vue'
// 注册前
const options = {
  components: {}
}

// 注册
Vue.component('HelloWorld', HelloWorld)

// 注册后
const options = {
  components: {
    HelloWorld: function VueComponent () { ... }
  }
}

既然组件已经注册完毕了,那么我们现在想两个问题:全局注册的组件到哪里去了?使用全局注册的组件的时候是如何查找的?

回答第一个问题的时候,我们先回顾一下components选项是如何合并的:

function mergeAssets (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): Object {
  const res = Object.create(parentVal || null)
  if (childVal) {
    process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)
    return extend(res, childVal)
  } else {
    return res
  }
}
strats.component = mergeAssets

因为全局注册的组件都在Vue.options.components选项上,根据以上合并策略,我们发现全局注册的组件最后都会合并到子组件的components选项的原型上,例如:

// 全局注册后
const baseVueOptions = {
  components: {
    HelloWorld: function VueComponent () { ... }
  }
}

// 合并后
const childOptions = {
  components: {
    __proto__: {
      HelloWorld: function VueComponent () { ... }
    }
  }
}

根据以上代码,我们就可以回答第一个问题:全局注册的组件会在子组件配置合并后反应到子组件components属性对象的原型上

接下来,我们来分析第二个问题,我们回到之前的createElement,在这个章节中,我们注意到有下面这样一段代码:

if (typeof tag === 'string') {
  if (xxx) {
    ...
  } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
    // component
    vnode = createComponent(Ctor, data, context, children, tag)
  }
}  else {
  vnode = createComponent(tag, data, context, children)
}

当模板编译到全局组件的时候,会在通过resolveAsset去尝试获取组件的构造函数,我们来看一下resolveAsset方法是如何实现的:

export function resolveAsset (
  options: Object,
  type: string,
  id: string,
  warnMissing?: boolean
): any {
  /* istanbul ignore if */
  if (typeof id !== 'string') {
    return
  }
  const assets = options[type]
  // check local registration variations first
  if (hasOwn(assets, id)) return assets[id]
  const camelizedId = camelize(id)
  if (hasOwn(assets, camelizedId)) return assets[camelizedId]
  const PascalCaseId = capitalize(camelizedId)
  if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
  // fallback to prototype chain
  const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]
  if (process.env.NODE_ENV !== 'production' && warnMissing && !res) {
    warn(
      'Failed to resolve ' + type.slice(0, -1) + ': ' + id,
      options
    )
  }
  return res
}

对于components选项来说,它首先会尝试使用hasOwn方法在自身对象上查找有没有,如果三种方式都没有,则最后在components的原型上去查找。对于全局注册的组件而言,它会在这个原型上找到,如果在原型上还找不到,那么最后会在patch的阶段去检验,然后抛出一个错误:

'Unknown custom element: xxx - did you register the component correctly?' +
'For recursive components, make sure to provide the "name" option.',

在了解了全局注册组件的方式后,对于局部注册组件的各种疑问相信都迎刃而解了。局部注册的组件都在components选项对象上,而全局注册的组件会在组件合并配置完毕后反应到子组件的components选项对象的原型上,这就是全局注册的组件可以在任意地方使用的根本原因了。

如果觉得写得不错,请到GitHub给我一个Star

上一篇:Vue2.0源码分析:组件化(上)
下一篇:Vue2.0源码分析:编译原理(上)

由于掘金文章字数限制,不得不拆分上、下两篇文章。