Vue源码学习系列04——Vue构造函数解析(二): 选项合并策略(optionMergeStrategies)

上一节可以看成是merge数据前期准备,下面就介绍到了我们的mergeOption的重头戏啦——merge。先看代码(仍然是mergeOptions里面的代码):

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

可以看到,首先定义了options,再给这个options赋值,最后返回options。这就是mergeOptions方法主要干的事。那么,我们知道,Vue提供的属性很多,比如:el, data, props, filters … 这些属性是怎么来merge的呢?是不是有什么合并的规则,每个属性有自己的对应合并规则呢?
是的。
这段代码有两个for循环,for循环内部都使用了mergeField函数。我们先来看看这个函数:

function mergeField (key) {
  const strat = strats[key] || defaultStrat
  options[key] = strat(parent[key], child[key], vm, key)
}

该函数接受了一个key作为参数,这个key就是属性名称。然后定义了一个strat变量,它的值是strats[key] || defaultStrat,也就是说,会在starts这个对象中找有没有这个key对应的值,有就用这个值,没有的话就用defaultStrat作为默认值。所以,这个strats可以看成我们上面说的合并规则集。它的定义就在当前文件中,我们一点一点来看。

合并策略

/**
 * Option overwriting strategies are functions that handle
 * how to merge a parent option value and a child option
 * value into the final value.
 * 
 * 选项重写策略是一堆函数, 它们负责把一个父选项和一个子选项合并成一个最终的值
 */
const strats = config.optionMergeStrategies

首先在上面定义了这个对象。它的值是config.optionMergeStrategies,顺着依赖找到这个定义可以发现他就是一个空对象: Object.create(null)。所以此时: strats = {}

el和propsData

然后开始给它添加规则了,先是这两个:

/**
 * Options with restrictions
 * 
 * el 和 propsData 的合并策略一样
 * 注意: 它们只能给使用 new 操作符 创建的 Vue 实例使用
 */
if (process.env.NODE_ENV !== 'production') {
  strats.el = strats.propsData = function (parent, child, vm, key) {
    if (!vm) {
      warn(
        `option "${key}" can only be used during instance ` +
        'creation with the `new` keyword.'
      )
    }
    return defaultStrat(parent, child)
  }
}

很简单,就是给 elpropsData 设置合并规则——默认的规则。慢着,我们先看一下这个默认合并策略是什么吧,毕竟出现两次了,这也是在当前文件中定义的:

/**
 * Default strategy.
 */
const defaultStrat = function (parentVal: any, childVal: any): any {
  return childVal === undefined
    ? parentVal
    : childVal
}

默认的策略很简单,就是有孩子用孩子,没孩子用爸爸。也就是无第二个参数时就使用第一个参数的值。
Ok,继续看

data

接下来是data的合并规则:

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

首先的判断的 if(!vm), 还有内部的两个return,参数列表只少了个vm,那么有vm和没有vm有什么不一样吗?
首先,不一样是肯定的,现在你可以简单的理解为,没有vm的操作的是子组件

继续,如果没有提供vm的话,进入if分支,接着又是一个if:childVal && typeof childVal !== 'function',你可能不太了解这个判断,但是你肯定认识这段警告的内容:data选项要是一个函数。是不是很熟悉?
如果满足data是函数的话,则会执行:return mergeDataOrFn(parentVal, childVal)。当然,如果提供了vm的话,也会执行: mergeDataOrFn(parentVal, childVal, vm)。那么就来看看这个mergeDataOrFn的真面目吧。

/**
 * Data
 */
export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    // in a Vue.extend merge, both should be functions
    // 在 Vue.extend 的合并中,两个都应该是函数
    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.
    // 当 parentVal 和 childVal 都存在的时候,我们需要返回一个函数,这个函数返回这两个对象
    // 合并的结果。这里不需要检查parentVal是不是函数,因为它只有是函数才可以通过之前的合并。
    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
      // 实例上的合并(有vm的情况下)
      // 拿到 child 的值,如果是函数,执行它获取返回值,如果不是,直接用值
      const instanceData = typeof childVal === 'function'
        ? childVal.call(vm, vm)
        : childVal
      // 同理拿到 parent 的值
      const defaultData = typeof parentVal === 'function'
        ? parentVal.call(vm, vm)
        : parentVal
      // 如果 child 计算结果有值的话,则会返回合并函数,否则直接返回 parent 的计算结果
      if (instanceData) {
        return mergeData(instanceData, defaultData)
      } else {
        return defaultData
      }
    }
  }
}

首先,这个函数要么返回mergedDataFn函数,要么返回mergedInstanceDataFn,一个给vue实例化使用,一个给 Vue.extend 时使用。
其次我们看到,这两个函数中都返回了mergeData这个函数,没错,他就是合并data最终要执行的代码:

/**
 * Helper that recursively merges two data objects together.
 * 
 * 递归合并两个数据对象
 */
function mergeData (to: Object, from: ?Object): Object {
  if (!from) return to
  let key, toVal, fromVal
  const keys = Object.keys(from)
  for (let i = 0; i < keys.length; i++) {
    key = keys[i]
    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
}

这个函数最终会把值赋到to上,然后返回这个to。这段代码比较简单,就是两个对象的合并。举个例子:

let objA = {
  name: 'vue',
  author: {
    name: 'you',
    age: 18
  },
  home: 'China'
}

let objB = {
  name: 'react',
  author: {
    name: 'facebook',
    age: 30
  },
  version: 12
}

mergeData(objA, objB) 
/**
{
  author: {name: "you", age: 18}
  home: "China"
  name: "vue"
  version: 12
}
**/

好了,data的合并看完了,继续往下看:

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

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

这里是生命周期钩子的合并策略,这些钩子定义在 /src/shared/constants.js 中:

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

那么它的合并策略是啥样的呢?我们在代码中只看到这样一行很骚的代码:

return childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal

乍看很骚,实际上就是三元运算符的嵌套使用,个人觉得这样代码的可读性不好,anyway,我们来看看吧:

childVal ? (parentVal  ? parentVal.concat(childVal)  : (Array.isArray(childVal)  ? childVal : [childVal])): parentVal

也不是很难,加上括号以后就很清晰了。

  • 有childVal的话,进入下面的判断;没有的话,直接返回parentVal
  • 有parentVal的话(有的话它肯定是数组),给它拼上childVal;没有的话,用childVal的值(进入下面的判断)
  • childVal是数组的话,用它的值;不是的话,把它变成数组

所以我们最终的生命周期的每个钩子最后都是一个数组,包含着要在对应阶段要执行的操作(回调)

这段逻辑还是比较清晰的,我们继续往下看:

assets(directives, filters, components)
/**
 * Assets
 *
 * When a vm is present (instance creation), we need to do
 * a three-way merge between constructor options, instance
 * options and parent options.
 * 
 * 当 vm 存在的时候(实例化创建的时候), 我们需要三向合并(构造器的选项,实例选项,父选项)
 * constructor options: 我们自己定义的构造函数中的选项
 * instance options: 实例选项(原型上的)
 * parent options: 传进来的
 */
function mergeAssets (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): Object {
  // 以 parentVal 为原型创建一个res对象
  const res = Object.create(parentVal || null)
  if (childVal) {
    // 检查以下 childVal
    process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)
    // 实际上也是不同名会赋值,同名会覆盖
    return extend(res, childVal)
  } else {
    return res
  }
}

ASSET_TYPES.forEach(function (type) {
  strats[type + 's'] = mergeAssets
})

这段代码是给所谓的“资源”定义合并策略,这里的资源实际是在contans.js中定义的:

export const ASSET_TYPES = [
  'component',
  'directive',
  'filter'
]

这个就很简单啦,就是一个extend的过程。

接下来看看watch的合并对策略:

watch
/**
 * Watchers.
 *
 * Watchers hashes should not overwrite one
 * another, so we merge them as arrays.
 * 
 * watcher不应该合并,所以这里把他们合并成数组(回调数组)
 */
strats.watch = function (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  // work around Firefox's Object.prototype.watch...
  // 相当于修正 Firefox 上的 watch带来的影响
  if (parentVal === nativeWatch) parentVal = undefined
  if (childVal === nativeWatch) childVal = undefined
  /* istanbul ignore if */
  // 没有 childVal 的话, 以 parentVal 为参数创建一个对象后返回
  if (!childVal) return Object.create(parentVal || null)
  // 有 childVal 的话呢,检查一下是不是对象类型
  if (process.env.NODE_ENV !== 'production') {
    assertObjectType(key, childVal, vm)
  }
  // 再检查 parentVal 如果没有值则返回 childVal
  if (!parentVal) return childVal
  // 走到这里说明 parentVal 和 childVal 都有合法的值了,合并吧
  const ret = {}
  extend(ret, parentVal)
  for (const key in childVal) {
    let parent = ret[key] // 获取 parent 的值
    const child = childVal[key] // 获取 child 的值
    // 如果 parent 有值并且不是数组的话, 把它变成数组
    if (parent && !Array.isArray(parent)) {
      parent = [parent]
    }
    // ret[key] 的值由 parent 的值来决定,如果 parent 有值的话,那么就再拼接上 child 的值
    // 如果 parent 没有值的话,那么就用 child 的值,不是数组转成数组即可
    ret[key] = parent
      ? parent.concat(child)
      : Array.isArray(child) ? child : [child]
  }
  return ret
}

