[Vue.js進階]從原始碼角度剖析Vue的生命週期

yeyan1996發表於2019-04-15

[Vue.js進階]從原始碼角度剖析Vue的生命週期

前言

使用Vue在日常開發中會頻繁接觸和使用生命週期,在官方文件中是這麼解釋生命週期的:

每個 Vue 例項在被建立時都要經過一系列的初始化過程——例如,需要設定資料監聽、編譯模板、將例項掛載到 DOM 並在資料變化時更新 DOM 等。同時在這個過程中也會執行一些叫做生命週期鉤子的函式,這給了使用者在不同階段新增自己的程式碼的機會。

好比人的生老病死的過程,Vue同樣也有從組建初始化到元件掛載,元件更新,元件銷燬的一系列過程,而生命週期鉤子,是一個函式,可以讓開發者在Vue到達某個時間段的時候做一些事情

最常見的就是在mounted鉤子中傳送ajax請求獲取當前的頁面元件所需要的資料

[Vue.js進階]從原始碼角度剖析Vue的生命週期

但是對於Vue.js進階來說,只知道生命週期的拼寫和對應的觸發時機肯定是不夠的,為什麼鉤子函式不能是一個箭頭函式,為什麼在data中有時候無法獲取定義的資料,我們通過this獲取data中的資料真的直接儲存在this下了嗎,Vue又是怎麼做到無感知的事件監聽/事件解綁

在這篇文章中,我將會帶大家深入Vue的原始碼,從原始碼中分析Vue的生命週期

部分和生命週期聯絡不大的屬性礙於篇幅不會詳解,如果有疑惑這個屬性哪裡定義的,可以在評論區和我進一步討論

原始碼概覽

生命週期這部分的原始碼地址在src/core/instance/init.js中,這裡我抽離了絕大部分無關的程式碼,只保留核心的邏輯

[Vue.js進階]從原始碼角度剖析Vue的生命週期

當我們在main.js中例項化Vue的時候,會經過一些邏輯,然後進入到_init函式開始Vue的生命週期,其實從這些函式的命名方式中就能大致看出Vue是如何執行的了,接下來我們逐個分析每個函式具體做了什麼

合併配置項

從上面的圖中能看到,在生命週期中第一件事就是合併配置項,而對於根例項和元件例項,Vue的處理方式是不同的(在main.js中new Vue生成的是根例項,其餘全部都是元件例項),根例項傳入的options引數裡不會有_isComponent屬性,反之為true(例項化的時機不同,傳入的引數也不同,感興趣的朋友可以檢視相關例項化的文章)

[Vue.js進階]從原始碼角度剖析Vue的生命週期

為了不必要的干涉,這裡沒有引入vue-router,vuex

根例項合併配置項

對於根例項會走false的邏輯,進入mergeOptions函式,合併Vue的各個配置項options,比如mixins,props,methods,watch,computed,生命週期鉤子等等,這是整個專案中第一次的合併配置。Vue會將所有的合併策略都儲存在一個strats物件中,然後依次遍歷當前例項和parent的同一個屬性,再去starts找那個屬性對應的合併策略

通過斷點可以看到strats儲存了很多合併的策略

[Vue.js進階]從原始碼角度剖析Vue的生命週期

我們沒有必要每個合併策略都去看一遍,儘量把精力放在整個流程中,不要撿了芝麻丟了西瓜。第一次的合併中,Vue會通過resolveConstructorOptions(vm.constructor)獲取Vue構造器的靜態屬性options作為parent,這個options包含了一些預先設定好的配置項,而child就是我們給根例項例項化的時候傳入的一些引數,本文例子中對應上圖中的render函式

Vue預先設定的配置項作為第一次的parent:

[Vue.js進階]從原始碼角度剖析Vue的生命週期

根例項例項化傳入的引數:

[Vue.js進階]從原始碼角度剖析Vue的生命週期

