Skip to content

选项合并策略

Vue的初始化过程中,最开始的阶段就是选项合并阶段。它通过调用mergeOptions函数将两个选项配置合并成一个选项配置。这里的选项options的形式实际上就是我们平时开发时在Vue中写的对象配置,形式如下:

json
{ 
  components: {}, 
  filters: {},
  data() { return {} }, 
  computed: {}, 
  created: {}, 
  methods: {},
  ... 
}

因此,选项合并实际可以简单的看作是两个上面的对象合并成一个对象。

由于mergeOptions是实现实例化(new Vue(options))、继承(Vue.extend)和混入(Vue.mixin)三大功能的核心函数,所以分析它的实现是理解Vue实例化过程和继承的必经之路。 下面我们将从以下几个方面来全面了解Vue中的选项合并:

  1. 实例化过程中的选项:了解我们需要合并的选项结构是怎样的。
  2. mergeOptions的实现:了解各个选项的合并策略。
  3. 理解为什么组件里的data需要是函数形式。
  4. 继承(Vue.extendextends:{})的选项合并。
  5. 混入(Vue.mixinmixins:[])的选项合并。
  6. 为什么实例化过程中有时用initInternalComponent而不是mergeOptions

实例化过程中的选项

从上一节的学习我们知道,Vue的实例化过程调用的是core/instance/init.js文件中的_init方法。

javascript
Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
  
   ...
  
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    )
  
   ...
  }

实例化的过程中第一个重要的处理就是选项的合并,这里第二个参数比较容易理解,就是我们平时写的Vue的配置项。第一个参数则是通过resolveConstructorOptions(vm.constructor)生成,找到resolveConstructorOptions方法,代码如下

javascript
// * 返回构造函数的 options
export function resolveConstructorOptions (Ctor: Class<Component>) {
  // * 如果不是继承,options 就是原构造函数的 options
  // * 如果是继承时,options 为合并 superOptions 和 extendOptions 的 options
  // * 此外,这里的 options 还包含了全局注册的 组件/指令/过滤器
  let options = Ctor.options
  // * Ctor.super 存在说明是调用了 extend 方法进行继承生成的构造函数
  // * 详见 /src/core/global-api/extend.js 文件
  // * - superOptions 是父类 options
  // * - extendOptions 是当前类传入的 options (如果与 sealedOptions不同,需要合并)
  // * - options = mergeOptions(superOptions, extendOptions)
  // * - sealedOptions 保存的是当前类继承时 合并后的 options(是extend的时候赋值的)
  if (Ctor.super) {
    const superOptions = resolveConstructorOptions(Ctor.super)
    const cachedSuperOptions = Ctor.superOptions
    // * 如果 superOptions 变动了,需要处理新的 options
    if (superOptions !== cachedSuperOptions) {
      Ctor.superOptions = superOptions
      // * sealedOptions 是 seal 的时候赋值的,
      // * 这里的变动可能是 options 在 extend 后继续被赋值
      // * 复现:https://jsfiddle.net/vvxLyLvq/2/
      // * 所以需要找出变动了的属性,然后更新到 extendOptions 上
      // * 这里的 extend 只是对象的合并
      const modifiedOptions = resolveModifiedOptions(Ctor)
      if (modifiedOptions) {
        extend(Ctor.extendOptions, modifiedOptions)
      }
      // * 由于 options 变化了,重新合并一次
      options = Ctor.options = mergeOptions(superOptions, Ctor.extendOptions)
      if (options.name) {
        // * 将自身的构造函数也存到了 components 对象中
        options.components[options.name] = Ctor
      }
    }
  }
  return options
}

这里传入的是当前构造函数,那么Ctor.options指的是Vue构造函数的options,还记得上一节中提到的Vue.options吗?这里将options的内容从上一节中复制过来,代码如下:

javascript
// Vue.options 内容
{
 components: {
   KeepAlive,
    // 新增 platformComponents
    Transition,
    // 新增 platformComponents
    TransitionGroup
  },
  filters: {},
  directives: {
   // 新增 platformDirectives
    model,
    // 新增 platformDirectives
    show
  },
  _base: Vue
}

再看下一句Ctor.super的判断,super这个字段是在core/global-api/extend.js文件中的extend方法调用时添加的。如果Ctor.super存在,说明Ctor是通过继承而来的子构造函数。但是,如果在extend后,我们又在父构造函数的options上添加新的属性,这个时候子构造函数是无法继承新的属性的。因此,这里需要通过Ctor.super向上寻找,找出所有父构造函数更新的options属性,并更新到子构造函数上,这样就能解决Vue.options被更改的问题了。有兴趣的话,可以看一下Vueissues#4976

最后,经过resolveConstructorOptions处理后,最终得到的同样是一个Vue的配置选项,下一步则是需要将这两个配置选项进行合并了。

