人人都能懂的Vue原始碼系列(四)—mergeOptions

淼淼真人發表於2018-05-28

上篇文章中我們講了resolveConstructorOptions,它的主要功能是解析當前例項建構函式上的options,不太明白的同學們可以看本系列的前幾篇文章。在解析完建構函式上的options之後,需要把建構函式上的options和例項化時傳入的options進行合併操作,並生成一個新的options。

這個合併操作就是今天要講的mergeOptions。如果大家不想看枯燥的講解,可以翻到文章最後,檢視整個mergeOptions的流程圖。

Merge two option objects into a new one. Core utility used in both instantiation and inheritance.

先來看原始碼中對mergeOptions方法的註釋。mergeOptions的功能是合併兩個options物件,並生成一個新的物件,是例項化和繼承中使用的核心方法。既然這麼重要,那我就帶大家一行一行的來解析程式碼,讓大家看完這篇文章後,能基本上理解mergeOptions。

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)
  const extendsFrom = child.extends
  if (extendsFrom) {
    parent = mergeOptions(parent, extendsFrom, 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
}
複製程式碼

首先看傳入的三個引數,parent,child,vm,這三個引數分別代表的是該例項建構函式上的options,例項化時傳入的options,vm例項本身。結合Vue作者寫的註釋,我們明白了,原來mergeoptions方法是要合併建構函式和傳入的options這兩個物件。接下來往下看

if (process.env.NODE_ENV !== 'production') {
    checkComponents(child) // 檢查元件名稱是否合法
 }
複製程式碼

這段程式碼主要是判斷當前環境是不是生產環境,如果不是,則呼叫checkComponents方法來檢查元件名稱是否是可用名稱?我們來看看checkComponents的邏輯

function checkComponents (options: Object) {
  for (const key in options.components) {
    validateComponentName(key)
  }
}
export function validateComponentName (name: string) {
  if (!/^[a-zA-Z][\w-]*$/.test(name)) {
    warn(
      'Invalid component name: "' + name + '". Component names ' +
      'can only contain alphanumeric characters and the hyphen, ' +
      'and must start with a letter.'
    )
  }
  if (isBuiltInTag(name) || config.isReservedTag(name)) {
    warn(
      'Do not use built-in or reserved HTML elements as component ' +
      'id: ' + name
    )
  }
}
複製程式碼

如果child的options(例項化傳入的options)有components屬性,如下面這種情況

const app = new Vue({
   el: '#app',
   ...
   components: {
     childComponent
   }
   ...
})
複製程式碼

那麼就呼叫validateComponentName來驗證傳入的元件名稱是否符合以下特徵

  1. 包含數字,字母,下劃線,連線符,並且以字母開頭
  2. 是否和html標籤名稱或svg標籤名稱相同
  3. 是否和關鍵字名稱相同,如undefined, infinity等

如果滿足第一條,並且第2,3條都是不相同的話,那麼元件名稱可用。 我們再回到mergeOptions原始碼中

if (typeof child === 'function') {
    child = child.options
}
複製程式碼

如果child是function型別的話,取其options屬性作為child。 接下來看這三個以normalize開頭的方法

normalizeProps(child, vm)
normalizeInject(child, vm)
normalizeDirectives(child)
複製程式碼

這三個方法的功能類似,分別是把options中props,inject,directives屬性轉換成物件的形式。有時候傳入的是陣列,如下面這種情況

Vue.component('blog-post', {
  props: ['postTitle'],
  template: '<h3>{{ postTitle }}</h3>'
})
複製程式碼

normalizeProps

我們先來看props處理的邏輯

function normalizeProps (options: Object, vm: ?Component) {
  const props = options.props
  if (!props) return
  const res = {}
  let i, val, name
  if (Array.isArray(props)) {
    i = props.length
    while (i--) {
      val = props[i]
      if (typeof val === 'string') {
        name = camelize(val)
        res[name] = { type: null }
      } else if (process.env.NODE_ENV !== 'production') {
        warn('props must be strings when using array syntax.')
      }
    }
  } else if (isPlainObject(props)) {
    for (const key in props) {
      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
}
複製程式碼

首先明確這兩個方法裡的引數是什麼,options傳入的是child,即例項化時傳入的options,而vm是例項。繼續看原始碼

const props = options.props
if (!props) return
const res = {}
let i, val, name
複製程式碼

上面的程式碼主要是宣告一些變數。res用來存放修改後的props,最後把res賦給新的props。下面的邏輯可以分為兩種情況來考慮

props是陣列

當props是陣列的時候,如下面這種情況

Vue.component('blog-post', {
  props: ['postTitle'],
  template: '<h3>{{ postTitle }}</h3>'
})
複製程式碼

它的處理邏輯是:遍歷props陣列,把陣列的每一項的值作為res物件的key,value值等於{type: null}。即把上面例子中的['postTitle']轉換成下面這種形式

{
  postTitle: { type: null }
}
複製程式碼

props是物件

當props是物件時,如下面這種情況

Vue.component('my-component', {
  props: {
    // 必填的字串
    propC: {
      type: String,
      required: true
    }
  }
})
複製程式碼

這種情況的處理邏輯是遍歷物件,先把物件的key值轉換成駝峰的形式。然後再判斷物件的值,如果是純物件(即呼叫object.prototype.toString方法的結果是[object Object]),則直接把物件的值賦值給res,如果不是,則把{ type: 物件的值}賦給res。最終會轉換成

{
  propC: {
   type: String,
   required: true
  }
}
複製程式碼

如果傳入的props不是純物件也不是陣列,且當前環境也不是生產環境,則丟擲警告。

warn(
  `Invalid value for option "props": expected an Array or an Object, ` +
  `but got ${toRawType(props)}.`,
   vm
)
複製程式碼

最後,把處理過的props重新賦值給options.props。

normalizeInject

這個方法的邏輯和normalizeProps類似,主要是處理inject。inject屬性如果大家平時不是寫庫或者外掛的話,可能很少接觸到,可以點選inject的使用檢視。inject的傳入和props類似,可以傳入object,也可以傳入array

// array
var Child = {
  inject: ['foo'],
  created () {
    console.log(this.foo) // => "bar"
  }
  // ...
}
// object
const Child = {
  inject: {
    foo: {
      from: 'bar',
      default: 'foo'
    }
  }
}
複製程式碼

由於這個方法和normalizeProps邏輯基本一樣,這裡也不具體展開講了。上面的demo最終會被轉換成如下形式

// array
{
  foo: { from: 'foo'}
}
// object
{
 foo: {
   from: 'bar',
   default: 'foo'
 }
}
複製程式碼

normalizeDirectives

這個方法主要是處理一些自定義指令,如果不瞭解自定義指令的同學可以點選自定義指令檢視。這裡的方法處理邏輯主要針對自定義指令中函式簡寫的情況。如下

Vue.directive('color', function (el, binding) {
  el.style.backgroundColor = binding.value
})
複製程式碼

normalizeDirectives建構函式會把這個指令傳入的引數,最終轉換成下面這種形式

  color: {
    bind: function (el, binding) {
      el.style.backgroundColor = binding.value
    },
    update: function (el, binding) {
      el.style.backgroundColor = binding.value
    }
  }
複製程式碼

mergeOptions合併策略

講完了三個以normalize開頭的方法,我們回到mergeOptions中繼續往下看。

  const extendsFrom = child.extends
  if (extendsFrom) {
    parent = mergeOptions(parent, extendsFrom, vm)
  }
  if (child.mixins) {
    for (let i = 0, l = child.mixins.length; i < l; i++) {
      parent = mergeOptions(parent, child.mixins[i], vm)
    }
  }
複製程式碼

這段程式碼的處理的邏輯是,當傳入的options裡有mixin或者extends屬性時,再次呼叫mergeOptions方法合併mixins和extends裡的內容到例項的建構函式options上(即parent options)比如下面這種情況

 const childComponent = Vue.component('child', {
      ...
      mixins: [myMixin],
      extends: myComponent
      ...
 })
 const myMixin = {
      created: function () {
        this.hello()
      },
      methods: {
        hello: function () {
          console.log('hello from mixin')
      }
    }
  }
 const myComponent = {
      mounted: function () {
        this.goodbye()
      },
      methods: {
        goodbye: function () {
          console.log('goodbye from mixin')
        }
     }
  }
複製程式碼

把傳入的mounted, created鉤子處理函式,還有methods方法和parent options做合併處理。繼續看原始碼

const options = {}
let key
複製程式碼

變數options儲存合併之後的options,變數key儲存parent options和child options上的key值。接下來的部分算是mergeOptions方法的核心處理部分了,像炒菜一樣,前面的程式碼相當於把所有的菜都配好了,接下來的部分就是教你怎麼去炒菜了。

  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)
  }
複製程式碼

前兩段for迴圈程式碼大同小異,都是遍歷options上的key值,然後呼叫mergeField方法來處理options。mergeField方法中出現了一個變數strats和defaultStrat,這兩個變數儲存的就是我們的合併策略,也就是炒菜的菜譜,我們先來看看defaultStrat

const defaultStrat = function (parentVal: any, childVal: any): any {
  return childVal === undefined
    ? parentVal
    : childVal
}
複製程式碼

defaultStrat的邏輯是,如果child上該屬性值存在時,就取child上的屬性值,如果不存在,則取parent上的屬性值。

現在我們知道預設的合併策略是什麼了,接下來看其他的合併策略。我們來看看strats裡都有哪些屬性?

strats合併策略
上圖就是strats中所有的策略了,粗略看起來裡面的內容非常的多,如果細細分析會發現,其實總結起來無非就是幾種合併策略。

鉤子函式的合併策略

所有關於鉤子函式的策略,其實都是呼叫mergeHook方法。

function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  return childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal
}
複製程式碼