注释中写的很详细啦,对于 watch 呢,最后它会变成一个数组,这个数组里面注册着父子中定义的所有同属性的回调函数。而且执行顺序是父亲先孩子后

再看看其他选项的合并策略吧~

props, methods, inject, computed,provide
/**
 * Other object hashes.
 * 
 * props, methods, inject, computed 的合并策略
 */
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
  // 两个 extend 的先后顺序可以看到, childVal 的值是覆盖 parentVal的
  // 也就是说,如果父子拥有同个key,那么子的值将会作为最终的值
  const ret = Object.create(null)
  extend(ret, parentVal)
  if (childVal) extend(ret, childVal)
  return ret
}

// 别漏了这个, provide 的合并策略和 data 一样
strats.provide = mergeDataOrFn

从代码和注释中可以看出来,props, methods, inject, computed这四个对合并策略就比较残暴直接了,孩子会直接覆盖父亲对同名属性。注意哦,这里的props和injects已经在前面被normalize了,所以这里直接操作就行了。可以看出来normalize的一点作用了吧~
其次provide的合并策略就跟data的合并策略是一模一样啦~


至此,我们已经基本分析完了Vue所有选项的合并策略了。有啥感觉呢?是不是感觉懵懵的,就是那种懂但是有说不全的那种感觉?没事,下面我重新用一个例子来解释以下这种合并的结果是怎样的。上一节的例子这里不太适合,因为它没有父子的关系,是直接的实例化。我们这里弄一个父子的关系来举个例子,直观的感受下这些合并策略的结果:
在这里举的例子会涉及一个Vue的很重要的api:Vue.extend,这里我们不会介绍它的原理,你只要知道它是什么就行了。参考官网的api文档:API - Vue-extend

合并规则的例子

OK, 我们按照上面介绍的合并规则倒着来,趁热打铁,对于一些相同合并规则的只列举常见的属性来讲解了,先来看看:

  • props, methods, inject, computed

    let Son = Vue.extend({
       data () {
         return {
            name: { firstName: 'Jerry', lastName: 'Yuan' }
         }
       },
       computed: {
         fullName(){
           return this.firstName + this.lastName
         }
       },
       methods: {
         hello(){
            console.info('hello from parent')
         },
         fatherFunc(){
           console.info('father function')
         }
       }
     })
    
     let son = new Son({
       methods: {
         hello(){
           console.info('hello from son')
         },
         sonFunc(){
           console.info('son function')
         }
       },
       computed: {
         fullName(){
           return this.lastName + this.firstName
         }
       }
     })
     
     son.hello() 
     son.fatherFunc()
     son.sonFunc() 
     console.info(son.fullName)
    

    运行结果:

    hello from son
    father function
    son function
    YuanJerry
    

    可以看出来,无论是compute还是methods还是其他没列举的但是应该是表现一致的,同名总是会被孩子option覆盖的。

  • watch
    修改例子:

    let Son = Vue.extend({
       data () {
         return {
            name: 'Jerry'
         }
       },
       watch: {
         name(){
           console.info('name changed detected in parent')
         }
       }
     })
    
     let son = new Son({
       watch: {
         name(){
           console.info('name changed detected in son')
         }
       }
     })
    
     son.name = 'Judy'
    

    输出如下:

    name changed detected in parent
    name changed detected in son
    

    可见,watch的合并策略如我们所说,是一个回调队列,从父亲的开始执行。而且如果你仔细看源码的话,你会发现,这么写watch也是可以的:

    let Son = Vue.extend({
      data() {
        return {
          name: 'Jerry'
        }
      },
      watch: {
        name: [
          function(){
            console.info('name changed parent-01')
          },
          function(){
            console.info('name changed parent-02')
          }
        ]
      }
    })
    
    let son = new Son({
      watch: {
        name: [
          function() {
            console.info('name changed son-01')
          },
          function(){
            console.info('name chaned son-02')
          }
        ]
      }
    })
    
    son.name = 'Judy'
    

    输出结果:

    name changed parent-01
    name changed parent-02
    name changed son-01
    name chaned son-02
    

    是不是有点意思啊,哈哈哈~

  • assets(directives, filters, components)

  • hooks
    hooks就比较简单了:

    let Son = Vue.extend({
      created () {
        console.info('created in father')
      }
    })
    
    let son = new Son({
      created () {
        console.info('created in son')
      }
    })
    

    这时候就会输出

    before created
    created in father
    created in son
    

    watch有点像,它也可以以数组的形式来定义,这里就不举例子了。另外上面忘了说了,你可以在控制台打印出来看看这些钩子到底是什么样的:

Vue源码学习系列04——Vue构造函数解析(二): 选项合并策略(optionMergeStrategies)

我想你大概清楚了吧~你也可以在上面的例子上打印看看。

  • data, provide
    这里就以data为例吧,在上面的分析中我们知道,data最终是一个函数。
  let Son = Vue.extend({
    data () {
      return {
        name: {
          firstName: 'Jerry'
        },
        title: 'Back-end',
        father: 'I am father'
      }
    }
  })

  let son = new Son({
    data(){
      return {
        name: {
          lastName: 'Yuan'
        },
        title: 'Front-end',
        son: 'I am son'
      }
    }
  })

先来看看控制台吧:
Vue源码学习系列04——Vue构造函数解析(二): 选项合并策略(optionMergeStrategies)

我们这里要分析的其实是:son.$options.data,但是又发现其他两个跟data貌似有关系的属性:$data 和 _data,这两个是什么呢?
这里我们先有个印象即可,他们是响应式对象哦。也就是Vue响应式系统对我们定义的data做的一些很有趣的工作。
好吧,继续回到我们合并策略的分析中~这里我们就看一下最终的data是啥就行了。data不是函数吗?执行完就可以获取到值了:
Vue源码学习系列04——Vue构造函数解析(二): 选项合并策略(optionMergeStrategies)
这个函数的名称是mergedInstanceDataFn,最终的data数据呢,把name的firstName和lastName合并到name中去了,title覆盖了父亲定义的,然后father和son都保留着。
嗯~OK

  • el, propsData
    这两个就简单很多了,用的默认的策略:
  let Son = Vue.extend({})

  let son = new Son({
    el: '#app',
    propsData: {
      name: 'joker',
      title: 'FE Dev'
    }
  })

结果就是这样子:

son.$options.el // #app
son.$options.propsData // { name: 'joker', title: 'FE Dev'}

Ok,是不是明朗许多了。

现在我们结合上一节一开始的一个简单的例子,结合我们的合并策略,看看发生了什么:

var app = new Vue({
  el: '#app',
  data(){
    return {
      name: 'hello'
    }
  }
})

接本节一开始的内容:

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

这里我们先明确一下,parent, child, vm 都是在这里传入的(/src/core/instance/init.js的_init方法)

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

第一个参数resolveConstructorOptions(vm.constructor)我们之前也说了,在这个例子中,它就是Vue.options,而这个对象我们之前vue初始化都干了什么 已经分析过了:

{components: {keepAlive}, directives: {}, filters: {}, _base: Vue}

所以,这个就是我们的parent了吗?答案是不全是的!
我们在之前vue初始化都干了什么 这一节的一开始分析src目录的时候就已经说了,vue是多平台的,这时候应该是最内层的vue暴露出来,然后给对应的平台再包装一下(这里的平台是web)。所以,在vue的runtime时候,也会给vue添加一些东西,这里就包括了在Vue.options上添加的directivescomponents。具体的代码逻辑呢,可以参考这两句(/src/platforms/web/runtime/index.js):

// install platform runtime directives & components
extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)

各自对应的值分别是:
指令(directives)

{
  Transition,
  TransitionGroup
}

组件:

{
  model,
  show
}

这也就是我们在使用vue的时候,为什么可以在任何组件中使用v-show,<transition></transition>等内置功能的原因。
OK,所以我们这里的Vue.options,也就是本例中的parent完整的应该是:

{
  components: {KeepAlive, Transition, TransitionGroup},
  directives: {model, show},
  filters: {},
  _base: Vue
}

而child就是我们定义的options啦,就是它:

{
  el: '#app',
  data(){
    return {
      name: 'hello'
    }
  }
}

也就是:

vm.$options = mergeOptions(
  {
    components: {KeepAlive, Transition, TransitionGroup},
    directives: {model, show},
    filters: {},
    _base: Vue
  },
  {
    el: '#app',
    data(){
      return {
        name: 'hello'
      }
    }
  },
  vm
)

然后就交给mergeOptions啦,就是我们上一节介绍那么多的,你现在可以带着这个去回顾上一节加深一下印象也行~~
Ok,言归正传,这节的主要内容是合并策略,那你看,我们上面分析了那么多属性的合并策略,这里应该就很简单了吧。都在我们的分析范围内。
so,我们来看看这个$options的庐山真面目吧:
Vue源码学习系列04——Vue构造函数解析(二): 选项合并策略(optionMergeStrategies)

是不是很简单啦~也没什么复杂的(关于render,staticRenderFns我们之后分析到编译部分再说。)