mergeOptions的实现

选项校验和规范化

mergeOptions函数的定义是在/src/core/util/options.js文件当中,部分代码如下:

javascript
export function mergeOptions (
  parent: Object,// 选项
  child: Object, // 选项
  vm?: Component // Vue 实例
): Object {
  // * 1. 校验选项中的 components 里的名称是否合法。
  if (process.env.NODE_ENV !== 'production') {
    checkComponents(child)
  }
  
  // * ['type1'], { type2: { type: String, default: '' } } 两种形式
  // * 2. 都转换成后一种形式
  normalizeProps(child, vm)
  
  // * ['injectKey1'], { injectKey2: { from: 'xxx', default: 'yyy' } } 两种形式
  // * 3. 都转换成后一种形式
  normalizeInject(child, vm)
  
  // * directive 有两种形式 function() {} 或者是 { bind, update, ... }
  // * 4. 都转换成后一种形式
  normalizeDirectives(child)

  // * 合并策略
 ...
}

在正式合并之前,会优先校验components里的组件名称是否合法,如果不合法会进行提示。

javascript
function checkComponents (options: Object) {
  for (const key in options.components) {
    validateComponentName(key)
  }
}

// 校验组件名称是否合法
export function validateComponentName (name: string) {
  // 1. 判断组件名是否合法,如数字开头的则不合法
  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.'
    )
  }
  // 2. 判断组件是否是自身定义的组件名,如 slot 等。
  // 3. 判断组件是否是 html 中的标签名,如 div 等
  if (isBuiltInTag(name) || config.isReservedTag(name)) {
    warn(
      'Do not use built-in or reserved HTML elements as component ' +
      'id: ' + name
    )
  }
}

除此之外, Vue还做了以下几点处理。通过选项形式的转换,将多种写法的选项转换成统一形式:

  1. normalizeProps方法:处理props,将数组形式定义的props转换成对象形式。

  2. normalizeInject方法:处理inject,将数组形式定义的inject转换成对象形式。

  3. normalizeDirectives方法:处理directives,将指令数组里函数形式定义的directive转换成对象形式。

处理前和处理后对比结果如下:

javascript
// ===> 处理前 <===
{
 props: ['user-name'],
  inject: ['id'],
  directives: [function add() {}]
}

// ===> 处理后 <===
{
  // 对象形式
 props: {
   userName: { // 转换成驼峰命名
     type: null
    }
  },
  // 对象形式
  inject: {
   id: {
     from: 'id'
    }
  },
  directives: [{
    // 对象形式
   bind: function add() {}
    update: function add() {}
  }]
}

在校验完成之后,接下里就是正式的合并流程了,Vue针对每个规定的配置选项都有定义好的合并策略,例如data,component,mounted,methods等。如果Vue父子选项配置具有相应的选项,那么直接按照相应的合并策略进行合并。合并的入口如下:

javascript
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  ...
  // * 合并策略
  const options = {}
  let key
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  function mergeField (key) {
    // 根据 key 获取相应的合并策略
    const strat = strats[key] || defaultStrat
    // 用相应的合并策略进行合并
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

以上通过两个for循环,遍历parentchildkeykey这里指的是data/methods/created等),然后依次调用mergeField方法。mergeField则是通过keystrats中找到对应的合并策略,然后用该合并策略进行相应合并。如果找不到合并策略,则使用默认合并策略defaultStrat

这里的strats已经在该文件中定义,现在重点来看一下Vuestrats是如何定义合并策略的。

data合并

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

可以看出当vm不存在时,如果childValdata不为函数形式,那么在非开发环境下就会报错,这也是为什么我们平时在写组件data时需要写成函数形式的原因。

但是这里的vm在什么情况下不存在呢?我们可以全局搜索一下mergeOptions(,看看哪些位置调用了该方法:

搜索mergeOptions

可以看出,在extendmixin中,由于处理构造函数阶段时,是没有实例的,所以也就不会传vm。这里我们主要讨论extend

接下来我们全局搜索一下extend在哪些地方被调用了。

搜索extend

可以看到,extend方法主要在两个位置被调用。

第一个位置在 src/core/global-api/assets.js文件

javascript
ASSET_TYPES.forEach(type => {
    Vue[type] = function (
      id: string,
      definition: Function | Object
    ): Function | Object | void {
       ...
        if (type === 'component' && isPlainObject(definition)) {
          definition.name = definition.name || id
          // * 通过继承,返回新的构造函数(相当于 子组件 的构造函数)
          definition = this.options._base.extend(definition)
        }
       ...
        // * component / directive / filter 
        // * 将注册的内容全部添加到 Vue 构造函数的 options 上
        this.options[type + 's'][id] = definition
        return definition
    }
  })

通过遍历ASSET_TYPES,在Vue构造函数上添加了component静态属性,即当我们使用Vue.component的时候,实际上会执行这里的this.options._base.extend(definition),即调用了extend方法来将传入的组件选项合并后返回新的构造函数。

第二个位置在src/core/vdom/create-component.js文件

javascript
export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
 ...
  // * 这里的 Ctor 有几种形式
  // * 1. 全局形式定义的 component,那么 Ctor 是构造函数形式
  // * 2. 局部定义的 component,那么是 对象形式。会对对象形式进行 extend 处理
  const baseCtor = context.$options._base

  // 对象形式。会对对象形式进行 extend 处理
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }
 ...
  return vnode
}