根例項的合併策略其實很簡單,主要就是把Vue框架內建的一些配置項和開發者在main.js中例項化Vue構造器傳入的引數進行一次簡單的合併,作為根例項的$options屬性

元件例項合併配置項

元件例項合併配置項並不在_init函式中,因為元件例項和根例項不同,元件例項是由元件構造器例項化的,而根例項是由Vue構造器例項化的,而元件構造器又是繼承自Vue的它需要通過Vue.extend方法去繼承Vue建構函式,我畫了張圖方便理解

[Vue.js進階]從原始碼角度剖析Vue的生命週期

Vue這麼做符合物件導向的設計模式,一個元件實質上是一個構造器函式(進一步可以認為是一個class),這樣在一個頁面中引入多個相同的元件只需要多次例項化元件構造器就可以了,並且可以做到例項之間互相獨立

而物件導向另外一個好處就是可以實現繼承,體現在Vue框架中則是將元件構造器繼承Vue構造器,從而元件構造器能夠獲得Vue構造器內建的一些配置項。在src/core/global-api/extend.js中同樣會呼叫·mergeOptions,這次的合併會將Vue框架內建的配置項和當前元件配置項進行合併並賦值給元件構造器的靜態屬性options

[Vue.js進階]從原始碼角度剖析Vue的生命週期

再次回到mergeOptions中,這裡就只例舉一個生命週期的合併策略,直接貼上原始碼並附上流程圖方便理解

[Vue.js進階]從原始碼角度剖析Vue的生命週期

[Vue.js進階]從原始碼角度剖析Vue的生命週期

這裡我用了父級而不是父元件,因為Vue的元件一般繼承自Vue建構函式而不是父元件,通過流程圖可以發現,Vue會保證生命週期函式始終是一個陣列,並且以父=>子的順序排列的,Vue在執行某個生命週期的時候會遍歷這個陣列依次執行函式,所以當我們在Vue構造器和元件構造器中的同一個生命週期裡都定義了生命週期函式,會先執行Vue構造器中的那個

繼承了Vue構造器後才會例項化子元件生成元件例項,再進入到_init函式,這個時候_isComponent為true會執行initInternalComponent,它會給元件例項建立$options屬性,指向子元件構造器的靜態屬性options,這樣就能夠通過元件例項的$options屬性訪問到當前元件的配置項以及Vue框架內建的配置項(包括全域性元件,全域性混入)

小結

  • 生命週期中第一件事就是合併配置項,對於根例項和元件例項合併的時機不同
  • 根例項是在new Vue的時候進行合併,將Vue內建的配置項和new Vue傳入的配置項進行合併
  • 對於元件例項來說,先會建立子元件的構造器,並且呼叫Vue.extend繼承Vue構造器,繼承的時候將Vue內建的配置項和元件配置項進行合併,並將結果儲存在構造器的options屬性中,之後在建立元件例項的時候進入initInternalComponent方法會將元件例項的$options指向元件構造器的options屬性
  • Vue框架會根據不同的配置執行不同的合併策略

代理開發環境的錯誤

非生產環境下會進入initProxy函式,通過ES6的Proxy給vm例項做一層攔截,主要作用是給開發環境下一些不合理的配置做出一些自定義的警告

[Vue.js進階]從原始碼角度剖析Vue的生命週期

上面的報錯很多開發者都遇到過,其實就是在這個時候通過Proxy的has攔截器,當某個屬性不在vm例項上卻被模版字串引用的時候,Vue會給出一些友好的提示

初始化自定義事件

隨後進入initLifecycle,這部分沒什麼好講的,初始化例項的一些生命週期的狀態和一些額外屬性,接著會進入初始化元件的自定義事件

[Vue.js進階]從原始碼角度剖析Vue的生命週期

