1. 程式人生 > >vue原始碼(六)Vue 選項的合併

vue原始碼(六)Vue 選項的合併

本文是學習vue原始碼,之所以轉載過來是方便自己隨時檢視,在這裡要感謝HcySunYang大神,提供的開源vue原始碼解析,寫的非常非常好,簡單易懂,比自己看要容易多了,他的文章連結地址是http://hcysun.me/vue-design/art/

上一章節我們瞭解了 Vue 對選項的規範化,而接下來才是真正的合併階段,我們繼續看 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

這段程式碼的第一句和最後一句說明了 mergeOptions 函式的的確確返回了一個新的物件,因為第一句程式碼定義了一個常量 options,而最後一句程式碼將其返回,所以我們自然可以預估到中間的程式碼是在充實 options 常量,而 options 常量就應該是最終合併之後的選項,我們看看它是怎麼產生的。

首先我們明確一下程式碼結構,這裡有兩個 for in 迴圈以及一個名字叫 mergeField 的函式,而且我們可以發現這兩個 for in 迴圈中都呼叫了 mergeField

 函式。我們先看第一段 for in 程式碼:

for (key in parent) {
  mergeField(key)
}

這段 for in 用來遍歷 parent,並且將 parent 物件的鍵作為引數傳遞給 mergeField 函式,大家應該知道這裡的 key 是什麼,假如 parent 就是 Vue.options

Vue.options = {
  components: {
      KeepAlive,
      Transition,
      TransitionGroup
  },
  directives:{
      model,
      show
  },
  filters: Object.create(null),
  _base: Vue
}

那麼 key 就應該分別是:componentsdirectivesfilters 以及 _base,除了 _base 其他的欄位都可以理解為是 Vue 提供的選項的名字。

而第二段 for in 程式碼:

for (key in child) {
  if (!hasOwn(parent, key)) {
    mergeField(key)
  }
}

其遍歷的是 child 物件,並且多了一個判斷:

if (!hasOwn(parent, key))

其中 hasOwn 函式來自於 shared/util.js 檔案,可以在 shared/util.js 檔案工具方法全解 中檢視其詳解,其作用是用來判斷一個屬性是否是物件自身的屬性(不包括原型上的)。所以這個判斷語句的意思是,如果 child 物件的鍵也在 parent 上出現,那麼就不要再呼叫 mergeField 了,因為在上一個 for in 迴圈中已經呼叫過了,這就避免了重複呼叫。

總之這兩個 for in 迴圈的目的就是使用在 parent 或者 child 物件中出現的 key(即選項的名字) 作為引數呼叫 mergeField 函式,真正合並的操作實際在 mergeField 函式中。

mergeField 程式碼如下:

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

mergeField 函式只有兩句程式碼,第一句程式碼定義了一個常量 strat,它的值是通過指定的 key 訪問 strats 物件得到的,而當訪問的屬性不存在時,則使用 defaultStrat 作為值。

這裡我們就要明確了,strats 是什麼?想弄明白這個問題,我們需要從整體角度去看一下 options.js檔案,首先看檔案頂部的一堆 import 語句下的第一句程式碼:

/**
 * 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

這句程式碼就定義了 strats 變數,且它是一個常量,這個常量的值為 config.optionMergeStrategies,這個 config 物件是全域性配置物件,來自於 core/config.js 檔案,此時 config.optionMergeStrategies 還只是一個空的物件。注意一下這裡的一段註釋:選項覆蓋策略是處理如何將父選項值和子選項值合併到最終值的函式。也就是說 config.optionMergeStrategies 是一個合併選項的策略物件,這個物件下包含很多函式,這些函式就可以認為是合併特定選項的策略。這樣不同的選項使用不同的合併策略,如果你使用自定義選項,那麼你也可以自定義該選項的合併策略,只需要在 Vue.config.optionMergeStrategies 物件上新增與自定義選項同名的函式就行。而這就是 Vue 文件中提過的全域性配置:optionMergeStrategies

#選項 el、propsData 的合併策略

那麼接下來我們就看看這個選項合併策略物件都有哪些策略,首先是下面這段程式碼:

/**
 * Options with restrictions
 */
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)
  }
}

非生產環境下在 strats 策略物件上新增兩個策略(兩個屬性)分別是 el 和 propsData,且這兩個屬性的值是一個函式。通過這兩個屬性的名字可知,這兩個策略函式是用來合併 el 選項和 propsData 選項的。與其說“合併”不如說“處理”,因為其本質上並沒有做什麼合併工作。那麼我們看看這個策略函式的具體內容,瞭解一下它是怎麼處理 el 和 propsData 選項的。

首先是一段 if 判斷分支,判斷是否有傳遞 vm 引數:

if (!vm) {
  warn(
    `option "${key}" can only be used during instance ` +
    'creation with the `new` keyword.'
  )
}

