1. 程式人生 > >vue原始碼(七)Vue 的初始化之開篇

vue原始碼(七)Vue 的初始化之開篇

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

用於初始化的最終選項 $options

在 以一個例子為線索 一節中,我們寫了一個很簡單的例子,這個例子如下:

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

我們以這個例子為線索開始了對 Vue 程式碼的講解,我們知道了在例項化 Vue

 例項的時候,Vue.prototype._init 方法被第一個執行,這個方法定義在 src/core/instance/init.js 檔案中,在分析 _init 方法的時候我們遇到了下面的程式碼:

vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
)

正是因為上面的程式碼,使得我們花了大篇章來講解其內部實現和運作,也就是 Vue選項的規範化 和 

Vue選項的合併 這兩節所介紹的內容。現在我們已經知道了 mergeOptions 函式是如何對父子選項進行合併處理的,也知道了它的作用。

我們開啟 core/util/options.js 檔案,找到 mergeOptions 函式,看其最後一句程式碼:

return options

這說明 mergeOptions 函式最終將合併處理後的選項返回,並以該返回值作為 vm.$options 的值。vm.$options 在 Vue 的官方文件中是可以找到的,它作為例項屬性暴露給開發者,那麼現在你應該知道 vm.$options

 到底是什麼了。並且看文件的時候你應該更能夠理解其作用,比如官方文件是這樣介紹 $options 例項屬性的:

用於當前 Vue 例項的初始化選項。需要在選項中包含自定義屬性時會有用處

並且給了一個例子,如下:

new Vue({
  customOption: 'foo',
  created: function () {
    console.log(this.$options.customOption) // => 'foo'
  }
})

上面的例子中,在建立 Vue 例項的時候傳遞了一個自定義選項:customOption,在之後的程式碼中我們可以通過 this.$options.customOption 進行訪問。那原理其實就是使用 mergeOptions 函式對自定義選項進行合併處理,由於沒有指定 customOption 選項的合併策略,所以將會使用預設的策略函式 defaultStrat。最終效果就是你初始化的值是什麼,得到的就是什麼。

另外,Vue 也提供了 Vue.config.optionMergeStrategies 全域性配置,大家也可以在官方文件中找到,我們知道這個物件其實就是選項合併中的策略物件,所以我們可以通過他指定某一個選項的合併策略,常用於指定自定義選項的合併策略,比如我們給 customOption 選項指定一個合併策略,只需要在 Vue.config.optionMergeStrategies 上新增與選項同名的策略函式即可:

Vue.config.optionMergeStrategies.customOption = function (parentVal, childVal) {
    return parentVal ? (parentVal + childVal) : childVal
}

如上程式碼中,我們添加了自定義選項 customOption 的合併策略,其策略為:如果沒有 parentVal 則直接返回 childVal,否則返回兩者的和。

所以如下程式碼:

// 建立子類
const Sub = Vue.extend({
    customOption: 1
})
// 以子類建立例項
const v = new Sub({
    customOption: 2,
    created () {
        console.log(this.$options.customOption) // 3
    }
})

最終,在例項的 created 方法中將列印數字 3。上面的例子很簡單,沒有什麼實際作用,但這為我們提供了自定義選項的機會,這其實是非常有用的。

現在我們需要回到正題上了,還是拿我們的例子,如下:

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

這個時候 mergeOptions 函式將會把 Vue.options 作為 父選項,把我們傳遞的例項選項作為子選項進行合併,合併的結果我們可以通過列印 $options 屬性得知。其實我們前面已經分析過了,el 選項將使用預設合併策略合併,最終的值就是字串 '#app',而 data 選項將變成一個函式,且這個函式的執行結果就是合併後的資料,即: {test: 1}

下面是 vm.$options 的截圖:

我們發現 el 確實還是原來的值,而 data 也確實變成了一個函式,並且這個函式就是我們之前遇到過的 mergedInstanceDataFn,除此之外我們還能看到其他合併後的選項,其中 componentsdirectivesfilters 以及 _base 是存在於 Vue.options 中的,這些是我們所知道的,至於 render 和 staticRenderFns 這兩個選項是在將模板編譯成渲染函式時新增上去的,我們後面會遇到。另外 _parentElm 和 _refElm 這兩個選項是在為虛擬DOM建立元件例項時新增的,我們後面也會講到,這裡大家不需要關心,免得失去重點。

#渲染函式的作用域代理

ok,現在我們已經足夠了解 vm.$options 這個屬性了,它才是用來做一系列初始化工作的最終選項,那麼接下來我們就繼續看 _init 方法中的程式碼,繼續瞭解 Vue 的初始化工作。

_init 方法中,在經過 mergeOptions 合併處理選項之後,要執行的是下面這段程式碼:

/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
    initProxy(vm)
} else {
    vm._renderProxy = vm
}

這段程式碼是一個判斷分支,如果是非生產環境的話則執行 initProxy(vm) 函式,如果在生產環境則直接在例項上新增 _renderProxy 例項屬性,該屬性的值就是當前例項。

現在有一個問題需要大家思考一下,目前我們還沒有看 initProxy 函式的具體內容,那麼你能猜到 initProxy 函式的主要作用是什麼嗎?我可以直接告訴大家,這個函式的主要作用其實就是在例項物件 vm 上新增 _renderProxy 屬性。為什麼呢?因為生產環境和非生產環境下要保持功能一致。在上面的程式碼中生產環境下直接執行這句:

vm._renderProxy = vm

那麼可想而知,在非生產環境下也應該執行這句程式碼,但實際上卻呼叫了 initProxy 函式,所以 initProxy 函式的作用之一必然也是在例項物件 vm 上新增 _renderProxy 屬性,那麼接下來我們就看看 initProxy 的內容,驗證一下我們的判斷,開啟 core/instance/proxy.js 檔案:

/* not type checking this file because flow doesn't play well with Proxy */

import config from 'core/config'
import { warn, makeMap } from '../util/index'

// 宣告 initProxy 變數
let initProxy

if (process.env.NODE_ENV !== 'production') {
  // ... 其他程式碼
  
  // 在這裡初始化 initProxy
  initProxy = function initProxy (vm) {
    if (hasProxy) {
      // determine which proxy handler to use
      const options = vm.$options
      const handlers = options.render && options.render._withStripped
        ? getHandler
        : hasHandler
      vm._renderProxy = new Proxy(vm, handlers)
    } else {
      vm._renderProxy = vm
    }
  }
}

// 匯出
export { initProxy }

上面的程式碼是簡化後的,可以發現在檔案的開頭聲明瞭 initProxy 變數,但並未初始化,所以目前 initProxy 還是 undefined,隨後,在檔案的結尾將 initProxy 匯出,那麼 initProxy 到底是什麼呢?實際上變數 initProxy 的賦值是在 if 語句塊內進行的,這個 if 語句塊進行環境判斷,如果是非生產環境的話,那麼才會對 initProxy 變數賦值,也就是說在生產環境下我們匯出的 initProxy 實際上就是 undefined。只有在非生產環境下匯出的 initProxy 才會有值,其值就是這個函式:

initProxy = function initProxy (vm) {
    if (hasProxy) {
        // determine which proxy handler to use
        const options = vm.$options
        const handlers = options.render && options.render._withStripped
        ? getHandler
        : hasHandler
        vm._renderProxy = new Proxy(vm, handlers)
    } else {
        vm._renderProxy = vm
    }
}

這個函式接收一個引數,實際就是 Vue 例項物件,我們先從巨集觀角度來看一下這個函式的作用是什麼,可以發現,這個函式由 if...else 語句塊組成,但無論走 if 還是 else,其最終的效果都是在 vm物件上添加了 _renderProxy 屬性,這就驗證了我們之前的猜想。如果 hasProxy 為真則走 if 分支,對於 hasProxy 顧名思義,這是用來判斷宿主環境是否支援 js 原生的 Proxy 特性的,如果發現 Proxy 存在,則執行:

vm._renderProxy = new Proxy(vm, handlers)

如果不存在,那麼和生產環境一樣,直接賦值就可以了:

vm._renderProxy = vm

所以我們發現 initProxy 的作用實際上就是對例項物件 vm 的代理,通過原生的 Proxy 實現。

另外 hasProxy 變數的定義也在當前檔案中,程式碼如下:

const hasProxy =
    typeof Proxy !== 'undefined' &&
    Proxy.toString().match(/native code/)

上面程式碼的作用是判斷當前宿主環境是否支援原生 Proxy,相信大家都能看得懂,所以就不做過多解釋,接下來我們就看看它是如何做代理的,並且有什麼作用。

檢視 initProxy 函式的 if 語句塊,內容如下:

initProxy = function initProxy (vm) {
    if (hasProxy) {
        // determine which proxy handler to use
        // options 就是 vm.$options 的引用
        const options = vm.$options
        // handlers 可能是 getHandler 也可能是 hasHandler
        const handlers = options.render && options.render._withStripped
            ? getHandler
            : hasHandler
        // 代理 vm 物件
        vm._renderProxy = new Proxy(vm, handlers)
    } else {
        // ...
    }
}

可以發現,如果 Proxy 存在,那麼將會使用 Proxy 對 vm 做一層代理,代理物件賦值給 vm._renderProxy,所以今後對 vm._renderProxy 的訪問,如果有代理那麼就會被攔截。代理物件配置引數是 handlers,可以發現 handlers 既可能是 getHandler 又可能是 hasHandler,至於到底使用哪個,是由判斷條件決定的:

options.render && options.render._withStripped

如果上面的條件為真,則使用 getHandler,否則使用 hasHandler,判斷條件要求 options.render 和 options.render._withStripped 必須都為真才行,我現在明確告訴大家 options.render._withStripped這個屬性只在測試程式碼中出現過,所以一般情況下這個條件都會為假,也就是使用 hasHandler 作為代理配置。

hasHandler 常量就定義在當前檔案,如下:

const hasHandler = {
    has (target, key) {
        // has 常量是真實經過 in 運算子得來的結果
        const has = key in target
        // 如果 key 在 allowedGlobals 之內,或者 key 是以下劃線 _ 開頭的字串,則為真
        const isAllowed = allowedGlobals(key) || (typeof key === 'string' && key.charAt(0) === '_')
        // 如果 has 和 isAllowed 都為假,使用 warnNonPresent 函式列印錯誤
        if (!has && !isAllowed) {
            warnNonPresent(target, key)
        }
        return has || !isAllowed
    }
}

這裡我假設大家都對 Proxy 的使用已經沒有任何問題了,我們知道 has 可以攔截以下操作:

  • 屬性查詢: foo in proxy
  • 繼承屬性查詢: foo in Object.create(proxy)
  • with 檢查: with(proxy) { (foo); }
  • Reflect.has()

其中關鍵點就在 has 可以攔截 with 語句塊裡對變數的訪問,後面我們會講到。

has 函式內出現了兩個函式,分別是 allowedGlobals 以及 warnNonPresent,這兩個函式也是定義在當前檔案中,首先我們看一下 allowedGlobals

const allowedGlobals = makeMap(
    'Infinity,undefined,NaN,isFinite,isNaN,' +
    'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' +
    'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' +
    'require' // for Webpack/Browserify
)

可以看到 allowedGlobals 實際上是通過 makeMap 生成的函式,所以 allowedGlobals 函式的作用是判斷給定的 key 是否出現在上面字串中定義的關鍵字中的。這些關鍵字都是在 js 中可以全域性訪問的。