mergeHook採用了一個非常騷的巢狀三元表示式來控制最後的返回值。下面我們來解析這段三元表示式 (1) child options上不存在該屬性,parent options上存在,則返回parent上的屬性。

圖片描述

(2)child和parent都存在該屬性,則返回concat之後的屬性

圖片描述

(3)child上存在該屬性,parent不存在,且child上的該屬性是Array,則直接返回child上的該屬性

圖片描述

(4) child上存在該屬性,parent不存在,且child上的該屬性不是Array,則把該屬性先轉換成Array,再返回。

圖片描述
上面就是鉤子函式合併策略,結合圖片看應該會比較清晰。

props/methods/inject/computed的策略

介紹完了鉤子函式的合併策略,我們接下來看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
}
複製程式碼

這個合併方法邏輯很簡單,如果child options上這些屬性存在,則先判斷它們是不是物件。

  1. 如果parent options上沒有該屬性,則直接返回child options上的該屬性。
  2. 如果parent options和child options都有,則合併parent options和child options並生成一個新的物件。(如果parent和child上有同名屬性,合併後的以child options上的為準)

components/directives/filters的合併策略

components/directives/filters這幾個屬性的處理邏輯如下

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
  }
}
複製程式碼

這裡的處理邏輯和上一種情況的類似,這裡不做過多講解。