如果沒有傳遞這個引數,那麼便會給你一個警告,提示你 el 選項或者 propsData 選項只能在使用 new 操作符建立例項的時候可用。比如下面的程式碼:

// 子元件
var ChildComponent = {
  el: '#app2',
  created: function () {
    console.log('child component created')
  }
}

// 父元件
new Vue({
  el: '#app',
  data: {
    test: 1
  },
  components: {
    ChildComponent
  }
})

上面的程式碼中我們在父元件中使用 el 選項,這並沒有什麼問題,但是在子元件中也使用了 el 選項,這就會得到如上警告。這說明了一個問題,即在策略函式中如果拿不到 vm 引數,那說明處理的是子元件選項。所以問題來了,為什麼通過判斷 vm 是否存在,就能判斷出是否是子元件呢?那首先我們要搞清楚策略函式中的 vm 引數是哪裡來的。首先我們還是看一下 mergeField 函式:

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

函式體的第二句程式碼中在呼叫策略函式的時候,第三個引數 vm 就是我們在策略函式中使用的那個 vm,那麼這裡的 vm 是誰呢?它實際上是從 mergeOptions 函式透傳過來的,因為 mergeOptions 函式的第三個引數就是 vm。我們知道在 _init 方法中呼叫 mergeOptions 函式時第三個引數就是當前 Vue 例項:

// _init 方法中呼叫 mergeOptions 函式,第三個引數是 Vue 例項
vm.$options = mergeOptions(
  resolveConstructorOptions(vm.constructor),
  options || {},
  vm
)

所以我們可以理解為:策略函式中的 vm 來自於 mergeOptions 函式的第三個引數。所以當呼叫 mergeOptions 函式且不傳遞第三個引數的時候,那麼在策略函式中就拿不到 vm 引數。所以我們可以猜測到一件事,那就是 mergeOptions 函式除了在 _init 方法中被呼叫之外,還在其他地方被呼叫,且沒有傳遞第三個引數。那麼到底是在哪裡被呼叫的呢?這裡可以先明確地告訴大家,就是在 Vue.extend方法中被呼叫的,大家可以開啟 core/global-api/extend.js 檔案找到 Vue.extend 方法,其中有這麼一段程式碼:

Sub.options = mergeOptions(
  Super.options,
  extendOptions
)

可以發現,此時呼叫 mergeOptions 函式就沒有傳遞第三個引數,也就是說通過 Vue.extend 建立子類的時候 mergeOptions 會被呼叫,此時策略函式就拿不到第三個引數。

所以現在就比較明朗了,在策略函式中通過判斷是否存在 vm 就能夠得知 mergeOptions 是在例項化時呼叫(使用 new 操作符走 _init 方法)還是在繼承時呼叫(Vue.extend),而子元件的實現方式就是通過例項化子類完成的,子類又是通過 Vue.extend 創造出來的,所以我們就能通過對 vm 的判斷而得知是否是子元件了。

所以最終的結論就是:如果策略函式中拿不到 vm 引數,那麼處理的就是子元件的選項,花了大量的口舌解釋了策略函式中判斷 vm 的意義,實際上這些解釋是必要的。

我們接著看 strats.el 和 strats.propsData 策略函式的程式碼,在 if 判斷分支下面,直接呼叫了 defaultStrat 函式並返回:

return defaultStrat(parent, child)

defaultStrat 函式就定義在 options.js 檔案內,原始碼如下:

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

實際上 defaultStrat 函式就如同它的名字一樣,它是一個預設的策略,當一個選項不需要特殊處理的時候就使用預設的合併策略,它的邏輯很簡單:只要子選項不是 undefined 那麼就是用子選項,否則使用父選項。

但是大家還要注意一點,strats.el 和 strats.propsData 這兩個策略函式是隻有在非生產環境才有的,在生產環境下訪問這兩個函式將會得到 undefined,那這個時候 mergeField 函式的第一句程式碼就起作用了:

// 當一個選項沒有對應的策略函式時,使用預設策略
const strat = strats[key] || defaultStrat

所以在生產環境將直接使用預設的策略函式 defaultStrat 來處理 el 和 propsData 這兩個選項。

#選項 data 的合併策略

下面我們接著按照順序看 options.js 檔案的程式碼,接下來定義了兩個函式:mergeData 以及 mergeDataOrFn,我們暫且不關注這兩個函式的作用。暫且跳過繼續看下面的程式碼,接下來的程式碼如下:

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

這段程式碼的作用是在 strats 策略物件上新增 data 策略函式,用來合併處理 data 選項。我們看看這個策略函式的內容,首先是一個判斷分支:

if (!vm) {
  ...
}

與 el 和 propsData 這兩個策略函式相同,先判斷是否傳遞了 vm 這個引數,我們知道當沒有 vm 引數時,說明處理的是子元件的選項,那我們就看看對於子元件的選項它是如何處理的,if 判斷語句塊內的程式碼如下:

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)