这段代码在patch阶段执行的(仅做了解)。传入的Ctor一种情况是全局定义的组件,此时Ctor通过extend创建,传入的是构造函数形式。另外一种情况则是局部注册的组件,传入的是选项配置形式,此时会执行baseCtor.extend(Ctor),同样会通过extend来创建构造函数。

因此,无论是全局注册的组件还是局部组件,最终都会调用extend方法,而extend方法在合并选项的时候会校验传入的data是否是函数形式,这也就是为什么在定义组件时data必须是以函数形式定义。

现在我们已经知道了组件data定义时必须使用函数,但是为什么需要用函数定义呢,用普通的对象形式不行吗?答案是不行。我们知道,Vue组件是可以复用的,也就是同一个extend出来的Vue构造函数(也就是组件的构造函数),可以被多次实例化,但是数据是不应该被共享的。

如果data是对象形式,那么多个实例引用都是指向的同一个data对象。那么每次更改其中一个实例的data里的属性时,其他实例也会跟随着改变

如果data是函数形式,每次实例化时都会执行该函数,并且返回一个全新的data对象,这样多个实例之间就各自有一份独立的data了,从而解决数据被共享的问题。

好了,了解完了data在组件中为什么要为函数形式后,我们继续看data的后续合并过程。mergeDataOrFn函数执行时最终调用的都是mergeData函数:

javascript
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]
    // key 为 __ob__ 则跳过 
    if (key === '__ob__') continue
    toVal = to[key]
    fromVal = from[key]
    // * 自身不存在这个key,那么使用将 from 的 key 和 value 添加到 to 上
    // * 如果 to 原本是响应式的,那么新增的 key 值也需要是响应式的
    if (!hasOwn(to, key)) {
      set(to, key, fromVal)
    } else if (
      toVal !== fromVal &&
      isPlainObject(toVal) &&
      isPlainObject(fromVal)
    ) {
      // * 如果都是对象,继续合并
      mergeData(toVal, fromVal)
    }
  }
  return to
}

这里的mergeData比较简单,实际上就是递归将两个对象合并。需要注意的是,在合并的过程中,如果data是响应式的,那么合并后添加的属性也需要是响应式的。

生命周期合并

生命周期的钩子是在src/shared/constant.js中定义

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

mergeHook是生命周期钩子合并的策略,其核心是将父选项和子选项的对应生命周期合并成数组形式,如果存在相同的生命周期执行函数,那么会进行去重处理。

javascript
function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  const res = childVal
    ? parentVal
     // 都存在时,拼接数组
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
    // parent 不存在时
        ? childVal
        : [childVal]
  // child 不存在,使用 parent
    : parentVal
  return res
    ? dedupeHooks(res)
    : res
}

// hooks 去重
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
}

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

下面结合具体例子看看实际合并的结果:

javascript
const extend = {
  created() {
    console.log('extends')
  }
}
const mixins = {
  created() {
    console.log('mixins')
  }
}

// 父构造函数
const Parent = Vue.extend({
  created() {
    console.log('parent created')
  },
  mixins: [mixins],
  extends: extend,
})

// 子构造函数
const Child = Parent.extend({
  created() {
    console.log('child')
  },
  mixins: [mixins],
  extends: {
    created() {
      console.log('child extends')
    }
  }
})

new Child()
// extends
// mixins
// parent created
// child extends
// child

由于mixins里的created在合并时去重了,所以只会打印一遍mixins。另外可以看出,生命周期在执行时,parentextends/mixins里的生命周期都是优先于child生命周期执行的

components/filters/directives合并

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

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

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__

这合并资源选项的时候,首先会创建一个原型指向父选项的空对象,再将子选项赋值给空对象。注意这里的父选项是通过原型链访问,而子选项是直接添加到对象上的。例如:

javascript
Vue.component('test', {})
const vm = new Vue({
  components: {
    test: 'test'
  }
})

console.log('vm.$options ==> ', vm.$options);

// 合并后,父类的 options 通过 __proto__ 访问
{ 
  components: {
    test: "test",
    __proto__: {
      KeepAlive: { ... },
      Transition: { ... },
      TransitionGroup: { ... },
      test: ...
    }
  },
  directives: {},
  filters: {},
  _base: ...
}

