Vue探究:精读mixin与mergeOptions

3,467 阅读9分钟

mixin在vue框架中的定位是实现逻辑复用的功能,可以类比javascript中的混合继承方式。实现逻辑复用的方式有很多种,比如react提倡的高阶组件、hooks等等,当然,在Vue中官方推荐的且使用频次最高的还是mixin。

本篇文章将会探讨Vue底层如何实现mixin,且mixin对vue各个配置项是如何处理的,以及混合的顺序如何等等问题。

Mixin实现方式

组件调用mixin的方式有两种:

  • Vue.mixin():直接调用组件构造函数上的mixin静态方法。
  • vue options->{ mixins: [] }:在组件的配置对象中挂载mixins的成员。

无论以上使用了哪种方式,最终调用的都是mergeOptions这个工具方法。

以Vue.mixin举例:

// src/core/global-api/mixin.js
import { mergeOptions } from '../util/index'

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

可以看到mergeOptions同字面意义一样,将多个options进行合并,生成一个新的options。

mergeOptions是vue中比较重要的辅助函数之一,除了在mixin中使用外还在extend、实例化阶段使用到:

// src/core/global-api/extend.js
Vue.extend = function (extendOptions: Object): Function {
  ...
  Sub.options = mergeOptions(
    Super.options,
    extendOptions
  )
}

// src/core/instance/init.js
Vue.prototype._init = function (options?: Object) {
  ...
  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )
}

mergeOptions

首先来看一下mergeOptions的主体代码:

// src/core/util/options.js
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  ...
    
  // 规范化props
  normalizeProps(child, vm)
  // 规范化inject
  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.
  // 未合并的options不带有_base
  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主要经历了两个步骤:

  1. 规范化配置项。
  2. 未合并的extends和mixins,针对不同字段选择不同合并策略递归合并每个options。

normalize

normalize同字面意思一样,用来规范化属性,比如props,可以使用对象语法,可以使用数组语法,而数组又可以是函数数组或者是字符串数组。所以normalize的作用就是统一将这些不同的类型处理成对象类型的格式。

normalizeProps
function normalizeProps (options: Object, vm: ?Component) {
  const props = options.props
  if (!props) return
  const res = {}
  let i, val, name
  // props是数组类型
  // props: [ 'someObjA', 'someObjB' ]
  if (Array.isArray(props)) {
    i = props.length
    while (i--) {
      val = props[i]
      if (typeof val === 'string') {
        // 将-改驼峰命名
        name = camelize(val)
        // string类型规范化为 { someObjA: { type: null } }
        res[name] = { type: null }
      } else if (process.env.NODE_ENV !== 'production') {
        warn('props must be strings when using array syntax.')
      }
    }
  } else if (isPlainObject(props)) {
    // 对象类型的props
    // props: { someObjA: String }
    // props: { someObjA: [ Number, String ] }
    // props: { someObjA: { type: Number, default: 1 } }
    for (const key in props) {
      // 如果是纯对象形式,如props类型3则直接使用,否则将属性后面的值作为type(如String)
      val = props[key]
      name = camelize(key)
      res[name] = isPlainObject(val)
        ? val
        : { type: val }
    }
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `Invalid value for option "props": expected an Array or an Object, ` +
      `but got ${toRawType(props)}.`,
      vm
    )
  }
  options.props = res
}

在normalizeProps中,字符串数组类型的props都会处理成type为null的类型,这里要注意的是,props会根据定义的type不同,而给传进来的props给予不同的默认值,比如我们直接在组件模版上写require这个属性:

  • type为Boolean:默认给require赋值为true
  • type为null:默认赋值为undefined
  • type为非Boolean:赋值为""