initEvents只會掛載自定義事件,即元件中使用v-on監聽的非native的事件(原生的DOM事件並非在initEvents中掛載)。Vue會把這些父元件中宣告的自定義的事件儲存在子元件的_parentListeners屬性中(vm是子元件的元件例項,_parentListeners是在initInternalComponent中定義的)

進入updateComponentListeners,發現Vue會呼叫add函式註冊所有的自定義事件,而對於元件來說add函式就會呼叫$on來達到監聽自定義事件的效果

//https://github.com/vuejs/vue/blob/dev/src/core/instance/events.js#L24
function add (event, fn) {
  target.$on(event, fn)
}

//https://github.com/vuejs/vue/blob/dev/src/core/vdom/helpers/update-listeners.js#L83
//呼叫add註冊自定義事件(後面3個引數可忽略)
add(event.name, cur, event.capture, event.passive, event.params)
複製程式碼

beforeCreate

新增完自定義事件後,進入initRender,定義插槽和給render函式的引數createElement,另外會將Vue的$attrs,$listeners變成響應式的屬性

接著會執行callHook(vm, 'beforeCreate'),從字面上來看就能猜出Vue在這個時候會呼叫beforeCreate這個生命週期函式,在之前合併配置項的時候就提到,生命週期函式最終會被包裹成一個陣列,所以事實上Vue也支援這麼寫

[Vue.js進階]從原始碼角度剖析Vue的生命週期

callHook函式會根據傳入的引數拿到$options屬性中對應的生命週期函式組成的陣列,這裡傳入了beforeCreate,所以會獲得beforeCreate中定義的所有生命週期函式,之後順序遍歷並且用call方法給每個生命週期函式繫結了this上下文,這就是為什麼生命週期函式不能使用剪頭函式書寫的原因

[Vue.js進階]從原始碼角度剖析Vue的生命週期

初始化資料

接著執行initInjections,這部分是用來初始化inject這個api,由於日常開發使用頻率較少就不詳細解釋了(其實是我懶得研究-.-)

隨後會進入另外一個關鍵的函式initState,它會依次初始化props,methods,data,computed,watch,我們一個個來講解

props

元件之間通訊的時候,父元件給子元件傳參,子元件需要定義props來接受父元件傳過來的屬性,而Vue規定,子元件是不能修改父元件傳來的props,因為這違背了單項資料流,會導致元件之間非常難以管理,如果在子元件修改了props,Vue會發出一個警告

而Vue又是怎麼知道開發者修改了props的屬性呢?原因還是利用了訪問器描述符setter

[Vue.js進階]從原始碼角度剖析Vue的生命週期

瞭解過響應式原理的朋友應該對這個有所熟悉,Vue會將props物件變成一個響應式物件,並且第四個引數是一個自定義的setter,當props被修改了會觸發這個setter,一單違背了單項資料流時就會報出這個警告

methods

對於methods,Vue會定義一些開發過程中的不規範的警告,隨後會將所有的method繫結vm例項,這樣我們就可以直接通過this獲取當前的vm例項

data

到了最關鍵的data,data中一般儲存的是當前元件需要使用的資料,除了根例項之外,元件例項的data一般都是一個函式,因為JS引用型別的特點,如果使用物件,當存在多個相同的元件,其中一個元件修改了data資料,會反映到所有的元件。當data作為一個函式返回一個物件時,每次執行都會生成一個新的物件,可以有效的解決這個問題

[Vue.js進階]從原始碼角度剖析Vue的生命週期

初始化data會執行initData這個函式,內部會執行定義的data函式並且把當前例項作為this值,並且賦值給_data這個內部屬性,值得注意的是,在執行data函式的過程中是獲取不到computed中的資料,因為computed中的資料此時還沒初始化

[Vue.js進階]從原始碼角度剖析Vue的生命週期

隨後執行proxy函式,它的作用是將vm._data的屬性對映到vm屬性上,起到了"代理"的作用,這樣做是為了在開發過程中直接書寫this[key]的形式,其原理依舊是利用了getter/setter,當我們訪問this[key]的時候會觸發getter,直接指向this._data[key],setter同理