warnNonPresent 函式如下:

const warnNonPresent = (target, key) => {
    warn(
        `Property or method "${key}" is not defined on the instance but ` +
        'referenced during render. Make sure that this property is reactive, ' +
        'either in the data option, or for class-based components, by ' +
        'initializing the property. ' +
        'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.',
        target
    )
}

這個函式就是通過 warn 列印一段警告資訊,警告資訊提示你“在渲染的時候引用了 key,但是在例項物件上並沒有定義 key 這個屬性或方法”。其實我們很容易就可以看到這個資訊,比如下面的程式碼:

const vm = new Vue({
    el: '#app',
    template: '<div>{{a}}</div>',
    data: {
        test: 1
    }
})

大家注意,在模板中我們使用 a,但是在 data 屬性中並沒有定義這個屬性,這個時候我們就能夠得到以上報錯資訊:

大家可能比較疑惑的是為什麼會這樣,其實我們後面講到渲染函式的時候你自然就知道了,不過現在大家可以先看一下,開啟 core/instance/render.js 檔案,找到 Vue.prototype._render 方法,裡面有這樣的程式碼:

vnode = render.call(vm._renderProxy, vm.$createElement)

可以發現,呼叫 render 函式的時候,使用 call 方法指定了函式的執行環境為 vm._renderProxy,渲染函式長成什麼樣呢?還是以上面的例子為例,我們可以通過列印 vm.$options.render 檢視,所以它長成這樣:

vm.$options.render = function () {
    // render 函式的 this 指向例項的 _renderProxy
    with(this){
        return _c('div', [_v(_s(a))])   // 在這裡訪問 a,相當於訪問 vm._renderProxy.a
    }
}

從上面的程式碼可以發現,顯然函式使用 with 語句塊指定了內部程式碼的執行環境為 this,由於 render 函式呼叫的時候使用 call 指定了其 this 指向為 vm._renderProxy,所以 with 語句塊內程式碼的執行環境就是 vm._renderProxy,所以在 with 語句塊內訪問 a 就相當於訪問 vm._renderProxy 的 a 屬性,前面我們提到過 with 語句塊內訪問變數將會被 Proxy 的 has 代理所攔截,所以自然就執行了 has 函式內的程式碼。最終通過 warnNonPresent 列印警告資訊給我們,所以這個代理的作用就是為了在開發階段給我們一個友好而準確的提示。

我們理解了 hasHandler,但是還有一個 getHandler,這個代理將會在判斷條件:

options.render && options.render._withStripped

為真的情況下被使用,那這個條件什麼時候成立呢?其實 _withStripped 只在 test/unit/features/instance/render-proxy.spec.js 檔案中出現過,該檔案有這樣一段程式碼:

it('should warn missing property in render fns without `with`', () => {
    const render = function (h) {
        // 這裡訪問了 a
        return h('div', [this.a])
    }
    // 在這裡將 render._withStripped 設定為 true
    render._withStripped = true
    new Vue({
        render
    }).$mount()
    // 應該得到警告
    expect(`Property or method "a" is not defined`).toHaveBeenWarned()
})

這個時候就會觸發 getHandler 設定的 get 攔截,getHandler 程式碼如下:

const getHandler = {
    get (target, key) {
        if (typeof key === 'string' && !(key in target)) {
            warnNonPresent(target, key)
        }
        return target[key]
    }
}

其最終實現的效果無非就是檢測到訪問的屬性不存在就給你一個警告。但我們也提到了,只有當 render函式的 _withStripped 為真的時候,才會給出警告,但是 render._withStripped 又只有寫測試的時候出現過,也就是說需要我們手動設定其為 true 才會得到提示,否則是得不到的,比如:

const render = function (h) {
    return h('div', [this.a])
}

var vm = new Vue({
    el: '#app',
    render,
    data: {
        test: 1
    }
})

上面的程式碼由於 render 函式是我們手動書寫的,所以 render 函式並不會被包裹在 with 語句塊內,當然也就觸發不了 has 攔截,但是由於 render._withStripped 也未定義,所以也不會被 get 攔截,那這個時候我們雖然訪問了不存在的 this.a,但是卻得不到警告,想要得到警告我們需要手動設定 render._withStripped 為 true

const render = function (h) {
    return h('div', [this.a])
}
render._withStripped = true

var vm = new Vue({
    el: '#app',
    render,
    data: {
        test: 1
    }
})

為什麼會這麼設計呢?因為在使用 webpack 配合 vue-loader 的環境中, vue-loader 會藉助 [email protected] 將 template 編譯為不使用 with 語句包裹的遵循嚴格模式的 JavaScript,併為編譯後的 render 方法設定 render._withStripped = true。在不使用 with 語句的 render 方法中,模板內的變數都是通過屬性訪問操作 vm['a'] 或 vm.a 的形式訪問的,從前文中我們瞭解到 Proxy 的 has 無法攔截屬性訪問操作,所以這裡需要使用 Proxy 中可以攔截到屬性訪問的 get,同時也省去了 has 中的全域性變數檢查(全域性變數的訪問不會被 get 攔截)。

現在,我們基本知道了 initProxy 的目的,就是設定渲染函式的作用域代理,其目的是為我們提供更好的提示資訊。但是我們忽略了一些細節沒有講清楚,回到下面這段程式碼:

// has 變數是真實經過 in 運算子得來的結果
const has = key in target
// 如果 key 在 allowedGlobals 之內,或者 key 是以下劃線 _ 開頭的字串,則為真
const isAllowed = allowedGlobals(key) || (typeof key === 'string' && key.charAt(0) === '_')
// 如果 has 和 isAllowed 都為假,使用 warnNonPresent 函式列印錯誤
if (!has && !isAllowed) {
    warnNonPresent(target, key)
}