首先判斷是否傳遞了子元件的 data 選項(即:childVal),並且檢測 childVal 的型別是不是 function,如果 childVal 的型別不是 function 則會給你一個警告,也就是說 childVal 應該是一個函式,如果不是函式會提示你 data 的型別必須是一個函式,這就是我們知道的:子元件中的 data必須是一個返回物件的函式。如果不是函式,除了給你一段警告之外,會直接返回 parentVal

如果 childVal 是函式型別,那說明滿足了子元件的 data 選項需要是一個函式的要求,那麼就直接返回 mergeDataOrFn 函式的執行結果:

return mergeDataOrFn(parentVal, childVal)

上面的情況是在 strats.data 策略函式拿不到 vm 引數時的情況,如果拿到了 vm 引數,那麼說明處理的選項不是子元件的選項,而是正常使用 new 操作符建立例項時的選項,這個時候則直接返回 mergeDataOrFn 的函式執行結果,但是會多透傳一個引數 vm

return mergeDataOrFn(parentVal, childVal, vm)

通過上面的分析我們得知一件事,即 strats.data 策略函式無論合併處理的是子元件的選項還是非子元件的選項,其最終都是呼叫 mergeDataOrFn 函式進行處理的,並且以 mergeDataOrFn 函式的返回值作為策略函式的最終返回值。有一點不同的是在處理非子元件選項的時候所呼叫的 mergeDataOrFn 函式多傳遞了一個引數 vm。所以接下來我們要做的事兒就是看看 mergeDataOrFn 的程式碼,看一看它的返回值是什麼,因為它的返回值就等價於 strats.data 策略函式的返回值。mergeDataOrFn 函式的原始碼如下:

/**
 * Data
 */
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
      }
    }
  }
}

這個函式整體由 if 判斷分支語句塊組成,首先對 vm 進行判斷,我們知道無論是子元件選項還是非子元件選項 strats.data 策略函式都是通過呼叫 mergeDataOrFn 函式來完成處理的,且處理非子元件選項的時候要比處理子元件選項時多傳遞了一個引數 vm,這就使得 mergeDataOrFn 也能通過是否有 vm來區分處理的是不是子元件選項。如果沒有拿到 vm 引數的話,那說明處理的是子元件選項,程式會走 if 分支,實際上我們可以看到這裡有段註釋:

// in a Vue.extend merge, both should be functions

這段註釋的意思是:選項是在呼叫 Vue.extend 函式時進行合併處理的,此時父子 data 選項都應該是函式。

這再次說明了,當拿不到 vm 這個引數的時候,合併操作是在 Vue.extend 中進行的,也就是在處理子元件的選項。而且此時 childVal 和 parentVal 都應該是函式,那麼這裡真的能保證 childVal 和 parentVal 都是函數了嗎?其實是可以的,我們後面會講到。

在這句註釋的下面是這段程式碼:

if (!childVal) {
  return parentVal
}
if (!parentVal) {
  return childVal
}

我們看第一個 if 語句塊,如果沒有 childVal,也就是說子元件的選項中沒有 data 選項,那麼直接返回 parentVal,比如下面的程式碼:

Vue.extend({})

我們使用 Vue.extend 函式建立子類的時候傳遞的子元件選項是一個空物件,即沒有 data 選項,那麼此時 parentVal 實際上就是 Vue.options,由於 Vue.options 上也沒有 data 這個屬性,所以壓根就不會執行 strats.data 策略函式,也就更不會執行 mergeDataOrFn 函式,有的同學可能會問:既然都沒有執行,那麼這裡的 return parentVal 是不是多餘的?當然不多餘,因為 parentVal 存在有值的情況。那麼什麼時候才會出現 childVal 不存在但是 parentVal 存在的情況呢?看下面的程式碼:

const Parent = Vue.extend({
  data: function () {
    return {
      test: 1
    }
  }
})

const Child = Parent.extend({})

上面的程式碼中 Parent 類繼承了 Vue,而 Child 又繼承了 Parent,關鍵就在於我們使用 Parent.extend 建立 Child 子類的時候,對於 Child 類來講,childVal 不存在,因為我們沒有傳遞 data 選項,但是 parentVal 存在,即 Parent.options 下的 data 選項,那麼 Parent.options 是哪裡來的呢?實際就是 Vue.extend 函式內使用 mergeOptions 生成的,所以此時 parentVal 必定是個函式,因為 strats.data 策略函式在處理 data 選項後返回的始終是一個函式。

所以現在再看這段程式碼就清晰多了:

if (!childVal) {
  return parentVal
}
if (!parentVal) {
  return childVal
}

由於 childVal 和 parentVal 必定會有其一,否則便不會執行 strats.data 策略函式,所以上面判斷的意思就是:如果沒有子選項則使用父選項,沒有父選項就直接使用子選項,且這兩個選項都能保證是函式,如果父子選項同時存在,則程式碼繼續進行,將執行下面的程式碼:

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