有人會問,那為啥不直接寫在vm例項上呢?因為我們需要將資料放在一個統一的物件上進行管理,為的是下一步把_data通過observe變成一個響應式物件。而為了在開發的時候書寫更加簡潔,Vue採取了這種方法,非常的討巧

computed

到了初始化computed,Vue會給每個計算屬性生成一個computed watcher,只有當這個計算屬性的依賴項改變了才會去通知computed watcher更新這個計算屬性,從而既能達到實時更新資料,又不會浪費效能,也是Vue非常棒的功能

[Vue.js進階]從原始碼角度剖析Vue的生命週期

watch

初始化watch的時候最終會呼叫$watch方法,生成一個user watcher,當監聽的屬性發生改變就會立即通知user watcher執行回撥

created

再呼叫initProvide初始化provide後就會執行callHook(vm, 'beforeCreate'),和beforeCreate一樣,依次遍歷定義在$options上的created陣列,執行生命週期函式

至此整個元件建立完畢,其實這個時候就可以和後端進行互動獲取資料了,但是對於真正的DOM節點還沒有被渲染出來,一些需要和DOM的互動操作還無法在created鉤子中執行,即無法在created鉤子中有操作生成檢視的DOM

掛載過程

回到_init函式,已經到了最後一行,會判斷$options是否有el屬性,在Vue-cli2的時候,cli會自動在new Vue的時候傳入el引數,而對於Vue-cli3並沒有這麼做,而是生成根例項後主動呼叫$mount並傳入了掛載的節點,其實兩者都是一樣的,也可以使用$mount來實現元件的手動掛載

Vue-cli2:

[Vue.js進階]從原始碼角度剖析Vue的生命週期

Vue-cli3:

[Vue.js進階]從原始碼角度剖析Vue的生命週期

$mount最終會執行mountComponent這個函式

[Vue.js進階]從原始碼角度剖析Vue的生命週期

剛剛從_init的長篇大論中逃出來,又要跳進mountComponent這個坑

[Vue.js進階]從原始碼角度剖析Vue的生命週期

元件掛載我這裡不會展開詳解,儘量把重心放在生命週期方面,有興趣的朋友可以自行了解,或者看我底下的連結

beforeMount

當元件執行$mount並且擁有掛載點和渲染函式的時候,就會觸發beforeMount的鉤子,準備元件的掛載

渲染檢視的函式updateComponent

之後Vue會定義一個updateComponent函式,這個函式是整個掛載的核心,它由2部分組成,_render函式和_update函式

  • render函式最終會執行之前在beforeCreate定義的createElement函式,作用是建立vnode
  • update函式會將render函式生成的vnode渲染成一個真實的DOM樹,並掛載到掛載點上

第一次執行updateComponent會渲染出整個DOM樹,這個時候頁面就完整的被展現了

渲染watcher

然後會例項化全域性唯一的watcher:渲染watcher,並且將updateComponent作為回撥函式傳入,內部會立即執行一次updateComponet函式

[Vue.js進階]從原始碼角度剖析Vue的生命週期

對應到它的名字,這個watcher是用來觀察變數的變化,執行updateComponet渲染檢視的。模版字串中的響應式變數(例子中的變數a)內部都會儲存這個渲染watcher(因為這些變數都有可能修改檢視),一旦變數被修改了就會觸發setter,最後都會再次執行updateComponent函式來重新整理檢視

mounted

例項化渲染watcher渲染出頁面後會進入一個判斷,這裡要注意的是,只有根例項才會為true並且觸發mounted鉤子,那元件例項什麼時候觸發mounted鉤子呢?

