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選項的規範化 和 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
,除此之外我們還能看到其他合併後的選項,其中 components
、directives
、filters
以及 _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._parentVnode
、options._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
包括了:initProps
、initMethods
、initData
、initComputed
以及 initWatch
。所以當 beforeCreate
鉤子被呼叫時,所有與 props
、methods
、data
、computed
以及 watch
相關的內容都不能使用,當然了 inject/provide
也是不可用的。
作為對立面,created
生命週期鉤子則恰恰是等待 initInjections
、initState
以及 initProvide
執行完畢之後才被呼叫,所以在 created
鉤子中,是完全能夠使用以上提到的內容的。但由於此時還沒有任何掛載的操作,所以在 created
中是不能訪問DOM的,即不能訪問 $el
。
最後我們注意到 callHook
函式的最後有這樣一段程式碼:
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook)
}
其中 vm._hasHookEvent
是在 initEvents
函式中定義的,它的作用是判斷是否存在生命週期鉤子的事件偵聽器,初始化值為 false
代表沒有,當元件檢測到存在生命週期鉤子的事件偵聽器時,會將 vm._hasHookEvent
設定為 true
。那麼問題來了,什麼叫做生命週期鉤