也就是說,當父子選項同時存在,那麼就返回一個函式 mergedDataFn,注意:此時程式碼執行就結束了,因為函式已經返回了(return),至於 mergedDataFn 函式裡面又返回了 mergeData 函式的執行結果這句程式碼目前還沒有執行。

以上就是 strats.data 策略函式在處理子元件的 data 選項時所做的事,我們可以發現 mergeDataOrFn 函式在處理子元件選項時返回的總是一個函式,這也就間接導致 strats.data 策略函式在處理子元件選項時返回的也總是一個函式。

說完了處理子選項的情況,我們再看看處理非子選項的情況,也就是使用 new 操作符建立例項時的情況,此時程式直接執行 strats.data 函式的最後一句程式碼:

return mergeDataOrFn(parentVal, childVal, vm)

我們發現同樣是呼叫 mergeDataOrFn 函式,只不過這個時候傳遞了 vm 引數,也就是說這將會執行 mergeDataOrFn 的 else 分支:

if (!vm) {
  ...
} 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
    }
  }
}

如果走了 else 分支的話那麼就直接返回 mergedInstanceDataFn 函式,注意此時的 mergedInstanceDataFn 函式同樣還沒有執行,它是 mergeDataOrFn 函式的返回值,所以這再次說明了一個問題:mergeDataOrFn 函式永遠返回一個函式

也就是說,假如以我們的例子為例:

let v = new Vue({
  el: '#app',
  data: {
    test: 1
  }
})

我們的 data 選項在經過 mergeOptions 處理之後將變成一個函式,且根據我們的分析,它應該就是 mergedInstanceDataFn 函式,我們可以在控制檯列印如下資訊:

console.log(v.$options)

輸出如下圖:

我們可以發現 data 選項確實被 mergeOptions 處理成了一個函式,且當 data 選項為非子元件的選項時,該函式就是 mergedInstanceDataFn

一個簡單的總結,現在我們瞭解到了一個事實,即 data 選項最終被 mergeOptions 函式處理成了一個函式,當合並處理的是子元件的選項時 data 函式可能是以下三者之一:

  • 1、就是 data 本身,因為子元件的 data 選項本身就是一個函式,即如下 mergeDataOrFn 函式的程式碼段所示:
export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    ...
    // 返回子元件的 data 選項本身
    if (!parentVal) {
      return childVal
    }
    ...
  } else {
    ...
  }
}
  • 2、父類的 data 選項,如下程式碼段所示::
export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    ...
    // 返回父類的 data 選項
    if (!childVal) {
      return parentVal
    }
    ...
  } else {
    ...
  }
}
  • 3、mergedDataFn 函式,如下程式碼段所示:
export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    ...
    // 返回 mergedDataFn 函式
    return function mergedDataFn () {
      return mergeData(
        typeof childVal === 'function' ? childVal.call(this, this) : childVal,
        typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
      )
    }
  } else {
    ...
  }
}

當合並處理的是非子元件的選項時 data 函式為 mergedInstanceDataFn 函式,如下程式碼段所示:

export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    ...
  } else {
    // 當合並處理的是非子元件的選項時 `data` 函式為 `mergedInstanceDataFn` 函式
    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
      }
    }
  }
}

所以這就是我們一直強調的:data 選項最終被處理為一個函式。但是根據我們之前的分析可知,函式分幾種情況,但它們都有一個共同的特點,即:這些函式的執行結果就是最終的資料

我們可以發現 mergedDataFn 和 mergedInstanceDataFn 這兩個函式有一個共同的特點,內部都呼叫了 mergeData 處理資料並返回,我們先看一下 mergedDataFn 函式,其原始碼如下:

return function mergedDataFn () {
  return mergeData(
    typeof childVal === 'function' ? childVal.call(this, this) : childVal,
    typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
  )
}

這個函式直接返回了 mergeData 函式的執行結果,再看看 mergedInstanceDataFn 函式,其原始碼如下:

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

我們注意到 mergedDataFn 和 mergedInstanceDataFn 這兩個函式都有類似這樣的程式碼:

typeof childVal === 'function' ? childVal.call(this, this) : childVal
typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal

我們知道 childVal 要麼是子元件的選項,要麼是使用 new 操作符建立例項時的選項,無論是哪一種,總之 childVal 要麼是函式,要麼就是一個純物件。所以如果是函式的話就通過執行該函式從而獲取到一個純物件,所以類似上面那段程式碼中判斷 childVal 和 parentVal 的型別是否是函式的目的只有一個,獲取資料物件(純物件)。所以 mergedDataFn 和 mergedInstanceDataFn 函式內部呼叫 mergeData方法時傳遞的兩個引數就是兩個純物件(當然你可以簡單的理解為兩個JSON物件)。

