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

1,467 阅读24分钟

组件化

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

上一篇:Vue2.0源码分析:响应式原理(下)
下一篇:Vue2.0源码分析:组件化(下)

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

介绍

在之前几个章节中,我们提到过很多次组件的概念,组件在我们日常的开发过程中出现频率是非常高的,它也是Vue的两大核心之一:数据驱动组件化

在前面章节我们已经介绍完了数据驱动,在这个章节我们会着重介绍与组件化相关的知识,我们将从入口文件main.js开始探索组件化的奥秘。

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

$mount方法

代码分析

在前面我们已经知道,Vue会根据不同的情况去挂载不同的$mount方法,其中带compiler版本的$mount方法是在src/platforms/web/entry-runtime-with-compiler.js文件中被重新定义,其代码如下:

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  return mount.call(this, el, hydrating)
}

我们可以看到,在代码最顶部它首先获取并缓存了Vue.prototype上原始的$mount方法,然后重新在Vue.prototype上定义$mount方法,其中在最新的$mount方法的最底部,还调用了缓存下来的原始$mount方法。

那么,这个原始的$mount方法又在哪里被定义呢,其实它是在src/core/platforms/web/runtime/index.js中被定义,其代码如下:

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

搞清楚了这两个$mount方法的区别后,我们接下来首先分析带compiler版本的$mount方法实现,它主要做三件事情:获取el元素处理template调用原始$mount方法,我们将根据这几个步骤来分别进行代码分析。

代码分析:

  • 获取el元素:还记得在main.js入口文件中,我们调用$mount方法时传递了#app参数吗。
import Vue from 'vue'
import App from './App.vue'
new Vue({
  render: h => h(App)
}).$mount('#app')

当执行$mount方法的时候,首先要做的就是根据传递的el元素获取到要挂载的DOM元素节点,它使用query这个方法来获取DOM元素节点,其中这个方法的代码如下:

export function query (el: string | Element): Element {
  if (typeof el === 'string') {
    const selected = document.querySelector(el)
    if (!selected) {
      process.env.NODE_ENV !== 'production' && warn(
        'Cannot find element: ' + el
      )
      return document.createElement('div')
    }
    return selected
  } else {
    return el
  }
}

我们可以看到在query方法中,首先对el参数做了判断,如果不是string类型,则直接返回;如果是则通过document.querySelector去获取DOM元素,如果没有获取到,则创建一个div元素返回并提示错误信息。

在看完以上代码后,我们可能有一个疑问:什么时候el参数不为string类型呢?其实$mount方法可以直接接受一个DOM元素节点,既意味着我们可以在入口文件中这样写:

import Vue from 'vue'
import App from './App.vue'
new Vue({
  render: h => h(App)
}).$mount(document.querySelector('#app'))

我们在Vue官方文档中,肯定看到过这样一段提示内容:el提供的元素只能作为挂载点。不同于 Vue 1.x,所有的挂载元素会被 Vue 生成的 DOM 替换。因此不推荐挂载 root 实例到 html 或者 body 上。

$mount方法中,我们也可以看到这样一段代码,它提示我们不能直接挂载到htmlbody上:

if (el === document.body || el === document.documentElement) {
  process.env.NODE_ENV !== 'production' && warn(
    `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
  )
  return this
}

那么,为什么不能挂载到html或者body上呢,其实这是因为:$mount方法执行后,会直接替换挂载节点上面的内容,如果直接挂载html或者body上,很有可能会丢失掉一些东西,比如:metalink或者script等。

  • 处理template:处理template$mount方法的核心,这个过程也相对比较复杂,代码比较多一点,但流程还是比较清晰的。首先会对render进行判断,如果有render那么就不会再走处理template这部分的逻辑了,一个使用render的例子就是我们的main.js入口文件:
import Vue from 'vue'
import App from './App.vue'
new Vue({
  render: h => h(App)
}).$mount('#app')

因为在创建根实例的时候提供了render选项,因此在$mount方法中进行$options.render条件为真,直接走最后一步:调用原始$mount方法。

注意:其实我们使用Vue-Cli脚手架创建的项目,组件在$mount方法执行的时候,已经存在render函数了,这是因为vue-loader已经帮我们把template转换为render函数了,因此对于大多数情况来说不会走处理template的过程,只有少部分特殊情况才会走template处理。

在分析完提供render选择的分支后,我们来看一下不提供render选项的时候,处理template的逻辑。我们先看一下,什么情况下会走处理template,以下面代码为例:

export default {
  name: 'HelloWorld',
  data () {
    return {
      msg: 'Welcome to Your Vue.js App'
    }
  },
  template: `<div class="hello">{{ msg }}</div>`
}

这个时候对于条件判断templatetypeof template === 'string'都为真,因此会走最后一步compileToFunctions(template, ...),这一步主要是把template编译成render函数,这个过程我们会在后续详细进行说明。转换完毕以后,在把render赋值到options.render上面,这个步骤就跟我们手动提供一个render函数是类似的。

处理template的过程我们已经整体介绍完毕了,然后我们来分析一下没有提到的细节问题,首先当我们判断完毕typeof template === 'string'后,为什么还进行了如下代码判断:

if (template.charAt(0) === '#') {
  template = idToTemplate(template)
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && !template) {
    warn(
      `Template element not found or is empty: ${options.template}`,
      this
    )
  }
}

这是因为,template我们可以直接传递一个DOM节点的id,例如:

export default {
  template: '#main'
}

这个时候,检查到template第一个字符为#号,然后调用idToTemplate,它的代码如下:

const idToTemplate = cached(id => {
  const el = query(id)
  return el && el.innerHTML
})

这段代码主要作用就是根据id查询DOM元素,然后返回它的innerHTML内容。

接下来第二个问题,为什么会有如下else if分支逻辑?

else if (template.nodeType) {
  template = template.innerHTML
}

这是因为,template除了可以接受字符串以外,还可以直接接受一个DOM元素节点,例如:

<div id="main">
  <div>dom</div>
</div>
export default {
  name: 'HelloWorld',
  template: document.querySelector('#main')
}

最后一个问题,如果我既没有传递render,也没有提供template,会发生什么呢?其实它会最后降级到去获取el选项,代码如下:

else if (el) {
  template = getOuterHTML(el)
}

如果rendertemplate都没有提供,那么会在最后一步使用el选项,然后通过el获取DOM元素的outerHTMLinnerHTMLouterHTML的区别如下:

// 模拟一个DOM元素
const dom = `<div id="main">
              <div>dom</div>
             </div>`

const innerHTML = '<div>dom</div>'
const outerHTML = `<div id="main">
                    <div>dom</div>
                   </div>``
  • **调用原始mount方法:最后,我们来分析mount方法**:最后,我们来分析`mount方法的最后一个步骤,也就是着重分析原始(公共)$mount`方法。我们先来回顾一下这个方法的实现代码:
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

在这个方法中,处理el的过程跟之前的没有什么区别,那么我们分析的重点就落到了mountComponent方法,这个方法是定义在src/core/instance/lifecycle.js文件中,其代码如下:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
    if (process.env.NODE_ENV !== 'production') {
      /* istanbul ignore if */
      if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el || el) {
        warn(
          'You are using the runtime-only build of Vue where the template ' +
          'compiler is not available. Either pre-compile the templates into ' +
          'render functions, or use the compiler-included build.',
          vm
        )
      } else {
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        )
      }
    }
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      const name = vm._name
      const id = vm._uid
      const startTag = `vue-perf-start:${id}`
      const endTag = `vue-perf-end:${id}`

      mark(startTag)
      const vnode = vm._render()
      mark(endTag)
      measure(`vue ${name} render`, startTag, endTag)

      mark(startTag)
      vm._update(vnode, hydrating)
      mark(endTag)
      measure(`vue ${name} patch`, startTag, endTag)
    }
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