normalizeInject
function normalizeInject (options: Object, vm: ?Component) {
  const inject = options.inject
  if (!inject) return
  const normalized = options.inject = {}
  if (Array.isArray(inject)) {
    for (let i = 0; i < inject.length; i++) {
      // 字符串数组处理成 { bar: { from: 'bar' }}格式
      normalized[inject[i]] = { from: inject[i] }
    }
  } else if (isPlainObject(inject)) {
    for (const key in inject) {
      const val = inject[key]
      normalized[key] = isPlainObject(val)
        ? extend({ from: key }, val)
        : { from: val }
    }
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `Invalid value for option "inject": expected an Array or an Object, ` +
      `but got ${toRawType(inject)}.`,
      vm
    )
  }
}

inject/provide是Vue 2.2.0版本引入特性,normalizeInject对inject的处理同props过程相似,都是处理成对象类型格式,但不同的是,面对对象类型时normalizeInject又做了一层处理:

inject: {
  foo: { someProperty: 'bar' }
}
// 处理后
inject: {
  'foo': { from: 'foo', someProperty: 'bar' }
}

这里对象的处理依旧是再次规范化了一下。

normalizeDirectives
function normalizeDirectives (options: Object) {
  const dirs = options.directives
  if (dirs) {
    for (const key in dirs) {
      const def = dirs[key]
      if (typeof def === 'function') {
        dirs[key] = { bind: def, update: def }
      }
    }
  }
}

normalizeDirectives对指令进行了规范化处理,同样统一处理成了对象类型。我们知道Vue针对指令,提供了两种定义但方式:

// 处理前
directives: {
    b: function () {
      console.log('v-b')
    }
}
// 处理后
directives: {
    b: {
        bind: function(){
            console.log('v-b')
        },
        update: function(){
            console.log('v-b')
        }
    }
}

综上,props、inject、directive统一处理成了扩展度较高的对象类型格式,并且格式化后的数据会被重新赋值给传入的第一个参数(这里是child),之后就是递归处理被合并项的extends和mixins成员了,再递归合并之前先做了一次_base的判断,这里的_base指向Vue构造函数,_base属性存在于Vue.options上,由于组件初始化阶段一定会merge Vue options并返回一个新的options,所以被合并的options一定会存在_base属性。

合并策略

以上是进行合并前的数据处理阶段,而mixin真正重要的阶段其实是mergeField阶段,我们知道两个组件options可以存在相同的选项,比如都具有methods对象,但methods对象挂载的方法可能相同可能不同,其它选项也可以类比。mergeField的作用就是考虑使用何种策略去处理这些选项,返回我们需要的配置。

在mergeOptions函数逻辑最后,首先申请了一个新的存储对象options,将parant与child都经过mergeField处理再合并进options中。

相关代码:

function mergeField (key) {
    // 策略模式-根据key选择不同的处理函数
    const strat = strats[key] || defaultStrat
    // 调用处理函数,合并两个选项
    options[key] = strat(parent[key], child[key], vm, key)
}

当mixin两个普通的对象的时候,可以使用深度优先去一层一层拷贝比对来合并值,但在vue中,简单的拷贝赋值并不能满足组件构造函数的需求,还需要将传进来的配置项进行处理。

因此组件实例化的过程,可以看作一个工厂,data、props、methods这些可以看作原材料,经过各个流水线工人的处理加工,就拿到了生产组件所需要的成品,这里的strat可以看到是加工原材料的工人。

所以理解mergeOptions的核心其实就是理解strate的过程。

首先来看Vue是如何定义strates的:

/**
 * 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
defaultStrat
const defaultStrat = function(parentVal: any, childVal: any): any {
  return childVal === undefined
    ? parentVal
    : childVal
}

defaultStrat表示默认的合并策略,childVal是需要处理的选项,当选项值不为undefined时,直接返回该选项。这也就意味着,那些未命中合并策略的选项将会被child中的选项直接覆盖。比如parent与child的组件options中均存在 demo: { ... } 属性,mixin后,parent中的demo无论定义为何值都会被child中的demo覆盖。

合并el与propsData
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)
}

对el和propsData处理是直接返回了默认策略,但返回之前加了对vm的判断,通过看warn可以得知el和propsData是通过new关键字实例化组件才可以使用的属性。只要未传入vm变量,就不能声明这两个字段。

传入了vm的场景:

  • init:new实例化调用时的初始化阶段:
vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
)

未传入vm的场景:

  • Vue.mixin
  • Vue.extends

也就是说,子组件和mixin对象不能定义el和propsData这两个字段。

合并data与provide
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)
}

// provide
strats.provide = mergeDataOrFn

合并data的逻辑异常繁琐,这也是非常必要的,因为data作为本地组件的状态管理器,挂载各种类型的状态,同时需要合并的data类型可能为对象也可能是函数,但返回结果,必须为函数类型。

data字段的合并策略依旧是首先判断了子组件data的类型必须为函数。之后继续调用了mergeDataOrFn。

export function mergeDataOrFn(
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    ...
    // extend或者mixin调用
    return function mergedDataFn() {
      return mergeData(
        typeof childVal === 'function' ? childVal.call(this, this) : childVal,
        typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
      )
    }
  } else {
    // new调用
    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函数最终返回了一个函数mergedDataFn或者mergedInstanceDataFn,函数值函数赋值给options.data属性,也就是说data属性最终会被处理成一个函数(防止引用传递),data属性真正合并的阶段放到了组件的初始化阶段。但无论是哪种函数,mergeData拿到都是需要合并的两个对象。

function mergeData(to: Object, from: ?Object): Object {
  if (!from) return to
  let key, toVal, fromVal
  
  // Object.keys拿不到不可枚举的属性
  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...
    // __ob__不合并
    if (key === '__ob__') continue
    toVal = to[key]
    fromVal = from[key]
    if (!hasOwn(to, key)) {
      // 若to不存在该属性,则使用set赋值
      set(to, key, fromVal)
    } else if (
      // 对象类型则深度合并
      toVal !== fromVal &&
      isPlainObject(toVal) &&
      isPlainObject(fromVal)
    ) {
      mergeData(toVal, fromVal)
    }
  }
  return to
}

从上面的过程可以看到真正执行data合并策略的过程在mergeData内部,而该函数却是在组件初始化阶段才调用,这样做的主要目的其实是为了保证data中可以访问到props对应的属性。

provide的合并策略与data相同。

合并生命周期
function mergeHook(
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  // res返回了一个数组
  // parentVal在与vue.options合并阶段不存在,所以不会命中parentVal.concat方法,则返回[childVal]
  // 与之后的options合并的时候,parentVal一定是数组
  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
}
// 'beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'beforeDestroy', 'destroyed', 'activated', 'deactivated', 'errorCaptured', 'serverPrefetch'
LIFECYCLE_HOOKS.forEach(hook => {
  // { created(){}} -> {created: [function created(){}]}
  strats[hook] = mergeHook
})

mergeHook函数会将parent与child的生命周期钩子函数合并成数组形式,比如:

parent.options.created = function() {
    console.log('parentCreated')
}
child.options.created = function() {
    console.log('childCreated')
}
// 合并后
[
    {
        created: function() {
            console.log('parentCreated')
        }
    },
    {
        created: function() {
            console.log('childCreated')
        }
    }
]

最后,生命周期的钩子函数会在callhook中依次调用。虽然是依次执行的,但关于函数放置的顺序有一些需要注意的事项。

回到开头提到的mixin使用方式:

  • Vue.mixin()
  • vue options->{ mixins: [] }

我们编写一个demo来测试一下这两种方式的生命周期钩子函数调用的顺序:

// a.js
export default {
    created() {
        console.log('a')
    }
}
// b.js
import mixinA from './a.js'
export default {
    mixins: [mixinA],
    created() {
        console.log('b')
    }
}
// a
// b

可以看到调用顺序同官网说明的方式一样。

// a.js
export default {
    created() {
        console.log('a')
    }
}
// b.js
export default {
    created() {
        console.log('b')
    }
}
// index.js
import Vue from 'vue'
import A from './a.js'
import B from './b.js'

const BComponent = Vue.extends('BComponent', B)
B.mixin(A)

new BComponent()

// b
// a

调用顺序与在options内挂载mixins方式调用顺序相反,这是为什么呢?

这是因为,组件在实例化的初始阶段,一定会与Vue.options进行一次mergeOptions,Vue.options并不存在任何生命周期钩子函数,且mergeOptions内会优先处理组件options上挂载的mixins,所以mixins内的钩子函数会优先被push到对应生命周期hook的第一位,所以在调用的时候,mixins混入组件的生命周期会优先调用。

而方式二中,首先调用了extends与Vue.options进行了merge,拿到了BComponent,BComponent中的hook数组第一位是B组件中的hook函数,之后再mixin其它任何组件都只能排在B组件的hook后面。

合并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
    const ret = Object.create(null)
    extend(ret, parentVal)
    if (childVal) extend(ret, childVal)
    return ret
  }

props、methods、inject、computed这几个固定都是对象类型(props与inject会被规范化成对象类型),直接进行拷贝赋值即可,同名的选项会被覆盖。在组件内部mixins引入混合组件时,由于宿主组件总是最后处理,所以当混合组件和宿主组件在props、methods、inject、computed中存在同名属性时,会被宿主组件对应的选项覆盖。而调用mixin方法混合的方式则相反,这一点需要注意。

合并directives、filters、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
  }
}

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

乍看之下,directives、filters、components与methods等合并策略很相似,唯独声明res变量的方式不一样,mergeAssets中使用的是const res = Object.create(parentVal || null)而不是直接覆盖,这是为什么呢?

我们知道,vue内部提供了一些内置的指令如v-for、v-if等,和一些内置的组件如KeepAlive、Transition等,我们可以直接在组件内部使用他们,但奇怪的是,vue并没有显式的去注册他们,这实现的关键就在const res = Object.create(parentVal || null)

这些内置属性其实就存在Vue.options上,经过mergeAssets处理后会变成:

options = {
    ...
    components: {
        ...
        __proto__: {
            ...
            Transition
        }
    }
}

这样的结构。使用对应的选项时,会顺着对应选项的原型链一层一层向上寻找选项。

实现的非常巧妙,既满足了同名属性'覆盖',又可以内置选项。

合并watch
// watch挂载的选项不可以直接进行覆盖,需要将每个选项处理成函数数组形式。
strats.watch = function(
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  // Firefox中存在原生的Object.prototype.watch函数
  // 为定义watch选项却访问到了watch属性,则重置parentVal与childVal
  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
  // 如果定义了watch选项
  // 将选项的每个成员处理成数组类型
  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
}

由于在Firefox中存在原生的Object.prototype.watch函数,在合并watch函数过程中如果访问到了原生函数,需要做兼容性处理。

合并watch同合并声明周期存在相似之处,都是将选项合并为数组类型。

总结

合并步骤:

  1. 规范化选项
  2. 优先合并extends和mixins的选项。
  3. 合并
  • defaultStrat:defaultStrat是默认到合并策略,未命中其它策略到话将会走默认策略,childVal将会覆盖parentVal。
  • 合并el和propsData:el与propsData只有new场景可以定义,子组件中不能使用。
  • 合并data:data首先会被合并成一个函数,该函数会在组件初始化阶段调用,再执行真正的合并逻辑(深度合并)。
  • 合并声明周期:声明周期会被处理成数组形式,parentVal始终处于数组开始位置。
  • 合并props、methods、inject、computed:先合并parentVal,childVal同名属性会进行覆盖。
  • 合并watch:合并过程与合并声明周期函数类似,parentVal与childVal对应属性合并成数组类型。

转载请注明出处!感谢!