這裡先給出答案,在src/core/vdom/create-component.js的insert鉤子(元件專屬的vnode鉤子),同時Vue會宣告一個insertedVnodeQueue陣列,儲存所有的元件vnode,每當一個元件vnode被渲染成DOM節點就會往這個陣列裡新增一個vnode元素,當元件全部渲染完畢後,會以子=>父的順序依次觸發mounted鉤子(最先觸發最裡層元件的mounted鉤子)。隨後再回到_init方法,最後觸發根例項的mounted鉤子,具體為什麼會這麼做有興趣的同學可以再深入研究

[Vue.js進階]從原始碼角度剖析Vue的生命週期

至此所有的資料都被初始化,並且渲染出了DOM節點,接下來會介紹元件更新和元件銷燬的過程

元件更新

回到mountComponent那張圖,在例項化渲染watcher的時候,Vue會給渲染watcher傳入一個物件,物件包含了一個before方法,執行before方法就會執行beforeUpdate鉤子,那什麼時候執行這個方法呢?

一旦模版字串的依賴的變數發生了變化,說明即將改變檢視,會觸發setter然後執行渲染watcher的回撥,即updateComponent重新整理檢視,在執行這個回撥前,Vue會檢視是否有before這個方法,如果有則會優先執行before,然後再執行updateCompont重新整理檢視

Vue會將所有的watcher放入一個佇列,flushSchedulerQueue會依次遍歷這些watcer,而渲染watcher會有一個before方法,從而觸發beforeUpdate鉤子

[Vue.js進階]從原始碼角度剖析Vue的生命週期

然後當所有的watcher都遍歷過之後,代表資料已經更新完畢,並且檢視也重新整理了,此時會呼叫callUpdatedHooks,執行updated鉤子

元件銷燬

元件銷燬的前提是發生了檢視更新,Vue會判斷生成新檢視的vnode和舊檢視對應的vnode的區別,然後刪除那些檢視中不需要渲染的節點,這個過程最終會呼叫例項的$destroy方法,對應原始碼的src/core/instance/lifecycle.js

[Vue.js進階]從原始碼角度剖析Vue的生命週期

依次按照順序執行:

  1. 首先會直接執行beforeDestory的鉤子,表示準備開始銷燬節點,此時是可以和當前元件例項互動的最後時機
  2. 隨後會找到當前元件的父節點,從父節點的children屬性中刪除當前的節點
  3. 對渲染watcher進行登出(vm_watcher存放的是渲染節點)
  4. 對其他的watcher進行登出(user watcher,computed watcher)
  5. 清除這個例項渲染出的DOM節點
  6. 執行destroyed鉤子
  7. 登出所有的監聽事件($off不傳引數會清空所有的監聽事件)

總結

至此整個Vue的生命週期結束了,最後再總結一下每個生命週期主要都做了什麼事情,嚴格按照Vue內部的執行順序羅列

  • beforeCreate:將開發者定義的配置項和Vue內部的配置項進行合併,初始化元件的自定義事件,定義createElement函式/初始化插槽
  • created:初始化注入,初始化所有資料(props,methods,data,computed,watch),初始化provide
  • beforeMount:尋找是否有掛載的節點,之後準備開始渲染頁面/例項化渲染watcher
  • mounted:頁面渲染完成
  • beforeUpdate:渲染watcher依賴的變數發生變化,準備更新檢視
  • updated:檢視和資料全部更新完畢
  • beforeDestroy:檢視發現變化且不需要用到當前元件,準備銷燬,登出watcher,刪除DOM節點
  • destroyed:元件銷燬完畢,登出所有監聽事件

事實上要想完全瞭解Vue的生命週期,還需要了解其他方面的知識點,例如元件掛載,響應式原理,另外可能還需要了解一下Vue的編譯原理,每個知識點又可以展開十幾個小的知識點,但是當你能夠真正理解Vue.js的核心原理,我相信對個人成長來說是一個不小的收穫(終於寫完了脖子都酸了_:(´°ω°`」 ∠):_)

砥礪前行 未來可期

參考資料

Vue.js 技術揭祕

相關文章