上面這段程式碼中的 if 語句的判斷條件是 (!has && !isAllowed),其中 !has 我們可以理解為你訪問了一個沒有定義在例項物件上(或原型鏈上)的屬性,所以這個時候提示錯誤資訊是合理,但是即便 !has成立也不一定要提示錯誤資訊,因為必須要滿足 !isAllowed,也就是說當你訪問了一個雖然不在例項物件上(或原型鏈上)的屬性,但如果你訪問的是全域性物件那麼也是被允許的。這樣我們就可以在模板中使用全域性物件了,如:

<template>
  {{Number(b) + 2}}
</template>

其中 Number 為全域性物件,如果去掉 !isAllowed 這個判斷條件,那麼上面模板的寫法將會得到警告資訊。除了允許使用全域性物件之外,還允許以 _ 開頭的屬性,這麼做是由於渲染函式中會包含很多以 _開頭的內部方法,如之前我們檢視渲染函式時遇到的 _c_v 等等。

最後對於 proxy.js 檔案內的程式碼,還有一段是我們沒有講過的,就是下面這段:

if (hasProxy) {
    // isBuiltInModifier 函式用來檢測是否是內建的修飾符
    const isBuiltInModifier = makeMap('stop,prevent,self,ctrl,shift,alt,meta,exact')
    // 為 config.keyCodes 設定 set 代理,防止內建修飾符被覆蓋
    config.keyCodes = new Proxy(config.keyCodes, {
        set (target, key, value) {
            if (isBuiltInModifier(key)) {
                warn(`Avoid overwriting built-in modifier in config.keyCodes: .${key}`)
                return false
            } else {
                target[key] = value
                return true
            }
        }
    })
}

上面的程式碼首先檢測宿主環境是否支援 Proxy,如果支援的話才會執行裡面的程式碼,內部的程式碼首先使用 makeMap 函式生成一個 isBuiltInModifier 函式,該函式用來檢測給定的值是否是內建的事件修飾符,我們知道在 Vue 中我們可以使用事件修飾符很方便地做一些工作,比如阻止預設事件等。

然後為 config.keyCodes 設定了 set 代理,其目的是防止開發者在自定義鍵位別名的時候,覆蓋了內建的修飾符,比如:

Vue.config.keyCodes.shift = 16

由於 shift 是內建的修飾符,所以上面這句程式碼將會得到警告。

#初始化之 initLifecycle

_init 函式在執行完 initProxy 之後,執行的就是 initLifecycle 函式:

vm._self = vm
initLifecycle(vm)

在 initLifecycle 函式執行之前,執行了 vm._self = vm 語句,這句話在 Vue 例項物件 vm 上添加了 _self 屬性,指向真實的例項本身。注意 vm._self 和 vm._renderProxy 不同,首先在用途上來說寓意是不同的,另外 vm._renderProxy 有可能是一個代理物件,即 Proxy 例項。

接下來執行的才是 initLifecycle 函式,同時將當前 Vue 例項 vm 作為引數傳遞。開啟 core/instance/lifecycle.js 檔案找到 initLifecycle 函式,如下:

export function initLifecycle (vm: Component) {
  // 定義 options,它是 vm.$options 的引用,後面的程式碼使用的都是 options 常量
  const options = vm.$options

  // locate first non-abstract parent
  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }

  vm.$parent = parent
  vm.$root = parent ? parent.$root : vm

  vm.$children = []
  vm.$refs = {}

  vm._watcher = null
  vm._inactive = null
  vm._directInactive = false
  vm._isMounted = false
  vm._isDestroyed = false
  vm._isBeingDestroyed = false
}

上面程式碼是 initLifecycle 函式的全部內容,首先定義 options 常量,它是 vm.$options 的引用。接著將執行下面這段程式碼:

// locate first non-abstract parent (查詢第一個非抽象的父元件)
// 定義 parent,它引用當前例項的父例項
let parent = options.parent
// 如果當前例項有父元件,且當前例項不是抽象的
if (parent && !options.abstract) {
  // 使用 while 迴圈查詢第一個非抽象的父元件
  while (parent.$options.abstract && parent.$parent) {
    parent = parent.$parent
  }
  // 經過上面的 while 迴圈後,parent 應該是一個非抽象的元件,將它作為當前例項的父級,所以將當前例項 vm 新增到父級的 $children 屬性裡
  parent.$children.push(vm)
}

// 設定當前例項的 $parent 屬性,指向父級
vm.$parent = parent
// 設定 $root 屬性,有父級就是用父級的 $root,否則 $root 指向自身
vm.$root = parent ? parent.$root : vm

上面程式碼的作用可以用一句話總結:“將當前例項新增到父例項的 $children 屬性裡,並設定當前例項的 $parent 指向父例項”。那麼要實現這個目標首先要尋找到父級才行,那麼父級的來源是哪裡呢?就是這句話:

// 定義 parent,它引用當前例項的父元件
let parent = options.parent

通過讀取 options.parent 獲取父例項,但是問題來了,我們知道 options 是 vm.$options 的引用,所以這裡的 options.parent 相當於 vm.$options.parent,那麼 vm.$options.parent 從哪裡來?比如下面的例子:

// 子元件本身並沒有指定 parent 選項
var ChildComponent = {
  created () {
    // 但是在子元件中訪問父例項,能夠找到正確的父例項引用
    console.log(this.$options.parent)
  }
}

var vm = new Vue({
    el: '#app',
    components: {
      // 註冊元件
      ChildComponent
    },
    data: {
        test: 1
    }
})