所以說既然知道了 mergeData 函式接收的兩個引數就是兩個純物件,那麼再看 mergeData 函式的程式碼就輕鬆多了,它才是終極合併策略,其原始碼如下:

/**
 * Helper that recursively merges two data objects together.
 */
function mergeData (to: Object, from: ?Object): Object {
  // 沒有 from 直接返回 to
  if (!from) return to
  let key, toVal, fromVal
  const keys = Object.keys(from)
  // 遍歷 from 的 key
  for (let i = 0; i < keys.length; i++) {
    key = keys[i]
    toVal = to[key]
    fromVal = from[key]
    // 如果 from 物件中的 key 不在 to 物件中,則使用 set 函式為 to 物件設定 key 及相應的值
    if (!hasOwn(to, key)) {
      set(to, key, fromVal)
    // 如果 from 物件中的 key 也在 to 物件中,且這兩個屬性的值都是純物件則遞迴進行深度合併
    } else if (isPlainObject(toVal) && isPlainObject(fromVal)) {
      mergeData(toVal, fromVal)
    }
    // 其他情況什麼都不做
  }
  return to
}

mergeData 函式接收兩個引數 to 和 from,根據 mergeData 函式被呼叫時引數的傳遞順序我們知道,to 對應的是 childVal 產生的純物件,from 對應 parentVal 產生的純物件,我們看 mergeData 第一句程式碼:

if (!from) return to

如果沒有 from 則直接返回 to,也就是說如果沒有 parentVal 產生的值,就直接使用 childVal 產生的值。

如果有 parentVal 產生的值,則程式碼繼續向下執行,我們看 mergeData 最後的返回值:

return to

其返回的仍是 to 物件,所以你應該能猜的到 mergeData 函式的作用,可以簡單理解為:將 from 物件的屬性混合到 to 物件中,也可以說是將 parentVal 物件的屬性混合到 childVal 中,最後返回的是處理後的 childVal 物件。

mergeData 的具體做法就是像上面 mergeData 函式的程式碼段中所註釋的那樣,對 from 物件的 key進行遍歷:

  • 如果 from 物件中的 key 不在 to 物件中,則使用 set 函式為 to 物件設定 key 及相應的值。

  • 如果 from 物件中的 key 在 to 物件中,且這兩個屬性的值都是純物件則遞迴地呼叫 mergeData函式進行深度合併。

  • 其他情況不做處理。

上面提到了一個 set 函式,根據 options.js 檔案頭部的引用關係可知:這個函式來自於 core/observer/index.js 檔案,實際上這個 set 函式就是 Vue 暴露給我們的全域性API Vue.set。在這裡由於我們還沒有講到 set 函式的具體實現,所以你就可以簡單理解為 set 函式的功能與我們前面遇到過的 extend 工具函式功能相似即可。

所以我們知道了 mergeData 函式的執行結果才是真正的資料物件,由於 mergedDataFn 和 mergedInstanceDataFn 這兩個函式的返回值就是 mergeData 函式的執行結果,所以 mergedDataFn 和 mergedInstanceDataFn 函式的執行將會得到資料物件,我們還知道 data 選項會被 mergeOptions 處理成函式,比如處理成 mergedInstanceDataFn,所以:最終得到的 data 選項是一個函式,且該函式的執行結果就是最終的資料物件

最後我們對大家經常會產生疑問的地方做一些補充:

#一、為什麼最終 strats.data 會被處理成一個函式?

這是因為,通過函式返回資料物件,保證了每個元件例項都有一個唯一的資料副本,避免了元件間資料互相影響。後面講到 Vue 的初始化的時候大家會看到,在初始化資料狀態的時候,就是通過執行 strats.data 函式來獲取資料並對其進行處理的。

#二、為什麼不在合併階段就把資料合併好,而是要等到初始化的時候再合併資料?

這個問題是什麼意思呢?我們知道在合併階段 strats.data 將被處理成一個函式,但是這個函式並沒有被執行,而是到了後面初始化的階段才執行的,這個時候才會呼叫 mergeData 對資料進行合併處理,那這麼做的目的是什麼呢?

其實這麼做是有原因的,後面講到 Vue 的初始化的時候,大家就會發現 inject 和 props 這兩個選項的初始化是先於 data 選項的,這就保證了我們能夠使用 props 初始化 data 中的資料,如下:

// 子元件:使用 props 初始化子元件的 childData 
const Child = {
  template: '<span></span>',
  data () {
    return {
      childData: this.parentData
    }
  },
  props: ['parentData'],
  created () {
    // 這裡將輸出 parent
    console.log(this.childData)
  }
}

var vm = new Vue({
    el: '#app',
    // 通過 props 向子元件傳遞資料
    template: '<child parent-data="parent" />',
    components: {
      Child
    }
})

如上例所示,子元件的資料 childData 的初始值就是 parentData 這個 props。而之所以能夠這樣做的原因有兩個

  • 1、由於 props 的初始化先於 data 選項的初始化
  • 2、data 選項是在初始化的時候才求值的,你也可以理解為在初始化的時候才使用 mergeData 進行資料合併。