mountComponent方法代码看起来很多,其实做的事情并不复杂,我们可以把它分为三个步骤:callHook触发生命周期函数、定义updateComponent和定义渲染Watcher

  1. callHook触发生命周期函数:这一部分最简单,只需要调用callHook方法触发对应的生命周期即可,在mountComponent方法中,一共有三处触发生命周期的地方,分别是:beforeMountmountedbeforeUpdate
  2. 定义updateComponent:定义updateComponent方法我们只需要看else分支即可,if分支主要做性能埋点相关的事情,这里会在开启浏览器performance时用到。updateComponent方法里面的代码调用了vm._update()这个方法的主要作用是触发组件重新渲染,而vm._render()我们在之前已经介绍过了。
  3. 定义渲染Watcher:在mountComponent方法中定义了一个渲染Watcher,其中渲染Watcher的第二个参数传递了我们的updateComponent,这个参数会在渲染Watcher实例化的时候赋值给this.getter属性,当进行派发更新的时候,会遍历subs数组执行update,然后调用this.getter,也就是再次调用updateComponent,然后让组件重新渲染。

流程图

在分析完$mount方法后,我们可以得到如下流程图:

render和renderProxy

介绍完$mount后,我们来看一下render以及renderProxy相关的逻辑,这一节的主要目标是:弄清楚renderProxy的作用以及render的实现原理。

renderProxy

我们在之前介绍的initMixin方法中,有下面这样一段代码:

import { initProxy } from './proxy'
export default initMixin (Vue) {
  Vue.prototype._init = function () {
    // ...
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // ...
  }
}

initProxy是定义在src/core/instance/proxy.js文件中的一个方法,其代码如下:

let initProxy
initProxy = function initProxy (vm) {
  if (hasProxy) {
    // determine which proxy handler to use
    const options = vm.$options
    const handlers = options.render && options.render._withStripped
      ? getHandler
      : hasHandler
    vm._renderProxy = new Proxy(vm, handlers)
  } else {
    vm._renderProxy = vm
  }
}

代码分析:

  • 这个方法首先判断了当前环境是否支持原生Proxy,如果支持则创建一个Proxy代理,其中hasProxy是一个boolean值,它的实现逻辑如下:
const hasProxy = typeof Proxy !== 'undefined' && isNative(Proxy)
  • 然后根据options.renderoptions.render._withStripped的值来选择使用getHandler还是hasHandler,当使用vue-loader解析.vue文件时,这个时候options.render._withStripped为真值,因此选用getHandler。当选择使用compiler版本的Vue.js时,我们的入口文件中根实例是这样定义的:
import Vue from 'vue'
import App from './App'
new Vue({
  el: '#app',
  components: { App },
  template: '<App/>'
})

这个时候,对于根实例而言其options.render._withStrippedundefined,因此使用hasHandler。在搞清楚什么时候使用getHandlerhasHandler后,我们可能会有另外的问题: getHandlerhasHandler是干什么的?怎么触发?

在回答第一个问题之前,我们先来看一下getHandlerhasHandler的定义:

const allowedGlobals = makeMap(
  'Infinity,undefined,NaN,isFinite,isNaN,' +
  'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' +
  'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' +
  'require' // for Webpack/Browserify
)

const warnNonPresent = (target, key) => {
  warn(
    `Property or method "${key}" is not defined on the instance but ` +
    'referenced during render. Make sure that this property is reactive, ' +
    'either in the data option, or for class-based components, by ' +
    'initializing the property. ' +
    'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.',
    target
  )
}

const warnReservedPrefix = (target, key) => {
  warn(
    `Property "${key}" must be accessed with "$data.${key}" because ` +
    'properties starting with "$" or "_" are not proxied in the Vue instance to ' +
    'prevent conflicts with Vue internals. ' +
    'See: https://vuejs.org/v2/api/#data',
    target
  )
}

const hasHandler = {
  has (target, key) {
    const has = key in target
    const isAllowed = allowedGlobals(key) ||
      (typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data))
    if (!has && !isAllowed) {
      if (key in target.$data) warnReservedPrefix(target, key)
      else warnNonPresent(target, key)
    }
    return has || !isAllowed
  }
}

const getHandler = {
  get (target, key) {
    if (typeof key === 'string' && !(key in target)) {
      if (key in target.$data) warnReservedPrefix(target, key)
      else warnNonPresent(target, key)
    }
    return target[key]
  }
}

我们可以看到,getHandlerhasHandler所做的事情几乎差不多,都是在渲染阶段对不合法的数据做判断和处理。对于warnNonPresent而言,它提示我们在模板中使用了未定义的变量;对于warnReservedPrefix而言,它提示我们不能定义带$或者_开头的变量,因为这样容易和一些内部的属性相互混淆。

<template>
  {{msg1}}
  {{$age}}
</template>
<script>
// msg1报错
// $age报错
export default {
  data () {
    return {
      msg: 'message',
      $age: 23
    }
  }
}
</script>

紧接着,我们第二个问题:getHandlerhasHandler如何触发?这其实涉及到一点ES6 Proxy方面的知识,我们以下面这段代码为例来进行说明:

const obj = {
  a: 1,
  b: 2,
  c: 3
}
const proxy = new Proxy(obj, {
  has (target, key) {
    console.log(key)
    return key in target
  },
  get (target, key) {
    console.log(key)
    return target[key]
  }
})

// 触发getHandler,输出a
proxy.a 

// 触发hasHandler,输出 b c
with(proxy){
  const d = b + c
}

