Vue探究:精讀mixin與mergeOptions

mixin在vue框架中的定位是實現邏輯複用的功能,能夠類比javascript中的混合繼承方式。實現邏輯複用的方式有不少種,好比react提倡的高階組件、hooks等等,固然,在Vue中官方推薦的且使用頻次最高的仍是mixin。javascript

本篇文章將會探討Vue底層如何實現mixin,且mixin對vue各個配置項是如何處理的,以及混合的順序如何等等問題。vue

Mixin實現方式

組件調用mixin的方式有兩種:java

  • Vue.mixin():直接調用組件構造函數上的mixin靜態方法。
  • vue options->{ mixins: [] }:在組件的配置對象中掛載mixins的成員。

不管以上使用了哪一種方式,最終調用的都是mergeOptions這個工具方法。react

以Vue.mixin舉例:api

// 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、實例化階段使用到:bash

// 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主要經歷了兩個步驟:ide

  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對應屬性合併成數組類型。

轉載請註明出處!感謝!

相關文章
相關標籤/搜索