#三、你可以這麼做。

在上面的例子中,子元件的 data 選項我們是這麼寫的:

data () {
  return {
    childData: this.parentData
  }
}

但你知道嗎,你也可以這麼寫:

data (vm) {
  return {
    childData: vm.parentData
  }
}
// 或者使用更簡單的解構賦值
data ({ parentData }) {
  return {
    childData: parentData
  }
}

我們可以通過解構賦值的方式,也就是說 data 函式的引數就是當前例項物件。那麼這個引數是在哪裡傳遞進來的呢?其實有兩個地方,其中一個地方我們前面見過了,如下面這段程式碼:

return function mergedDataFn () {
  return mergeData(
    typeof childVal === 'function' ? childVal.call(this, this) : childVal,
    typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
  )
}

注意這裡的 childVal.call(this, this) 和 parentVal.call(this, this),關鍵在於 call(this, this),可以看到,第一個 this 指定了 data 函式的作用域,而第二個 this 就是傳遞給 data 函式的引數。

當然了僅僅在這裡這麼做是不夠的,比如 mergedDataFn 前面的程式碼:

if (!childVal) {
  return parentVal
}
if (!parentVal) {
  return childVal
}

在這段程式碼中,直接將 parentVal 或 childVal 返回了,我們知道這裡的 parentVal 和 childVal 就是 data 函式,由於被直接返回,所以並沒有指定其執行的作用域,且也沒有傳遞當前例項作為引數,所以我們必然還是在其他地方做這些事情,而這個地方就是我們說的第二個地方,它在哪裡呢?當然是初始化的時候,後面我們會講到的,如果這裡大家沒有理解也不用擔心。

#生命週期鉤子選項的合併策略

現在我們看完了 strats.data 策略函式,我們繼續按照 options.js 檔案的順序看程式碼,接下來的一段程式碼如下:

/**
 * Hooks and props are merged as arrays.
 */
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
})

看上去,這段程式碼貌似是用來合併生命週期鉤子的,事實上的確是這樣,我們看看它是怎麼做的,首先上面的程式碼由兩部分組成:mergeHook 函式和一個 forEach 語句。我們先看下面的 forEach 語句:

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

使用 forEach 遍歷 LIFECYCLE_HOOKS 常量,那說明這個常量應該是一個數組,我們根據 options.js檔案頭部的引用關係可知 LIFECYCLE_HOOKS 常量來自於 shared/constants.js 檔案,我們開啟這個檔案找到 LIFECYCLE_HOOKS 常量如下:

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

可以發現 LIFECYCLE_HOOKS 常量實際上是由與生命週期鉤子同名的字串組成的陣列。

所以現在再回頭來看那段 forEach 語句可知,它的作用就是在 strats 策略物件上新增用來合併各個生命週期鉤子選項的策略函式,並且這些生命週期鉤子選項的策略函式相同:都是 mergeHook 函式

那麼 mergeHook 函式是怎樣合併生命週期選項的呢?我們看看 mergeHook 函式的程式碼,如下:

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

整個函式體由三組三目運算子組成,有一點值得大家學習的就是這裡寫三目運算子的方式,是不是感覺非常地清晰易讀?那麼這段程式碼的分析我們同樣使用與上面程式碼相同的格式來寫:

return (是否有 childVal,即判斷元件的選項中是否有對應名字的生命週期鉤子函式)
  ? 如果有 childVal 則判斷是否有 parentVal
    ? 如果有 parentVal 則使用 concat 方法將二者合併為一個數組
    : 如果沒有 parentVal 則判斷 childVal 是不是一個數組
      ? 如果 childVal 是一個數組則直接返回
      : 否則將其作為陣列的元素,然後返回陣列
  : 如果沒有 childVal 則直接返回 parentVal

如上就是對 mergeHook 函式的解讀,我們可以發現,在經過 mergeHook 函式處理之後,元件選項的生命週期鉤子函式被合併成一個數組。第一個三目運算子需要注意,它判斷是否有 childVal,即元件的選項是否寫了生命週期鉤子函式,如果沒有則直接返回了 parentVal,這裡有個問題:parentVal 一定是陣列嗎?答案是:如果有 parentVal 那麼其一定是陣列,如果沒有 parentVal 那麼 strats[hooks] 函式根本不會執行。我們以 created 生命週期鉤子函式為例:

如下程式碼:

new Vue({
  created: function () {
    console.log('created')
  }
})

如果以這段程式碼為例,那麼對於 strats.created 策略函式來講(注意這裡的 strats.created 就是 mergeHooks),childVal 就是我們例子中的 created 選項,它是一個函式。parentVal 應該是 Vue.options.created,但 Vue.options.created 是不存在的,所以最終經過 strats.created 函式的處理將返回一個數組:

options.created = [
  function () {
    console.log('created')
  }  
]

再看下面的例子:

const Parent = Vue.extend({
  created: function () {
    console.log('parentVal')
  }
})

const Child = new Parent({
  created: function () {
    console.log('childVal')
  }
})

其中 Child 是使用 new Parent 生成的,所以對於 Child 來講,childVal 是:

created: function () {
  console.log('childVal')
}

而 parentVal 已經不是 Vue.options.created 了,而是 Parent.options.created,那麼 Parent.options.created 是什麼呢?它其實是通過 Vue.extend 函式內部的 mergeOptions 處理過的,所以它應該是這樣的:

Parent.options.created = [
  created: function () {
    console.log('parentVal')
  }
]

所以這個例子最終的結果就是既有 childVal,又有 parentVal,那麼根據 mergeHooks 函式的邏輯:

function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  return childVal
    ? parentVal
      // 這裡,合併且生成一個新陣列
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal
}

關鍵在這句:parentVal.concat(childVal),將 parentVal 和 childVal 合併成一個數組。所以最終結果如下:

[
  created: function () {
    console.log('parentVal')
  },
  created: function () {
    console.log('childVal')
  }
]

另外我們注意第三個三目運算子:

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

它判斷了 childVal 是不是陣列,這說明什麼?說明了生命週期鉤子是可以寫成陣列的,雖然 Vue 的文件裡沒有,不信你可以試試:

new Vue({
  created: [
    function () {
      console.log('first')
    },
    function () {
      console.log('second')
    },
    function () {
      console.log('third')
    }
  ]
})

鉤子函式將按順序執行。

#資源(assets)選項的合併策略

在 Vue 中 directivesfilters 以及 components 被認為是資源,其實很好理解,指令、過濾器和元件都是可以作為第三方應用來提供的,比如你需要一個模擬滾動的元件,你當然可以選用超級強大的第三方元件 scroll-flip-page,所以這樣看來 scroll-flip-page 就可以認為是資源,除了元件之外指令和過濾器也都是同樣的道理。

而我們接下來要看的程式碼就是用來合併處理 directivesfilters 以及 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.
 */
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
})

與生命週期鉤子的合併處理策略基本一致,以上程式碼段也分為兩部分:mergeAssets 函式以及一個 forEach 語句。我們同樣先看 forEach 語句,這個 forEach 迴圈用來遍歷 ASSET_TYPES 常量,根據 options.js 檔案頭部的引用關係可知 ASSET_TYPES 常量來自於 shared/constants.js 檔案,我們開啟 shared/constants.js 檔案找到 ASSET_TYPES 常量如下:

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

我們發現 ASSET_TYPES 其實是由與資源選項“同名”的三個字串組成的陣列,注意所謂的“同名”是帶引號的,因為陣列中的字串與真正的資源選項名字相比要少一個字元 s

ASSET_TYPES 資源選項名字
component components
directive directives
filter filters

所以我們再看一下那段 forEach 語句:

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

我們發現在迴圈內部它有手動拼接上一個 's',所以最終的結果就是在 strats 策略物件上新增與資源選項名字相同的策略函式,用來分別合併處理三類資源。所以接下來我們就看看它是怎麼處理的,mergeAssets 程式碼如下:

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

上面的程式碼本身邏輯很簡單,首先以 parentVal 為原型建立物件 res,然後判斷是否有 childVal,如果有的話使用 extend 函式將 childVal 上的屬性混合到 res 物件上並返回。如果沒有 childVal則直接返回 res

舉個例子,大家知道任何元件的模板中我們都可以直接使用 <transition/> 元件或者 <keep-alive/>等,但是我們並沒有在我們自己的元件例項的 components 選項中顯式地宣告這些元件。那麼這是怎麼做到的呢?其實答案就在 mergeAssets 函式中。以下面的程式碼為例:

var v = new Vue({
  el: '#app',
  components: {
    ChildComponent: ChildComponent
  }
})

上面的程式碼中,我們建立了一個 Vue 例項,並註冊了一個子元件 ChildComponent,此時 mergeAssets方法內的 childVal 就是例子中的 components 選項:

components: {
  ChildComponent: ChildComponent
}

而 parentVal 就是 Vue.options.components,我們知道 Vue.options 如下:

Vue.options = {
	components: {
	  KeepAlive,
	  Transition,
	  TransitionGroup
	},
	directives: Object.create(null),
	directives:{
	  model,
	  show
	},
	filters: Object.create(null),
	_base: Vue
}

所以 Vue.options.components 就應該是一個物件:

{
  KeepAlive,
  Transition,
  TransitionGroup
}

也就是說 parentVal 就是如上包含三個內建元件的物件,所以經過如下這句話之後:

const res = Object.create(parentVal || null)

你可以通過 res.KeepAlive 訪問到 KeepAlive 物件,因為雖然 res 物件自身屬性沒有 KeepAlive,但是它的原型上有。