data和provide的策略

data和provide的策略相對來說複雜一些,我們先來看程式碼

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
      }
    }
  }
}
複製程式碼

這個合併策略可以分成兩種情況來考慮。

第一種情況,當前呼叫mergeOptions操作的是vm例項(呼叫new新建vue例項觸發mergeOptions方法)

   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
      }
    }
複製程式碼

如果新建例項時傳入的options上有data屬性,則呼叫mergeData方法合併例項上的data屬性和其建構函式options上的data屬性(如果有的話)

第二種情況,當前呼叫mergeOptions操作的不是vm例項(即通過Vue.extend/Vue.component呼叫了mergeOptions方法)

 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
      )
    }
  }
複製程式碼

在這種情況下,其處理邏輯也是類似的。如果當前例項options或者建構函式options上有一個沒有data屬性,則返回另一個的data屬性,如果兩者都有,則同樣呼叫mergeData方法處理合並。

既然這兩種情況都呼叫了mergeData方法,那我們就繼續來看看mergeData的原始碼。

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 (isPlainObject(toVal) && isPlainObject(fromVal)) {
      mergeData(toVal, fromVal)
    }
  }
  return to
}
複製程式碼

mergeData的邏輯是,如果from物件中有to物件裡沒有的屬性,則呼叫set方法,(這裡的set就是Vue.$set,先可以簡單理解為物件設定屬性。之後會細講)如果from和to中有相同的key值,且key對應的value是物件,則會遞迴呼叫mergeData方法,否則以to的值為準,最後返回to物件。

這裡我們就講完了data的合併策略,返回mergeOptions程式碼裡,在經過這幾種合併策略合併options後,最終返回合併後的options。

return options
複製程式碼

總結

講到這裡,整個mergeOptions的流程也全講完了。這個方法牽扯到的內容比較多,流程也比較複雜,為了大家更好的理解和記憶,我畫了一張圖來表達整個mergeOptions的過程。

mergeoptions的流程圖
如果大家覺得我的文章寫的還行,請為我點贊,你們的認可是我最大的動力。

相關文章