在以上代码中,我们定义了一个proxy代理,当我们访问proxy.a的时候,根据Proxy相关的知识会触发getHandler,因此会输出a。当我们使用with访问proxy的时候,在其中任何属性的访问都会触发hasHandler,因此会输出bc

在以上代码分析完毕后,我们就可以对initProxy的作用进行一个总结:在渲染阶段对不合法的数据做判断和处理

render

在之前的代码中,我们在mountComponent中遇到过下面这样一段代码:

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

在这一节,我们来分析一下_render函数的实现,它其实是在src/core/instance/render.js文件中被定义:

export function renderMixin (Vue) {
  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options
    // ...省略代码
    let vnode
    try {
      // There's no need to maintain a stack because all render fns are called
      // separately from one another. Nested component's render fns are called
      // when parent component is patched.
      currentRenderingInstance = vm
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      handleError(e, vm, `render`)
    }
    // ...省略代码
    vnode.parent = _parentVnode
    return vnode
  }
}

其中通过$options结构出来的render,就是我们实例化的时候提供的render选择或者通过template编译好的render函数。在_render代码中,最重要的一步是render.call函数的调用,render函数执行后会返回VNodeVNode会在之后的处理过程中使用到。

我们在render.call方法调用的时候,除了传递我们的renderProxy代理,还传递了一个$createElement函数,其中这个函数是在initRender方法中被定义:

export function initRender (vm) {
  // ...省略代码
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // normalization is always applied for the public version, used in
  // user-written render functions.
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
  // ...省略代码
}

我们发现,vm.$createElementvm._c的函数定义是差不多的,唯一的区别是在调用createElement方法的时候,传递的最后一个参数不相同。$createElement_c方法虽然方法定义差不多,但使用场景是不一样的,$createElement通常是用户手动提供的render来使用,而_c方法通常是模板编译生成的render来使用的。

根据render函数的定义,我们可以把template例子改写成使用render的形式:

<template>
  <div id="app">
    {{msg}}
  </div>
</template>
<script>
export default () {
  data () {
    return {
      msg: 'message'
    }
  }
}
</script>

render改写后:

export default {
  data () {
    return {
      msg: 'message'
    }
  },
  render: ($createElement) {
    return  $createElement('div', {
      attrs: {
        id: 'app'
      }
    }, this.message)
  }
}

在这一小节,我们分析了render的实现,在下一小节我们将深入学习createElement方法的实现原理。

createElement

在上一节,我们知道了render函数执行的时候,会调用$createElement或者_c方法,也知道了它们最后其实调用的是同一个createElement方法,只不过最后一个参数有点区别。在这一节,我们来详细分析一下createElement方法的实现逻辑。

createElement是定义在src/core/vdom/create-element.js文件中,其代码如下:

const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2
export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

在分析代码之前,我们来看一下$createElement_c方法最后一个不相同的参数,在createElement中体现在什么地方。我们可以从最后一个参数命名猜测其作用,对于模板编译调用_c时,其alwaysNormalize传递的是false,因为_c只会在内部使用,因此其方法调用的时候参数格式是比较规范的,我们不需要过多的进行normalize。而$createElement是提供给用户使用的,为了让$createElement更加简洁和实用,允许用户传递不同形式的参数来调用$createElement,这也就造成了用户手写的render,我们必须始终进行normalize

在上述分析完毕后,我们就知道了$createElement_c最后一个不相同的参数,体现在什么地方了:调用_c时对children进行简单规范化,调用$createElement时必须始终对children进行规范化。

回到正题,我们发现createElement其实是对_createElement方法的一层包裹,之所以这样做是为了让createElement达到一种类似于函数重载的功能(JavaScript实际并没有这个概念)。其中第三个参数data是可以不传的。

// 不传递data 
createElement(this, 'div', 'Hello, Vue', 1, false)
// 传递data
createElement(this, 'div', undefined, 'Hello, Vue', 1, false)

当不传递data的时候,我们需要把第三、第四个参数往后移动一个位置,然后把data赋值为undefined,最后在把处理好的参数传递给_createElement。接下来,我们先看一下_createElement方法几个参数的具体作用:

  • contextVNode当前上下环境。
  • tag:标签,可以是正常的HTML元素标签,也可以是Component组件。
  • dataVNode的数据,其类型为VNodeData,可以在根目录flow/vnode.js文件中看到其具体定义。
  • childrenVNode的子节点。
  • normalizationTypechildren子节点规范化类型。