我們知道 Vue 給我們提供了 parent 選項,使得我們可以手動指定一個元件的父例項,但在上面的例子中,我們並沒有手動指定 parent 選項,但是子元件依然能夠正確地找到它的父例項,這說明 Vue在尋找父例項的時候是自動檢測的。那它是怎麼做的呢?目前不準備給大家介紹,因為時機還不夠成熟,現在講大家很容易懵,不過可以給大家看一段程式碼,開啟 core/vdom/create-component.js 檔案,裡面有一個函式叫做 createComponentInstanceForVnode,如下:

export function createComponentInstanceForVnode (
  vnode: any, // we know it's MountedComponentVNode but flow doesn't
  parent: any, // activeInstance in lifecycle state
  parentElm?: ?Node,
  refElm?: ?Node
): Component {
  const vnodeComponentOptions = vnode.componentOptions
  const options: InternalComponentOptions = {
    _isComponent: true,
    parent,
    propsData: vnodeComponentOptions.propsData,
    _componentTag: vnodeComponentOptions.tag,
    _parentVnode: vnode,
    _parentListeners: vnodeComponentOptions.listeners,
    _renderChildren: vnodeComponentOptions.children,
    _parentElm: parentElm || null,
    _refElm: refElm || null
  }
  // check inline-template render functions
  const inlineTemplate = vnode.data.inlineTemplate
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render
    options.staticRenderFns = inlineTemplate.staticRenderFns
  }
  return new vnodeComponentOptions.Ctor(options)
}

這個函式是幹什麼的呢?我們知道當我們註冊一個元件的時候,還是拿上面的例子,如下:

// 子元件
var ChildComponent = {
  created () {
    console.log(this.$options.parent)
  }
}

var vm = new Vue({
    el: '#app',
    components: {
      // 註冊元件
      ChildComponent
    },
    data: {
        test: 1
    }
})

上面的程式碼中,我們的子元件 ChildComponent 說白了就是一個 json 物件,或者叫做元件選項物件,在父元件的 components 選項中把這個子元件選項物件註冊了進去,實際上在 Vue 內部,會首先以子元件選項物件作為引數通過 Vue.extend 函式建立一個子類出來,然後再通過例項化子類來建立子元件,而 createComponentInstanceForVnode 函式的作用,在這裡大家就可以簡單理解為例項化子元件,只不過這個過程是在虛擬DOM的 patch 演算法中進行的,我們後邊會詳細去講。我們看 createComponentInstanceForVnode 函式內部有這樣一段程式碼:

const options: InternalComponentOptions = {
  _isComponent: true,
  parent,
  propsData: vnodeComponentOptions.propsData,
  _componentTag: vnodeComponentOptions.tag,
  _parentVnode: vnode,
  _parentListeners: vnodeComponentOptions.listeners,
  _renderChildren: vnodeComponentOptions.children,
  _parentElm: parentElm || null,
  _refElm: refElm || null
}

這是例項化子元件時的元件選項,我們發現,第二個值就是 parent,那麼這個 parent 是誰呢?它是 createComponentInstanceForVnode 函式的形參,所以我們需要找到 createComponentInstanceForVnode函式是在哪裡呼叫的,它的呼叫位置就在 core/vdom/create-component.js 檔案內的 componentVNodeHooks 鉤子物件的 init 鉤子函式內,如下:

// hooks to be invoked on component VNodes during patch
const componentVNodeHooks = {
  init (
    vnode: VNodeWithData,
    hydrating: boolean,
    parentElm: ?Node,
    refElm: ?Node
  ): ?boolean {
    if (!vnode.componentInstance || vnode.componentInstance._isDestroyed) {
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance,
        parentElm,
        refElm
      )
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    } else if (vnode.data.keepAlive) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    }
  },

  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    ...
  },

  insert (vnode: MountedComponentVNode) {
    ...
  },

  destroy (vnode: MountedComponentVNode) {
    ...
  }
}

在 init 函式內有這樣一段程式碼:

const child = vnode.componentInstance = createComponentInstanceForVnode(
  vnode,
  activeInstance,
  parentElm,
  refElm
)

第二個引數 activeInstance 就是我們要找的 parent,那麼 activeInstance 是什麼呢?根據檔案頂部的 import 語句可知,activeInstance 來自於 core/instance/lifecycle.js 檔案,也就是我們正在看的 initLifecycle 函式的上面,如下:

export let activeInstance: any = null

這個變數將總是儲存著當前正在渲染的例項的引用,所以它就是當前例項 components 下注冊的子元件的父例項,所以 Vue 實際上就是這樣做到自動偵測父級的。

這裡大家儘量去理解一下,不過如果還是有點懵也沒關係,隨著我們對 Vue 的深入,慢慢的都會很好消化。上面我們解釋了這麼多,其實就是想說明白一件事,即 initLifecycle 函式內的程式碼中的 options.parent 的來歷,它有值的原因。

所以現在我們初步知道了 options.parent 值的來歷,且知道了它的值指向父例項,那麼接下來我們繼續看程式碼,還是這段程式碼:

// 定義 parent,它引用當前例項的父元件
let parent = options.parent
// 如果當前例項有父元件,且當前例項不是抽象的
if (parent && !options.abstract) {
  // 使用 while 迴圈查詢第一個非抽象的父元件
  while (parent.$options.abstract && parent.$parent) {
    parent = parent.$parent
  }
  // 經過上面的 while 迴圈後,parent 應該是一個非抽象的元件,將它作為當前例項的父級,所以將當前例項 vm 新增到父級的 $children 屬性裡
  parent.$children.push(vm)
}

拿到父例項 parent 之後,進入一個判斷分支,條件是:parent && !options.abstract,即父例項存在,且當前例項不是抽象的,這裡大家可能會有疑問:什麼是抽象的例項?實際上 Vue 內部有一些選項是沒有暴露給我們的,就比如這裡的 abstract,通過設定這個選項為 true,可以指定該元件是抽象的,那麼通過該元件建立的例項也都是抽象的,比如:

AbsComponents = {
  abstract: true,
  created () {
    console.log('我是一個抽象的元件')
  }
}

抽象的元件有什麼特點呢?一個最顯著的特點就是它們一般不渲染真實DOM,這麼說大家可能不理解,我舉個例子大家就明白了,我們知道 Vue 內建了一些全域性元件比如 keep-alive 或者 transition,我們知道這兩個元件它是不會渲染DOM至頁面的,但他們依然給我提供了很有用的功能。所以他們就是抽象的元件,我們可以檢視一下它的原始碼,開啟 core/components/keep-alive.js 檔案,你能看到這樣的程式碼:

export default {
  name: 'keep-alive',
  abstract: true,
  ...
}

可以發現,它使用 abstract 選項來宣告這是一個抽象元件。除了不渲染真實DOM,抽象元件還有一個特點,就是它們不會出現在父子關係的路徑上。這麼設計也是合理的,這是由它們的性質所決定的。

所以現在大家再回看這段程式碼:

// locate first non-abstract parent (查詢第一個非抽象的父元件)
// 定義 parent,它引用當前例項的父元件
let parent = options.parent
// 如果當前例項有父元件,且當前例項不是抽象的
if (parent && !options.abstract) {
  // 使用 while 迴圈查詢第一個非抽象的父元件
  while (parent.$options.abstract && parent.$parent) {
    parent = parent.$parent
  }
  // 經過上線的 while 迴圈後,parent 應該是一個非抽象的元件,將它作為當前例項的父級,所以將當前例項 vm 新增到父級的 $children 屬性裡
  parent.$children.push(vm)
}

// 設定當前例項的 $parent 屬性,指向父級
vm.$parent = parent
// 設定 $root 屬性,有父級就是用父級的 $root,否則 $root 指向自身
vm.$root = parent ? parent.$root : vm

如果 options.abstract 為真,那說明當前例項是抽象的,所以並不會走 if 分支的程式碼,所以會跳過 if 語句塊直接設定 vm.$parent 和 vm.$root 的值。跳過 if 語句塊的結果將導致該抽象例項不會被新增到父例項的 $children 中。如果 options.abstract 為假,那說明當前例項不是抽象的,是一個普通的元件例項,這個時候就會走 while 迴圈,那麼這個 while 迴圈是幹嘛的呢?我們前面說過,抽象的元件是不能夠也不應該作為父級的,所以 while 迴圈的目的就是沿著父例項鏈逐層向上尋找到第一個不抽象的例項作為 parent(父級)。並且在找到父級之後將當前例項新增到父例項的 $children 屬性中,這樣最終的目的就達成了。

在上面這段程式碼執行完畢之後,initLifecycle 函式還負責在當前例項上新增一些屬性,即後面要執行的程式碼:

vm.$children = []
vm.$refs = {}

vm._watcher = null
vm._inactive = null
vm._directInactive = false
vm._isMounted = false
vm._isDestroyed = false
vm._isBeingDestroyed = false

其中 $children 和 $refs 都是我們熟悉的例項屬性,他們都在 initLifecycle 函式中被初始化,其中 $children 被初始化為一個數組,$refs 被初始化為一個空 json 物件,除此之外,還定義了一些內部使用的屬性,大家先混個臉熟,在後面的分析中自然會知道他們的用途,但是不要忘了,既然這些屬性是在 initLifecycle 函式中定義的,那麼自然會與生命週期有關。這樣 initLifecycle 函式我們就分析完畢了,我們回到 _init 函式,看看接下來要做的初始化工作是什麼。

#初始化之 initEvents

在 initLifecycle 函式之後,執行的就是 initEvents,它來自於 core/instance/events.js 檔案,開啟該檔案找到 initEvents 方法,其內容很簡短,如下:

export function initEvents (vm: Component) {
  vm._events = Object.create(null)
  vm._hasHookEvent = false
  // init parent attached events
  const listeners = vm.$options._parentListeners
  if (listeners) {
    updateComponentListeners(vm, listeners)
  }
}

首先在 vm 例項物件上新增兩個例項屬性 _events 和 _hasHookEvent,其中 _events 被初始化為一個空物件,_hasHookEvent 的初始值為 false。之後將執行這段程式碼:

// init parent attached events
const listeners = vm.$options._parentListeners
if (listeners) {
  updateComponentListeners(vm, listeners)
}

大家肯定還是有這個疑問:vm.$options._parentListeners 這個 _parentListeners 是哪裡來的?細心的同學可能已經注意到了,我們之前看過一個函式叫做 createComponentInstanceForVnode,他在 core/vdom/create-component.js 檔案中,如下:

export function createComponentInstanceForVnode (
  vnode: any, // we know it's MountedComponentVNode but flow doesn't
  parent: any, // activeInstance in lifecycle state
  parentElm?: ?Node,
  refElm?: ?Node
): Component {
  const vnodeComponentOptions = vnode.componentOptions
  const options: InternalComponentOptions = {
    _isComponent: true,
    parent,
    propsData: vnodeComponentOptions.propsData,
    _componentTag: vnodeComponentOptions.tag,
    _parentVnode: vnode,
    _parentListeners: vnodeComponentOptions.listeners,
    _renderChildren: vnodeComponentOptions.children,
    _parentElm: parentElm || null,
    _refElm: refElm || null
  }
  // check inline-template render functions
  const inlineTemplate = vnode.data.inlineTemplate
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render
    options.staticRenderFns = inlineTemplate.staticRenderFns
  }
  return new vnodeComponentOptions.Ctor(options)
}

我們發現 _parentListeners 也出現這裡,也就是說在建立子元件例項的時候才會有這個引數選項,所以現在我們不做深入討論,後面自然有機會。