然後再經過 return extend(res, childVal) 這句話之後,res 變數將被新增 ChildComponent 屬性,最終 res 如下:

res = {
  ChildComponent
  // 原型
  __proto__: {
    KeepAlive,
    Transition,
    TransitionGroup
  }
}

所以這就是為什麼我們不用顯式地註冊元件就能夠使用一些內建元件的原因,同時這也是內建元件的實現方式,通過 Vue.extend 創建出來的子類也是一樣的道理,一層一層地通過原型進行元件的搜尋。

最後說一下 mergeAssets 函式中的這句話:

process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)

在非生產環境下,會呼叫 assertObjectType 函式,這個函式其實是用來檢測 childVal 是不是一個純物件的,如果不是純物件會給你一個警告,其原始碼很簡單,如下:

function assertObjectType (name: string, value: any, vm: ?Component) {
  if (!isPlainObject(value)) {
    warn(
      `Invalid value for option "${name}": expected an Object, ` +
      `but got ${toRawType(value)}.`,
      vm
    )
  }
}

就是使用 isPlainObject 進行判斷。上面我們都在以 components 進行講解,對於指令(directives)和過濾器(filters)也是一樣的,因為他們都是用 mergeAssets 進行合併處理。

#選項 watch 的合併策略

接下來我們要看的程式碼就是這一段了:

/**
 * Watchers.
 *
 * Watchers hashes should not overwrite one
 * another, so we merge them as arrays.
 */
strats.watch = function (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  // work around Firefox's Object.prototype.watch...
  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
  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
}

這一段程式碼的作用是在 strats 策略物件上新增 watch 策略函式。所以 strats.watch 策略函式應該是合併處理 watch 選項的。我們先看函式體開頭的兩句程式碼:

// work around Firefox's Object.prototype.watch...
if (parentVal === nativeWatch) parentVal = undefined
if (childVal === nativeWatch) childVal = undefined

其中 nativeWatch 來自於 core/util/env.js 檔案,大家可以在 core/util 目錄下的工具方法全解 中檢視其作用。在 Firefox 瀏覽器中 Object.prototype 擁有原生的 watch 函式,所以即便一個普通的物件你沒有定義 watch 屬性,但是依然可以通過原型鏈訪問到原生的 watch 屬性,這就會給 Vue 在處理選項的時候造成迷惑,因為 Vue 也提供了一個叫做 watch 的選項,即使你的元件選項中沒有寫 watch 選項,但是 Vue 通過原型訪問到了原生的 watch。這不是我們想要的,所以上面兩句程式碼的目的是一個變通方案,當發現元件選項是瀏覽器原生的 watch 時,那說明使用者並沒有提供 Vue 的 watch 選項,直接重置為 undefined

然後是這句程式碼:

if (!childVal) return Object.create(parentVal || null)

檢測了是否有 childVal,即元件選項是否有 watch 選項,如果沒有的話,直接以 parentVal 為原型建立物件並返回(如果有 parentVal 的話)。

如果元件選項中有 watch 選項,即 childVal 存在,則程式碼繼續執行,接下來將執行這段程式碼:

if (process.env.NODE_ENV !== 'production') {
  assertObjectType(key, childVal, vm)
}
if (!parentVal) return childVal

由於此時 childVal 存在,所以在非生產環境下使用 assertObjectType 函式對 childVal 進行型別檢測,檢測其是否是一個純物件,我們知道 Vue 的 watch 選項需要是一個純物件。接著判斷是否有 parentVal,如果沒有的話則直接返回 childVal,即直接使用元件選項的 watch

如果存在 parentVal,那麼程式碼繼續執行,此時 parentVal 以及 childVal 都將存在,那麼就需要做合併處理了,也就是下面要執行的程式碼:

// 定義 ret 常量,其值為一個物件
const ret = {}
// 將 parentVal 的屬性混合到 ret 中,後面處理的都將是 ret 物件,最後返回的也是 ret 物件
extend(ret, parentVal)
// 遍歷 childVal
for (const key in childVal) {
  // 由於遍歷的是 childVal,所以 key 是子選項的 key,父選項中未必能獲取到值,所以 parent 未必有值
  let parent = ret[key]
  // child 是肯定有值的,因為遍歷的就是 childVal 本身
  const child = childVal[key]
  // 這個 if 分支的作用就是如果 parent 存在,就將其轉為陣列
  if (parent && !Array.isArray(parent)) {
    parent = [parent]
  }
  ret[key] = parent
    // 最後,如果 parent 存在,此時的 parent 應該已經被轉為陣列了,所以直接將 child concat 進去
    ? parent.concat(child)
    // 如果 parent 不存在,直接將 child 轉為陣列返回
    : Array.isArray(child) ? child : [child]
}
// 最後返回新的 ret 物件
return ret

上面的程式碼