其具体实现代码如下:

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // ...省略代码
  if (!tag) {
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
        warn(
          `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
          context
        )
      }
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  // ...省略代码
}

_createElement的代码看起来有点多,但它主要做两件事情:规范化子节点和创建VNode节点,接下来我们围绕这两个方面来详细介绍。

  • 规范化子节点:因为虚拟DOM是一个树形结构,每一个节点都应该是VNode类型,但是children参数又是任意类型的,所以如果有子节点,我们需要把它进行规范化成VNode类型,如果没有子节点,那么children就是undefined。至于如何规范化,则是通过normalizationType参数来实现的,其中normalizationType可能的值我们只说三种:undefined表示不进行规范化,1表示简单规范化,2表示始终规范化。我们先来看当值为1的情况,它调用了simpleNormalizeChildren,这个方法和normalizeChildren是定义在同一个地方src/core/vdom/helpers/normalize-children.js文件中,其代码如下:
export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}

simpleNormalizeChildren的作用是把多维数组降低一个维度,例如二维数组降低到一维数组,三维数组降低到二维数组,这样做的目的是为了方便后续遍历children

// 展示使用,实例为VNode
let children = ['VNode', ['VNode', 'VNode'], 'VNode']

// 简单规范化子节点
children = simpleNormalizeChildren(children)

// 规范化后
console.log(children) // ['VNode', 'VNode', 'VNode', 'VNode']

接下来我们来看值为2的情况,它调用了normalizeChildren,其代码如下:

export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

normalizeChildren的代码不是很多,也不是很复杂。当children是基础类型值的时候,直接返回一个文本节点的VNode数组,createTextVNode我们在之前已经介绍过了。如果不是,则再判断是否为数组,不是则其children就是undefined,是的话就调用normalizeArrayChildren来规范化。接下来,我们重点分析以下normalizeArrayChildren的实现,它和normalizeChildren是定义在同一个位置,其实现代码如下:

function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
  const res = []
  let i, c, lastIndex, last
  for (i = 0; i < children.length; i++) {
    c = children[i]
    if (isUndef(c) || typeof c === 'boolean') continue
    lastIndex = res.length - 1
    last = res[lastIndex]
    //  nested
    if (Array.isArray(c)) {
      if (c.length > 0) {
        c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
        // merge adjacent text nodes
        if (isTextNode(c[0]) && isTextNode(last)) {
          res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
          c.shift()
        }
        res.push.apply(res, c)
      }
    } else if (isPrimitive(c)) {
      if (isTextNode(last)) {
        // merge adjacent text nodes
        // this is necessary for SSR hydration because text nodes are
        // essentially merged when rendered to HTML strings
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        // convert primitive to vnode
        res.push(createTextVNode(c))
      }
    } else {
      if (isTextNode(c) && isTextNode(last)) {
        // merge adjacent text nodes
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        // default key for nested array children (likely generated by v-for)
        if (isTrue(children._isVList) &&
          isDef(c.tag) &&
          isUndef(c.key) &&
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        res.push(c)
      }
    }
  }
  return res
}

虽然normalizeArrayChildren的代码很多,但做的事情并不复杂,我们只要关注遍历过程中几个重要的逻辑分支即可。

  1. 遍历项为数组:这种情况稍微复杂一点,多见于v-for或者slot的时候,会出现嵌套VNode数组的情况,如果存在嵌套VNode的情况会递归调用normalizeArrayChildren,我们以下面这个例子为例:
<template>
  <div id="app">
    <p>{{msg}}</p>
    <span v-for="(item, index) in list" :key="index">{{item}}</span>
  </div>
</template>
<script>
export default {
  name: 'App',
  data () {
    return {
      msg: 'message',
      list: [1, 2, 3]
    }
  }
}
</script>

App组件render函数执行的时候,其children子节点会出现VNode嵌套数组的情况,可以用以下代码示例说明:

const children = [
  [ { tag: 'p' }, ... ],
  [
    [ { tag: 'span', ... } ],
    [ { tag: 'span', ... } ],
    [ { tag: 'span', ... } ]
  ]
]

递归调用normalizeArrayChildren方法后,嵌套数组被处理成了一维数组,如下:

const children = [
  [ { tag: 'p' }, ... ],
  [ { tag: 'span', ... } ],
  [ { tag: 'span', ... } ],
  [ { tag: 'span', ... } ]
]
  1. 遍历项为基础类型:当为基础类型的时候,调用封装的createTextVNode方法来创建一个文本节点,然后push到结果数组中。
  2. 遍历项已经是VNode类型:这种情况最简单,如果不属于以上两种情况,那么代表本身已经是VNode类型了,这时候我们什么都不需要做,直接push到结果数组中即可。

在这三个逻辑分支中,都判断了isTextNode,这部分的代码主要是用来优化文本节点:如果存在两个连续的文本节点,则将其合并成一个文本节点。

// 合并前
const children = [
  { text: 'Hello ', ... },
  { text: 'Vue.js', ... },
]

// 合并后
const children = [
  { text: 'Hello Vue.js', ... }
]
  • 创建VNode节点:创建VNode节点的逻辑有两大分支,tagstring类型和component类型,其中string类型又存在几个小的逻辑判断分支。在createElement章节,我们重点介绍类型为string的分支。在这个分支中,首先判断tag提供的标签名是不是平台保留标签(htmlsvg标签),如果是则直接创建对应标签的VNode节点,如果不是则尝试在已经全局或者局部注册的组件中去匹配,匹配成功则使用createComponent去创建组件节点,如果没有匹配上则创建一个未知标签的VNode节点,例如:
<template>
  <div id="app">
    <div>{{msg}}</div>
    <hello-world :msg="msg" />
    <cms>12321321</cms>
  </div>
</template>
<script>
import HelloWorld from '@/components/HelloWorld.vue'
export default {
  name: 'App',
  data () {
    return {
      msg: 'message',
    }
  },
  components: {
    HelloWorld
  }
}
</script>

tagcms,但它既不像div一样是平台保留标签,又不像hello-world一样是已经局部注册过的组件,它属于未知的标签。这里之所以直接创建未知标签的VNode而不是报错,这是因为子节点在createElement的过程中,有可能父节点会为其提供一个namespace,真正做未知标签校验的过程发生在path阶段,path的过程我们将在后续进行介绍。

createComponent

在前面介绍createElement方法的过程中,我们提到过有两处都调用了createComponent方法,在这一节我们来详细分析一下createComponent方法的实现逻辑。

createComponent是定义在src/core/vmode/create-component.js文件中的,其代码如下:

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  // ...省略其它
  const baseCtor = context.$options._base
  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

  data = data || {}

  // resolve constructor options in case global mixins are applied after
  // component constructor creation
  resolveConstructorOptions(Ctor)

  // install component management hooks onto the placeholder node
  installComponentHooks(data)

  // return a placeholder vnode
  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
  // ...省略其它
  return vnode
}

因为createComponent方法要实现的功能点有很多,以上是我们的精简代码,其中被精简掉的代码有:组件检验相关异步组件相关获取prosData相关抽象组件相关以及weex相关

在分析createComponent方法的时候,我们主要关注两个方面的内容:构造子类构造函数安装组件钩子函数。至于最后的创建组件VNode并返回VNode,则是最简单的,在这一步我们只需要知道创建组件VNode的时候,向VNode构造函数传递的第三个参数childrenundefined,也就是说组件VNode没有children子节点,因为其值为undefined

代码分析:

  • 构造子类构造函数:在代码最开始,首先通过$options._base拿到基础构造函数,这个基础构造函数也就是大Vue的构造函数,$options._base赋值过程是在initGlobalAPI函数执行的过程中赋值的。
export function initGlobalAPI (Vue) {
  Vue.options._base = Vue
}

根据我们之前介绍的规则,我们在options上的属性,可以在后续通过$options拿到,这是因为在this._init方法的执行过程中,进行了mergeOptions配置合并。

vm.$options = mergeOptions(
  resolveConstructorOptions(vm.constructor),
  options || {},
  vm
)

我们再来看一下createComponent的第一个参数,以App.vue组件为例:

import HelloWorld from '@/components/HelloWorld.vue'
export default {
  name: 'App',
  data () {
    return {
      msg: 'message',
      age: 23,
      list: [1, 2, 3]
    }
  },
  components: {
    HelloWorld
  }
}

我们在App.vue组件中export导出的是一个对象,其中对象定义了namedata以及components三个属性,那么Ctor参数就应该是这个对象,但当我们真实调试的时候却发现Ctor属性比我们想象的还要多,这是因为vue-loader在处理.vue文件的时候默认帮我们做了一些处理,以下是App.vue真实调试时的Ctor参数:

const Ctor = {
  beforeCreate: [function () {}],
  beforeDestroy: [function () {}],
  components: {
    HelloWorld
  },
  data () {
    return {
      msg: 'message',
      age: 23,
      list: [1, 2, 3]
    }
  },
  name: 'App',
  render: function () {},
  staticRenderFns: [],
  __file: './App.vue',
  _compiled: true
}

接下来,我们来看一下baseCtor.extend,全局的extend方法的定义位置我们已经在之前介绍过了,它是在initGlobalAPI方法中调用initExtend时被定义的,其中initExtend定义在src/core/global-api/extend.js文件中,代码如下:

export function initExtend (Vue: GlobalAPI) {
  Vue.cid = 0
  let cid = 1

  Vue.extend = function (extendOptions: Object): Function {
    extendOptions = extendOptions || {}
    const Super = this
    const SuperId = Super.cid
    const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
    if (cachedCtors[SuperId]) {
      return cachedCtors[SuperId]
    }

    const name = extendOptions.name || Super.options.name
    if (process.env.NODE_ENV !== 'production' && name) {
      validateComponentName(name)
    }

    const Sub = function VueComponent (options) {
      this._init(options)
    }
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    Sub.cid = cid++
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )
    Sub['super'] = Super

    // For props and computed properties, we define the proxy getters on
    // the Vue instances at extension time, on the extended prototype. This
    // avoids Object.defineProperty calls for each instance created.
    if (Sub.options.props) {
      initProps(Sub)
    }
    if (Sub.options.computed) {
      initComputed(Sub)
    }

    // allow further extension/mixin/plugin usage
    Sub.extend = Super.extend
    Sub.mixin = Super.mixin
    Sub.use = Super.use

    // create asset registers, so extended classes
    // can have their private assets too.
    ASSET_TYPES.forEach(function (type) {
      Sub[type] = Super[type]
    })
    // enable recursive self-lookup
    if (name) {
      Sub.options.components[name] = Sub
    }

    // keep a reference to the super options at extension time.
    // later at instantiation we can check if Super's options have
    // been updated.
    Sub.superOptions = Super.options
    Sub.extendOptions = extendOptions
    Sub.sealedOptions = extend({}, Sub.options)

    // cache constructor
    cachedCtors[SuperId] = Sub
    return Sub
  }
}

我们看一下Vue.extend方法最核心的几段代码:

const Super = this
const Sub = function VueComponent (options) {
  this._init(options)
}
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub

extend方法中,使用了非常经典的寄生组合继承的方式,来让Sub子类去继承父类的属性和方法。在原型继承之前,首先调用了this._init方法,这个方法的逻辑我们在之前已经提到过了,这里就不再累述。原型继承后,Sub子类就拥有了Super父类全部的属性和方法,例如:

const Super = function () {
  this.id = 1
  this.name = 'Super'
}
Super.prototype.say = function () {
  console.log('hello Super')
}
const Sub = function () {
  Super.call(this)
}
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub

const sub = new Sub()
console.log(sub.id)   // 1
console.log(sub.name) // Super
sub.say()             // hellp Super

我们来看另外几段代码:

const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
  return cachedCtors[SuperId]
}
cachedCtors[SuperId] = Sub

这几段代码是用来缓存的,作用是:如果我们先在A.vue文件中引入了header.vue组件,它会执行一遍extend,随后我们又在B.vue文件中引入了header.vue,由于已经有了header.vue组件的缓存,因此不再执行后续的代码,直接返回。

// A.vue
import MHeader from '@/components/header.vue'
export default {
  name: 'AComponent',
  components: {
    MHeader
  }
}

// B.vue
import MHeader from '@/components/header.vue'
export default {
  name: 'BComponent',
  components: {
    MHeader
  }
}

// header.vue只会extend一次。

最后在继承完毕后,还处理了propscomputed以及各种全局API方法,这部分的逻辑跟之前我们提到过的是一样的,不再此累述。

  • 安装组件钩子函数:我们在前面提到过,Vue中的虚拟DOM借鉴了开源库snabbdom的实现,在这个库里面当VNode节点处于不同的场景下,提供了对应的钩子函数来方便我们处理相关的逻辑,这些钩子函数如下:

Vue中也用到了这些钩子函数,它的定义如下:

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

我们来看一下,installComponentHooks方法的定义:

const hooksToMerge = Object.keys(componentVNodeHooks)
function installComponentHooks (data: VNodeData) {
  const hooks = data.hook || (data.hook = {})
  for (let i = 0; i < hooksToMerge.length; i++) {
    const key = hooksToMerge[i]
    const existing = hooks[key]
    const toMerge = componentVNodeHooks[key]
    if (existing !== toMerge && !(existing && existing._merged)) {
      hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge
    }
  }
}

installComponentHooks方法执行的时候,遍历了我们定义的hooks对象的属性,然后在遍历的过程中把这些hook赋值到我们传递的参数上面,其中有一个地方值得我们注意:如果已经有了相同的hook,则会执行mergeHook来合并,mergeHook方法的定义如下:

function mergeHook (f1: any, f2: any): Function {
  const merged = (a, b) => {
    // flow complains about extra args which is why we use any
    f1(a, b)
    f2(a, b)
  }
  merged._merged = true
  return merged
}

我们以下面代码为例,来举例说明:

// 合并前
const hooks = {
  init: function () {
    console.log('init hook 1')
  }
}
const vnode = {
  data: {
    hook: {
      init: function () {
        console.log('init hook 2')
      }
    }
  }
}
// 合并
mergeHook()

// 合并后
const vnode = {
  data: {
    hook: {
      init: () => {
        init1(),
        init2()
      }
    }
  }
}

createComponent这一小节,我们介绍了组件会进行mergeOptions配置合并,为了更好的理解path的过程,我们会在接下来的小节优先介绍mergeOptions配置合并策略。

合并策略

在这一节合并策略中,我们主要分三个步骤来说明:配置合并的背景配置合并的场景以及合并策略

背景

我们可以会很好奇,为什么要进行配置合并?这是因为Vue内部存在一些默认的配置,在初始化的时候又允许我们提供一些自定义配置,这是为了在不同的场景下达到定制化个性需求的目的。纵观一些优秀的开源库、框架它们的设计理念几乎都是类似的。

我们举例来说明一下配置合并的背景:

Vue.mixin({
  created () {
    console.log('global created mixin')
  },
  mounted () {
    console.log('global mounted mixin')
  }
})

假设我们使用Vue.mixin方法全局混入了两个生命周期配置createdmounted,那么在我们的应用中,这两个生命周期配置都会反应到各个实例上去,无论是根实例还是各种组件实例。但对于根实例或者组件实例而言,它们也可能会拥有自己的createdmounted配置,如果不进行合理的配置合并,那么会出现一些意料之外的问题。

场景

要进行配置合并的场景不止一两处,我们主要介绍以下四种场景:

  • vue-loader:在之前我们提到过当我们使用.vue文件的形式进行开发的时候,由于.vue属于特殊的文件扩展,webpack无法原生识别,因此需要对应的loader去解析,它就是vue-loader。假如我们撰写以下HelloWorld.vue组件,然后在别的地方去引入它。
// HelloWorld.vue
export default {
  name: 'HelloWorld',
  data () {
    return {
      msg: 'hello, world'
    }
  }
}

// App.vue
import HelloWorld from '@/components/HelloWorld.vue'
export default {
  name: 'App',
  components: {
    HelloWorld
  }
}

因为我们在HelloWorld.vue文件中只提供了namedata两个配置选项,但真正调试的时候我们发现HelloWorld组件的实例上多了很多额外的属性,这是因为vue-loader帮我们默认添加的。

const HelloWorld = {
  beforeCreate: [function () {}],
  beforeDestroy: [function () {}],
  name: 'HelloWorld',
  data () {
    return {
      msg: 'hello, world'
    }
  },
  ...
}

我们可以发现vue-loader默认添加的有beforeCreatebeforeDestroy两个配置,如果我们组件自身也提供了这两个配置的话,这种情况必须进行配置合并。

  • extend:在上一节我们介绍createComponent的时候,我们知道子组件会继承大Vue上的一些属性或方法,假设我们全局注册了一个组件。
import HelloWorld from '@/components/HelloWorld.vue'
Vue.component('HelloWorld', HelloWorld)

当我们在其它组件中也注册了一些组件,这样大Vue上的components就要和组件中的components进行合理的配置合并。

  • mixin:在前面的配置合并背景小节中,我们使用Vue.mixin全局混入了两个生命周期配置,这属于mixin配置合并的范围,我们来举例另外一种组件内的mixin混入场景:
// mixin定义
const sayMixin = {
  created () {
    console.log('hello mixin created')
  },
  mounted () {
    console.log('hello mixin mounted')
  }
}

// 组件引入mixin
export default {
  name: 'App',
  mixins: [sayMixin],
  created () {
    console.log('app component created')
  },
  mounted () {
    console.log('app component mounted')
  }
}

当在App.vue组件中提供mixins选择的时候,因为在我们定义的sayMixin也提供了createdmounted两个生命周期配置,因此这种情况下也要进行配置合并。又因为mixins接受一个数组选项,假如我们传递了多个已经定义的mixin,而这些mixin又可能会存在提供了相同配置的情况,因此同样需要进行配置合并。

注意Vue.mixin全局API方法在内部调用了mergeOptions来进行混入,它的定义位置我们在之前的initGlobalAPI小节中提到过,其实现代码如下:

import { mergeOptions } from '../util/index'

export function initMixin (Vue: GlobalAPI) {
  Vue.mixin = function (mixin: Object) {
    this.options = mergeOptions(this.options, mixin)
    return this
  }
}
  • this._init:严格意义上来说,这里其实并不算是一个配置合并的场景,而应该是一种配置合并的手段。对于第一种vue-loader和第二种extend的场景,它们在必要的场景下也会在this._init进行配置合并,例如在子组件实例化的时候,它在构造函数中就调用了this._init:
const Sub = function VueComponent (options) {
  this._init(options)
}

Vue.prototype._init = function () {
  // ...省略其它
  if (options && options._isComponent) {
    // optimize internal component instantiation
    // since dynamic options merging is pretty slow, and none of the
    // internal component options needs special treatment.
    initInternalComponent(vm, options)
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    )
  }
  // ...省略其它
}

合并策略

我们先来看看合并策略的代码,它是定义在src/core/util/options.js文件中,其代码如下:

export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  if (process.env.NODE_ENV !== 'production') {
    checkComponents(child)
  }

  if (typeof child === 'function') {
    child = child.options
  }

  normalizeProps(child, vm)
  normalizeInject(child, vm)
  normalizeDirectives(child)

  // Apply extends and mixins on the child options,
  // but only if it is a raw options object that isn't
  // the result of another mergeOptions call.
  // Only merged options has the _base property.
  if (!child._base) {
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    if (child.mixins) {
      for (let i = 0, l = child.mixins.length; i < l; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }

  const options = {}
  let key
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

我们先忽略mergeOptions方法中其它的代码,来看最核心的mergeField,在这个方法里面,它会根据不同的key,调用策略对象strats中的策略方法,然后把合并完的配置再赋值到options上,strats策略对象每个key的具体定义我们会在之后对应的章节中介绍。

默认合并策略

mergeField方法中,我们看到当传入的key没有对应的策略方法时,会使用defaultStrat默认合并策略,它的定义代码如下:

const defaultStrat = function (parentVal: any, childVal: any): any {
  return childVal === undefined
    ? parentVal
    : childVal
}

defaultStrat默认合并策略的代码非常简单,即:简单的覆盖已有值,例如:

const defaultStrat = function (parentVal, childVal) {
  return childVal === undefined
    ? parentVal
    : childVal
}
const parent = {
  age: 23,
  name: 'parent',
  sex: 1
}
const child = {
  age: undefined,
  name: 'child',
  address: '广州'
}
function mergeOptions (parent, child) {
  let options = {}
  for (const key in parent) {
    mergeField(key)
  }
  for (const key in child) {
    if (!parent.hasOwnProperty(key)) {
      mergeField(key)
    }
  }

  function mergeField (key) {
    options[key] = defaultStrat(parent[key], child[key])
  }
  return options
}
const $options = mergeOptions(parent, child)
console.log($options) // { age: 23, name: 'child', sex: 1, address: '广州' }

代码分析:在以上案例中,agename都存在于parentchild对象中,因为child.age值为undefined,所以最后取parent.age值,这种情况也适用于sex属性的合并。因为child.name值不为undefined,所以最后取child.name的值,这种情况也适用于address属性的合并。

注意:如果你想针对某一个选择修改它的默认合并策略,可以使用Vue.config.optionMergeStrategies去配置,例如:

// 自定义el选择的合并策略,只取第二个参数的。
import Vue from 'vue'
Vue.config.optionMergeStrategies.el = (toVal, fromVal) {
  return fromVal
}

el和propsData合并

对于elpropsData属性的合并,在Vue中使用了默认合并策略,其定义代码如下:

const strats = config.optionMergeStrategies
if (process.env.NODE_ENV !== 'production') {
  strats.el = strats.propsData = function (parent, child, vm, key) {
    // ...省略其它
    return defaultStrat(parent, child)
  }
}

对于elpropsData这两个选项来说,使用默认合并策略的原因很简单,因为elpropsData只允许有一份。

生命周期hooks合并

对于生命周期钩子函数而言,它们都是通过mergeHook方法来合并的,strats策略对象上关于hooks属性定义代码如下:

export const LIFECYCLE_HOOKS = [
  'beforeCreate',
  'created',
  'beforeMount',
  'mounted',
  'beforeUpdate',
  'updated',
  'beforeDestroy',
  'destroyed',
  'activated',
  'deactivated',
  'errorCaptured',
  'serverPrefetch'
]

LIFECYCLE_HOOKS.forEach(hook => {
  strats[hook] = mergeHook
})

我们接下来看一下mergeHook是如何实现的,其代码如下:

function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  const res = childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal
  return res
    ? dedupeHooks(res)
    : res
}

function dedupeHooks (hooks) {
  const res = []
  for (let i = 0; i < hooks.length; i++) {
    if (res.indexOf(hooks[i]) === -1) {
      res.push(hooks[i])
    }
  }
  return res
}

我们可以看到在mergeHook方法中,它用到了三层三目运算来判断,首先判断了是否有childVal,如果没有则直接返回parentVal;如果有,则parentVal有没有,如果有则一定是数组形式,这个时候直接把childVal添加到parentVal数组的末尾;如果没有,则需要判断一下childVal是不是数组,如果不是数组则转成数组,如果已经是数组了,则直接返回。

在最后还判断了res,然后满足条件则调用dedupeHooks,这个方法的作用很简单,就是剔除掉数组中的重复项。最后,我们根据以上逻辑撰写几个案例来说明。

// 情况一
const parentVal = [function created1 () {}]
const childVal = undefined
const res = [function created1 () {}]

// 情况二
const parentVal = [function created1 () {}]
const childVal = [function created2 () {}]
const res = [function created1 () {}, function created2 () {}]

// 情况三
const parentVal = undefined
const childVal = [function created2 () {}]
const res = [function created2 () {}]

我们再来看一个比较特殊的场景:

// mixin.js
export const sayMixin = {
  created () {
    console.log('say mixin created')
  }
}
export const helloMixin = {
  created () {
    console.log('hello mixin created')
  }
}


// App.vue
export default {
  name: 'App',
  created () {
    console.log('component created')
  }
}

// 执行顺序
// say mixin created
// hello mixin created
// component created

代码分析:我们可以看到mixins里面的created生命周期函数会优先于组件自身提供的created生命周期函数,这是因为在遍历parentchild的属性之前,会优先处理extendsmixins选项。以mixins为例,它会首先遍历我们提供的mixins数组,然后依次把这些配置按照规则合并到parent上,最后在遍历child的属性时,才会把其自身的配置合并对应的位置,在我们提供的例子当中,自身提供的created会使用数组concat方法添加到数组的末尾。当组件触发created生命周期的时候,会按照数组顺序依次调用。

if (!child._base) {
  if (child.extends) {
    parent = mergeOptions(parent, child.extends, vm)
  }
  if (child.mixins) {
    for (let i = 0, l = child.mixins.length; i < l; i++) {
      parent = mergeOptions(parent, child.mixins[i], vm)
    }
  }
}

data和provide合并

对于dataprovide而言,它们最后都使用mergeDataOrFn来合并,只不过对于data选项比较特殊,它需要单独包裹一层,它们在strats策略对象上的属性定义如下:

strats.data = function (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    if (childVal && typeof childVal !== 'function') {
      process.env.NODE_ENV !== 'production' && warn(
        'The "data" option should be a function ' +
        'that returns a per-instance value in component ' +
        'definitions.',
        vm
      )

      return parentVal
    }
    return mergeDataOrFn(parentVal, childVal)
  }

  return mergeDataOrFn(parentVal, childVal, vm)
}
strats.provide = mergeDataOrFn

在合并data的包裹函数中,对childVal进行了检验,如果不是函数类型,提示错误信息并直接返回。如果时,再调用mergeDataOrFn方法来合并。接下来,我们来看一下mergeDataOrFn方法的具体实现逻辑:

export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    // in a Vue.extend merge, both should be functions
    if (!childVal) {
      return parentVal
    }
    if (!parentVal) {
      return childVal
    }
    // when parentVal & childVal are both present,
    // we need to return a function that returns the
    // merged result of both functions... no need to
    // check if parentVal is a function here because
    // it has to be a function to pass previous merges.
    return function mergedDataFn () {
      return mergeData(
        typeof childVal === 'function' ? childVal.call(this, this) : childVal,
        typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
      )
    }
  } else {
    return function mergedInstanceDataFn () {
      // instance merge
      const instanceData = typeof childVal === 'function'
        ? childVal.call(vm, vm)
        : childVal
      const defaultData = typeof parentVal === 'function'
        ? parentVal.call(vm, vm)
        : parentVal
      if (instanceData) {
        return mergeData(instanceData, defaultData)
      } else {
        return defaultData
      }
    }
  }
}

mergeDataOrFn方法中,我们可以发现它根据vm进行了区分,但这两块的合并思路是一致的:如果parentValchildVal是函数类型,则分别调用这个函数,然后合并它们返回的对象,这种情况主要针对data合并。对于provide而言,它不需要是function类型,因此直接使用mergeData来合并即可。我们再回过头来看,为什么要区分vm,这是因为要处理兼容provide的情况,当传递provide的时候,因为这个属性是在父级定义的,因此this属于父级而不是当前组件vm

最后来看一下mergeData方法的实现代码:

function mergeData (to: Object, from: ?Object): Object {
  if (!from) return to
  let key, toVal, fromVal

  const keys = hasSymbol
    ? Reflect.ownKeys(from)
    : Object.keys(from)

  for (let i = 0; i < keys.length; i++) {
    key = keys[i]
    // in case the object is already observed...
    if (key === '__ob__') continue
    toVal = to[key]
    fromVal = from[key]
    if (!hasOwn(to, key)) {
      set(to, key, fromVal)
    } else if (
      toVal !== fromVal &&
      isPlainObject(toVal) &&
      isPlainObject(fromVal)
    ) {
      mergeData(toVal, fromVal)
    }
  }
  return to
}

mergeData和前面提到extend方法所做的事情几乎是一样的,只不过由于data中所有的属性(包括嵌套对象的属性),我们需要使用set处理成响应式的。set方法就是Vue.setthis.$set方法的本体,它定义在src/core/observer/index.js文件中,我们之前在响应式章节提到过。

components、directives和filters合并

对于componentsdirectives以及filters的合并是同一个mergeAssets方法,strats策略对象上关于这几种属性定义代码如下:

const ASSET_TYPES = [
  'component',
  'directive',
  'filter'
]
ASSET_TYPES.forEach(function (type) {
  strats[type + 's'] = mergeAssets
})

接下来,我们看一下mergeAssets具体定义:

function mergeAssets (
  parentVal: ?Object,1
  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
  }
}

mergeAssets方法的代码不是很多,逻辑也很清晰,首先以parentVal创建一个res原型,如果childVal没有,则直接返回这个res原型;如果有,则使用extendchildVal上的所有属性扩展到res原型上。有一点需要注意,extend不是我们之前提到的Vue.extend或者this.$extend,它是定义在src/shared/utils.js文件中的一个方法,其代码如下:

export function extend (to: Object, _from: ?Object): Object {
  for (const key in _from) {
    to[key] = _from[key]
  }
  return to
}

我们撰写一个简单的例子来说明一下extend方法的用法:

const obj1 = {
  name: 'AAA',
  age: 23
}
const obj2 = {
  sex: '男',
  address: '广州'
}
const extendObj = extend(obj1, obj2)
console.log(extendObj) // { name: 'AAA', age: 23, sex: '男', address: '广州' }

在介绍完extend方法后,我们回到mergeAssets方法,我们同样举例说明:

// main.js
import Vue from 'vue'
import HelloWorld from '@/components/HelloWorld.vue'
Vue.component('HelloWorld', HelloWorld)

// App.vue
import Test from '@/components/test.vue'
export default {
  name: 'App',
  components: {
    Test
  }
}

main.js入口文件中,我们全局定义了一个HelloWorld 全局组件,然后在App.vue中又定义了一个Test局部组件,当代码运行到mergeAssets的时候,部分参数如下:

const parentVal = {
  HelloWorld: function VueComponent () {...},
  KeepAlive: {...},
  Transition: {...},
  TransitionGroup: {...}
}
const childVal = {
  Test: function VueComponent () {...}
}

因为parentValchildVal都有值,因此会调用extend方法,调用前和调用后的res如下所示:

// 调用前
const res = {
  __proto__: {
    HelloWorld: function VueComponent () {...},
    KeepAlive: {...},
    Transition: {...},
    TransitionGroup: {...}
  }
}

// extend调用后
const res = {
  Test: function VueComponent () {...},
  __proto__: {
    HelloWorld: function VueComponent () {...},
    KeepAlive: {...},
    Transition: {...},
    TransitionGroup: {...}
  }
}

假如我们在App.vue组件中都使用了这两个组件,如下:

<template>
  <div>
    <test />
    <hello-world />
  </div>
</template>

App.vue组件渲染的过程中,当编译到<test />时,会在其components选项中查找组件,马上在自身属性上找到了test.vue。然后当编译到<hello-world />的时候,在自身对象上找不到这个属性,根据原型链的规则会在原型上去找,然后在__proto__上找到了HelloWorld.vue组件,两个组件得以顺利的被解析和渲染。

对于另外两个选项directivesfilters,它们跟components是一样的处理逻辑。

watch合并

对于watch选项而言,它使用的合并方法是单独定义的,其在strats策略对象上的属性定义如下:

strats.watch = function (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  // work around Firefox's Object.prototype.watch...
  if (parentVal === nativeWatch) parentVal = undefined
  if (childVal === nativeWatch) childVal = undefined
  /* istanbul ignore if */
  if (!childVal) return Object.create(parentVal || null)
  if (process.env.NODE_ENV !== 'production') {
    assertObjectType(key, childVal, vm)
  }
  if (!parentVal) return childVal
  const ret = {}
  extend(ret, parentVal)
  for (const key in childVal) {
    let parent = ret[key]
    const child = childVal[key]
    if (parent && !Array.isArray(parent)) {
      parent = [parent]
    }
    ret[key] = parent
      ? parent.concat(child)
      : Array.isArray(child) ? child : [child]
  }
  return ret
}

我们可以看到watch配置的合并与hooks合并的思路几乎差不多,只是多了一些微小的差异,当childVal没有时,直接返回按照parentVal创建的原型,类似的当parentVal没有是,直接返回childVal,注意这里因为是自身的配置,因此不需要像parentVal那样创建并一个原型。当parentValchildVal都存在时,首先把parentVal上的属性全部扩展到ret对象上,然后遍历childVal的属性键。在遍历的过程中如果parent值不为数组形式,则手动处理成数组形式,然后把child使用数组concat方法添加到数组的末尾。以上代码分析,可以使用下面的示例来说明:

// 情况一
const parentVal = {
  msg: function () {
    console.log('parent watch msg')
  }
}
const childVal = undefined
const ret = {
  __proto__: {
    msg: function () {
      console.log('parent watch msg')
    }
  }
}

// 情况二
const parentVal = undefined
const childVal = {
  msg: function () {
    console.log('child watch msg')
  }
}
const ret = {
  msg: function () {
    console.log('child watch msg')
  }
}

// 情况三
const parentVal = {
  msg: function () {
    console.log('parent watch msg')
  }
}
const childVal = {
  msg: function () {
    console.log('child watch msg')
  }
}
const ret = {
  msg: [
    function () {
      console.log('parent watch msg')
    },
    function () {
      console.log('child watch msg')
    }
  ]
}

hooks一样,如果在mixins里面也提供了与自身组件一样的watch,那么会优先执行mixins里面的watch,然后在执行自身组件中的watch

props、methods、inject和computed合并

propsmethodsinjectcomputed和之前我们提到的几种配置有点不一样,这几种配置有一个共同点:不允许存在相同的属性,例如我们在methods上提供的属性,不管来自于哪里,我们只需要把所有属性合并在一起即可。

接下来我们来看一下这几个属性在strats策略对象上的具体定义:

strats.props =
strats.methods =
strats.inject =
strats.computed = function (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  if (childVal && process.env.NODE_ENV !== 'production') {
    assertObjectType(key, childVal, vm)
  }
  if (!parentVal) return childVal
  const ret = Object.create(null)
  extend(ret, parentVal)
  if (childVal) extend(ret, childVal)
  return ret
}

我们可以看到,在其实现方法中代码并不是很复杂,仅仅使用到extend方法合并对象属性即可。当parentVal没有时,直接返回childVal,这里也不需要创建并返回一个原型,原因在上面提到过。如果parentVal有,则先创建一个原型,再使用extendparentVal上的所有属性全部扩展到ret对象上。最后再判断childVal,如果有则再使用extendchildVal上的对象扩展到ret上,如果没有,则直接返回。以上代码分析,我们举例说明:

const parentVal = {
  age: 23,
  name: 'AAA'
}
const parentVal = {
  address: '广州'
}
const ret = {
  age: 23,
  name: 'AAA',
  address: '广州'
}

上一篇:Vue2.0源码分析:响应式原理(下)
下一篇:Vue2.0源码分析:组件化(下)

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