#初始化之 initRender

在 initEvents 的下面,執行的是 initRender 函式,該函式來自於 core/instance/render.js 檔案,我們開啟這個檔案找到 initRender 函式,如下:

export function initRender (vm: Component) {
  vm._vnode = null // the root of the child tree
  vm._staticTrees = null // v-once cached trees
  const options = vm.$options
  const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
  const renderContext = parentVnode && parentVnode.context
  vm.$slots = resolveSlots(options._renderChildren, renderContext)
  vm.$scopedSlots = emptyObject
  // bind the createElement fn to this instance
  // so that we get proper render context inside it.
  // args order: tag, data, children, normalizationType, alwaysNormalize
  // internal version is used by render functions compiled from templates
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // normalization is always applied for the public version, used in
  // user-written render functions.
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

  // $attrs & $listeners are exposed for easier HOC creation.
  // they need to be reactive so that HOCs using them are always updated
  const parentData = parentVnode && parentVnode.data

  /* istanbul ignore else */
  if (process.env.NODE_ENV !== 'production') {
    defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
      !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
    }, true)
    defineReactive(vm, '$listeners', options._parentListeners || emptyObject, () => {
      !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)
    }, true)
  } else {
    defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
    defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
  }
}

上面是 initRender 函式的全部程式碼,我們慢慢來看,首先在 Vue 例項物件上新增兩個例項屬性,即 _vnode 和 _staticTrees

vm._vnode = null // the root of the child tree
vm._staticTrees = null // v-once cached trees

並且這兩個屬性都被初始化為 null,它們會在合適的地方被賦值並使用,到時候我們再講其作用,現在我們暫且不介紹這兩個屬性的作用,你只要知道這兩句話僅僅是在當前例項物件上添加了兩個屬性就行了。

接著是這樣一段程式碼:

const options = vm.$options
const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
const renderContext = parentVnode && parentVnode.context
vm.$slots = resolveSlots(options._renderChildren, renderContext)
vm.$scopedSlots = emptyObject

上面這段程式碼從表面上看很複雜,可以明確地告訴大家,如果你看懂了上面這段程式碼就意味著你已經知道了 Vue 是如何解析並處理 slot 的了。由於上面這段程式碼涉及內部選項比較多如:options._parentVnodeoptions._renderChildren 甚至 parentVnode.context,這些內容牽扯的東西比較多,現在大家對 Vue 的儲備還不夠,所以我們會在本節的最後階段補講,那個時候相信大家理解起來要容易多了。

不講歸不講,但是有一些事兒還是要講清楚的,比如上面這段程式碼無論它處理的是什麼內容,其結果都是在 Vue 當前例項物件上添加了三個例項屬性:

vm.$vnode
vm.$slots
vm.$scopedSlots

我們把這些屬性都整理到 Vue例項的設計 中。

再往下是這段程式碼:

// bind the createElement fn to this instance
// so that we get proper render context inside it.
// args order: tag, data, children, normalizationType, alwaysNormalize
// internal version is used by render functions compiled from templates
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

這段程式碼在 Vue 例項物件上添加了兩個方法:vm._c 和 vm.$createElement,這兩個方法實際上是對內部函式 createElement 的包裝。其中 vm.$createElement 相信手寫過渲染函式的同學都比較熟悉,如下程式碼:

render: function (createElement) {
  return createElement('h2', 'Title')
}

我們知道,渲染函式的第一個引數是 createElement 函式,該函式用來建立虛擬節點,實際上你也完全可以這麼做:

render: function () {
  return this.$createElement('h2', 'Title')
}

上面兩段程式碼是完全等價的。而對於 vm._c 方法,則用於編譯器根據模板字串生成的渲染函式的。vm._c 和 vm.$createElement 的不同之處就在於呼叫 createElement 函式時傳遞的第六個引數不同,至於這麼做的原因,我們放到後面講解。有一點需要注意,即 $createElement 看上去像對外暴露的介面,但其實文件上並沒有體現。

再往下,就是 initRender 函式的最後一段程式碼了:

// $attrs & $listeners are exposed for easier HOC creation.
// they need to be reactive so that HOCs using them are always updated
const parentData = parentVnode && parentVnode.data

/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
  defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
    !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
  }, true)
  defineReactive(vm, '$listeners', options._parentListeners || emptyObject, () => {
    !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)
  }, true)
} else {
  defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
  defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
}

上面的程式碼主要作用就是在 Vue 例項物件上定義兩個屬性:vm.$attrs 以及 vm.$listeners。這兩個屬性在 Vue 的文件中是有說明的,由於這兩個屬性的存在使得在 Vue 中建立高階元件變得更容易,感興趣的同學可以閱讀 探索Vue高階元件

我們注意到,在為例項物件定義 $attrs 屬性和 $listeners 屬性時,使用了 defineReactive 函式,該函式的作用就是為一個物件定義響應式的屬性,所以 $attrs 和 $listeners 這兩個屬性是響應式的,至於 defineReactive 函式的講解,我們會放到 Vue 的響應系統中講解。

另外,上面的程式碼中有一個對環境的判斷,在非生產環境中呼叫 defineReactive 函式時傳遞的第四個引數是一個函式,實際上這個函式是一個自定義的 setter,這個 setter 會在你設定 $attrs 或 $listeners 屬性時觸發並執行。以 $attrs 屬性為例,當你試圖設定該屬性時,會執行該函式:

() => {
  !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
}

可以看到,當 !isUpdatingChildComponent 成立時,會提示你 $attrs 是隻讀屬性,你不應該手動設定它的值。同樣的,對於 $listeners 屬性也做了這樣的處理。