这里的__proto__指向的就是父类选项的components

watch合并

watch的策略是:

  • 当子选项不存在时,使用父选项;

  • 当父选项不存在时,使用子选项;

  • 当父选项和子选项都存在时,如果他们具有相同的观测字段,那么将其合并成数组形式。

javascript
strats.watch = function (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  ...
  // 子类不存在,使用父类
  if (!childVal) return Object.create(parentVal || null)
  ...
  // 父类不存在,使用子类
  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
}

props,methods,inject,computed合并

这一类的选项合并比较简单:

  • 当父选项不存在时,使用子选项;

  • 当子选项不存在时,使用父选项;

  • 当两者都存在时,使用子选项覆盖父选项。

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

provide合并

使用的是mergeDataOrFn合并策略,同data合并。

总结

到这里我们就已经对所有的合并策略都有所了解了。总结一下就是

  1. data,provide,props,methods,inject,computedcomponents,filters,directives基本都是在父子选项同时存在的情况下,子覆盖父。
  2. 生命周期在父子选项同时存在的情况下,会合并成数组形式,且去重。
  3. watch在父子选项同时存在的情况下,会合并成数组形式,不去重。

Vue.extend的实现

Vue.extend的定义是在core/gloabl-api/extend.js文件里面,主要用于通过选项配参数生成新的构造函数。这里的参数extendOptions就是我们在定义组件时传入的配置选项。

javascript
Vue.extend = function (extendOptions: Object): Function {
  extendOptions = extendOptions || {}
  const Super = this
  const SuperId = Super.cid
  const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
  // * 1. 查看配置选项中是否缓存有构造函数
  if (cachedCtors[SuperId]) {
    return cachedCtors[SuperId]
  }

  // * 2. 校验组件名称
  const name = extendOptions.name || Super.options.name
  if (process.env.NODE_ENV !== 'production' && name) {
    validateComponentName(name)
  }

  // * 3. 继承
  const Sub = function VueComponent (options) {
    this._init(options)
  }
  Sub.prototype = Object.create(Super.prototype)
  Sub.prototype.constructor = Sub
  Sub.cid = cid++

  ...
}

我们先看前半段代码,前半段主要做了三件事:

  1. 检验是否通过该选项配置生成过相应构造函数,如果生成过,那么直接使用生成的构造函数即可。这里相当于做了一层优化。
  2. 校验组件名称是否合法。
  3. 通过原型实现继承,生成新的构造函数。

接下来就是选项合并了:

javascript
    // * 4. 合并选项
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )

参数Super.options就是Vue.options(父构造函数的静态属性options),前面已经提过两次,这里不再赘述了。这里合并后相当于将Super.options扩充了,并将扩充后的结果保存到Sub.options上(即新的构造函数options上),所以在通过该构造函数实例化的时候,拥有extendOptions配置的相关功能。

最后,会在新生成的构造函数上添加一些静态方法和属性。注意这里的superOptioins/extendOptions/sealedOptioins都在resolveConstructorOptions方法寻找options中使用到。

javascript
  // * 5. 添加 super
  Sub['super'] = Super
    
  ...

  // * 6. 添加一些方法
  // allow further extension/mixin/plugin usage
  Sub.extend = Super.extend
  Sub.mixin = Super.mixin
  Sub.use = Super.use
  ASSET_TYPES.forEach(function (type) {
    Sub[type] = Super[type]
  })
  if (name) {
    Sub.options.components[name] = Sub
  }

  // * 7. 添加一些属性
  // * 父选项
  Sub.superOptions = Super.options
  // * 传入的配置选项
  Sub.extendOptions = extendOptions
  // * 合并后的配置相许那个
  Sub.sealedOptions = extend({}, Sub.options)

最后总结来讲,Vue.extend方法实际上就是通过寄生组合式继承,将Vue.optionsextendOptions合并,返回一个新的构造函数。

Vue.mixin的实现

Vue.mixin方法的实现更是简单,打开core/global-api/mixin.js

javascript
Vue.mixin = function (mixin: Object) {
  this.options = mergeOptions(this.options, mixin)
  return this
}

不难看出,mixin实际上就是将两个选项配置进行合并。

最后

在结尾之前,这里再抛出一个问题,在Vue实例化过程中有一层判断,当_isComponenttrue时,将不会进行选项合并,这是为什么呢?

javascript
if (options && options._isComponent) {
  initInternalComponent(vm, options)
} else {
  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )
}

这是因为当_isComponenttrue时,此时为组件渲染阶段,options的内容为

javascript
const options: InternalComponentOptions = {
  _isComponent: true,
  _parentVnode: vnode,
  parent
}

此时并没有需要选项合并的项,所以也就没有必要mergeOptions了。

至此,选项合并的实现与应用我们已经学完了,下一章节将会学习Vue的核心模块之一:响应式系统。