這裡使用到了 isUpdatingChildComponent 變數,根據引用關係,該變數來自於 lifecycle.js 檔案,開啟 lifecycle.js 檔案,可以發現有三個地方使用了這個變數:

// 定義 isUpdatingChildComponent,並初始化為 false
export let isUpdatingChildComponent: boolean = false

// 省略中間程式碼 ...

export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  if (process.env.NODE_ENV !== 'production') {
    isUpdatingChildComponent = true
  }

  // 省略中間程式碼 ...

  // update $attrs and $listeners hash
  // these are also reactive so they may trigger child update if the child
  // used them during render
  vm.$attrs = parentVnode.data.attrs || emptyObject
  vm.$listeners = listeners || emptyObject

  // 省略中間程式碼 ...

  if (process.env.NODE_ENV !== 'production') {
    isUpdatingChildComponent = false
  }
}

上面程式碼是簡化後的,可以發現 isUpdatingChildComponent 初始值為 false,只有當 updateChildComponent 函式開始執行的時候會被更新為 true,當 updateChildComponent 執行結束時又將 isUpdatingChildComponent 的值還原為 false,這是因為 updateChildComponent 函式需要更新例項物件的 $attrs 和 $listeners 屬性,所以此時是不需要提示 $attrs 和 $listeners 是隻讀屬性的。

最後,對於大家來講,現在瞭解這些知識就足夠了,至於 $attrs 和 $listeners 這兩個屬性的值到底是什麼,等我們講解虛擬DOM的時候再回來說明,這樣大家更容易理解。

#生命週期鉤子的實現方式

在 initRender 函式執行完畢後,是這樣一段程式碼:

callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

可以發現,initInjections(vm)initState(vm) 以及 initProvide(vm) 被包裹在兩個 callHook 函式呼叫的語句中。那麼 callHook 函式的作用是什麼呢?正如它的名字一樣,callHook 函式的作用是呼叫生命週期鉤子函式。根據引用關係可知 callHook 函式來自於 lifecycle.js 檔案,開啟該檔案找到 callHook 函式如下:

export function callHook (vm: Component, hook: string) {
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget()
  const handlers = vm.$options[hook]
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      try {
        handlers[i].call(vm)
      } catch (e) {
        handleError(e, vm, `${hook} hook`)
      }
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
  popTarget()
}

以上是 callHook 函式的全部程式碼,它接收兩個引數:例項物件和要呼叫的生命週期鉤子的名稱。接下來我們就看看 callHook 是如何實現的。

大家可能注意到了 callHook 函式體的程式碼以 pushTarget() 開頭,並以 popTarget() 結尾,這裡我們暫且不講這麼做的目的,這其實是為了避免在某些生命週期鉤子中使用 props 資料導致收集冗餘的依賴,我們在 Vue 響應系統的章節會回過頭來仔細給大家講解。下面我們開始分析 callHook 函式的程式碼的中間部分,首先獲取要呼叫的生命週期鉤子:

const handlers = vm.$options[hook]

比如 callHook(vm, created),那麼上面的程式碼就相當於:

const handlers = vm.$options.created

在 Vue選項的合併 一節中我們講過,對於生命週期鉤子選項最終會被合併處理成一個數組,所以得到的 handlers 就是對應生命週期鉤子的陣列。接著執行的是這段程式碼:

if (handlers) {
  for (let i = 0, j = handlers.length; i < j; i++) {
    try {
      handlers[i].call(vm)
    } catch (e) {
      handleError(e, vm, `${hook} hook`)
    }
  }
}

由於開發者在編寫元件時未必會寫生命週期鉤子,所以獲取到的 handlers 可能不存在,所以使用 if語句進行判斷,只有當 handlers 存在的時候才對 handlers 進行遍歷,handlers 陣列的元素就是生命週期鉤子函式,所以直接執行即可:

handlers[i].call(vm)

為了保證生命週期鉤子函式內可以通過 this 訪問例項物件,所以使用 .call(vm) 執行這些函式。另外由於生命週期鉤子函式的函式體是開發者編寫的,為了捕獲可能出現的錯誤,使用 try...catch 語句塊,並在 catch 語句塊內使用 handleError 處理錯誤資訊。其中 handleError 來自於 core/util/error.js 檔案,大家可以在附錄 core/util 目錄下的工具方法全解 中檢視關於 handleError的講解。

所以我們發現,對於生命週期鉤子的呼叫,其實就是通過 this.$options 訪問處理過的對應的生命週期鉤子函式陣列,遍歷並執行它們。原理還是很簡單的。

我們回過頭來再看一下這段程式碼:

callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

現在大家應該知道,beforeCreate 以及 created 這兩個生命週期鉤子的呼叫時機了。其中 initState包括了:initPropsinitMethodsinitDatainitComputed 以及 initWatch。所以當 beforeCreate 鉤子被呼叫時,所有與 propsmethodsdatacomputed 以及 watch 相關的內容都不能使用,當然了 inject/provide 也是不可用的。

作為對立面,created 生命週期鉤子則恰恰是等待 initInjectionsinitState 以及 initProvide執行完畢之後才被呼叫,所以在 created 鉤子中,是完全能夠使用以上提到的內容的。但由於此時還沒有任何掛載的操作,所以在 created 中是不能訪問DOM的,即不能訪問 $el

最後我們注意到 callHook 函式的最後有這樣一段程式碼:

if (vm._hasHookEvent) {
  vm.$emit('hook:' + hook)
}

其中 vm._hasHookEvent 是在 initEvents 函式中定義的,它的作用是判斷是否存在生命週期鉤子的事件偵聽器,初始化值為 false 代表沒有,當元件檢測到存在生命週期鉤子的事件偵聽器時,會將 vm._hasHookEvent 設定為 true。那麼問題來了,什麼叫做生命週期鉤