Vue 的生命周期之間到底做了什麼事清?(源碼詳解,帶你從頭梳理組件化流程)
- 2020 年 4 月 11 日
- 筆記
前言
相信大家對 Vue 有哪些生命周期早就已經爛熟於心,但是對於這些生命周期的前後分別做了哪些事情,可能還有些不熟悉。
本篇文章就從一個完整的流程開始,詳細講解各個生命周期之間發生了什麼事情。
注意本文不涉及 keep-alive
的場景和錯誤處理的場景。
初始化流程
new Vue
從 new Vue(options)
開始作為入口,Vue
只是一個簡單的構造函數,內部是這樣的:
function Vue (options) { this._init(options) } 複製程式碼
進入了 _init
函數之後,先初始化了一些屬性。
initLifecycle
:初始化一些屬性如$parent
,$children
。根實例沒有$parent
,$children
開始是空數組,直到它的子組件
實例進入到initLifecycle
時,才會往父組件的$children
里把自身放進去。所以$children
里的一定是組件的實例。initEvents
:初始化事件相關的屬性,如_events
等。initRender
:初始化渲染相關如$createElement
,並且定義了$attrs
和$listeners
為淺層
響應式屬性。具體可以查看細節
章節。並且還定義了$slots
、$scopedSlots
,其中$slots
是立刻賦值的,但是$scopedSlots
初始化的時候是一個emptyObject
,直到組件的vm._render
過程中才會通過normalizeScopedSlots
去把真正的$scopedSlots
整合後掛到vm
上。
然後開始第一個生命周期:
callHook(vm, 'beforeCreate') 複製程式碼
beforeCreate被調用完成
beforeCreate
之後
- 初始化
inject
- 初始化
state
- 初始化
props
- 初始化
methods
- 初始化
data
- 初始化
computed
- 初始化
watch
- 初始化
- 初始化
provide
所以在 data
中可以使用 props
上的值,反過來則不行。
然後進入 created
階段:
callHook(vm, 'created') 複製程式碼
created被調用完成
調用 $mount
方法,開始掛載組件到 dom
上。
如果使用了 runtime-with-compile
版本,則會把你傳入的 template
選項,或者 html
文本,通過一系列的編譯生成 render
函數。
- 編譯這個
template
,生成ast
抽象語法樹。 - 優化這個
ast
,標記靜態節點。(渲染過程中不會變的那些節點,優化性能)。 - 根據
ast
,生成render
函數。
對應具體的程式碼就是:
const ast = parse(template.trim(), options) if (options.optimize !== false) { optimize(ast, options) } const code = generate(ast, options) 複製程式碼
如果是腳手架搭建的項目的話,這一步 vue-cli
已經幫你做好了,所以就直接進入 mountComponent
函數。
那麼,確保有了 render
函數後,我們就可以往渲染
的步驟繼續進行了
beforeMount被調用完成
把 渲染組件的函數
定義好,具體程式碼是:
updateComponent = () => { vm._update(vm._render(), hydrating) } 複製程式碼
拆解來看,vm._render
其實就是調用我們上一步拿到的 render
函數生成一個 vnode
,而 vm._update
方法則會對這個 vnode
進行 patch
操作,幫我們把 vnode
通過 createElm
函數創建新節點並且渲染到 dom節點
中。
接下來就是執行這段程式碼了,是由 響應式原理
的一個核心類 Watcher
負責執行這個函數,為什麼要它來代理執行呢?因為我們需要在這段過程中去 觀察
這個函數讀取了哪些響應式數據,將來這些響應式數據更新的時候,我們需要重新執行 updateComponent
函數。
如果是更新後調用 updateComponent
函數的話,updateComponent
內部的 patch
就不再是初始化時候的創建節點,而是對新舊 vnode
進行 diff
,最小化的更新到 dom節點
上去。具體過程可以看我的上一篇文章:
為什麼 Vue 中不要用 index 作為 key?(diff 演算法詳解)
這一切交給 Watcher
完成:
new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) 複製程式碼
注意這裡在before
屬性上定義了beforeUpdate
函數,也就是說在 Watcher
被響應式屬性的更新觸發之後,重新渲染新視圖之前,會先調用 beforeUpdate
生命周期。
關於 Watcher
和響應式的概念,如果你還不清楚的話,可以閱讀我之前的文章:
手把手帶你實現一個最精簡的響應式系統來學習Vue的data、computed、watch源碼
注意,在 render
的過程中,如果遇到了 子組件
,則會調用 createComponent
函數。
createComponent
函數內部,會為子組件生成一個屬於自己的構造函數
,可以理解為子組件自己的 Vue
函數:
Ctor = baseCtor.extend(Ctor) 複製程式碼
在普通的場景下,其實這就是 Vue.extend
生成的構造函數,它繼承自 Vue
函數,擁有它的很多全局屬性。
這裡插播一個知識點,除了組件有自己的生命周期
外,其實 vnode
也是有自己的 生命周期的
,只不過我們平常開發的時候是接觸不到的。
那麼子組件的 vnode
會有自己的 init
周期,這個周期內部會做這樣的事情:
// 創建子組件 const child = createComponentInstanceForVnode(vnode) // 掛載到 dom 上 child.$mount(vnode.elm) 複製程式碼
而 createComponentInstanceForVnode
內部又做了什麼事呢?它會去調用 子組件
的構造函數。
new vnode.componentOptions.Ctor(options) 複製程式碼
構造函數的內部是這樣的:
const Sub = function VueComponent (options) { this._init(options) } 複製程式碼
這個 _init
其實就是我們文章開頭的那個函數,也就是說,如果遇到 子組件
,那麼就會優先開始子組件
的構建過程,也就是說,從 beforeCreated
重新開始。這是一個遞歸的構建過程。
也就是說,如果我們有 父 -> 子 -> 孫
這三個組件,那麼它們的初始化生命周期順序是這樣的:
父 beforeCreate 父 create 父 beforeMount 子 beforeCreate 子 create 子 beforeMount 孫 beforeCreate 孫 create 孫 beforeMount 孫 mounted 子 mounted 父 mounted 複製程式碼
然後,mounted
生命周期被觸發。
mounted被調用完成
到此為止,組件的掛載就完成了,初始化的生命周期結束。
更新流程
當一個響應式屬性被更新後,觸發了 Watcher
的回調函數,也就是 vm._update(vm._render())
,在更新之前,會先調用剛才在 before
屬性上定義的函數,也就是
callHook(vm, 'beforeUpdate') 複製程式碼
注意,由於 Vue 的非同步更新機制,beforeUpdate
的調用已經是在 nextTick
中了。 具體程式碼如下:
nextTick(flushSchedulerQueue) function flushSchedulerQueue { for (index = 0; index < queue.length; index++) { watcher = queue[index] if (watcher.before) { // callHook(vm, 'beforeUpdate') watcher.before() } } } 複製程式碼
beforeUpdate被調用完成
然後經歷了一系列的 patch
、diff
流程後,組件重新渲染完畢,調用 updated
鉤子。
注意,這裡是對 watcher
倒序 updated
調用的。
也就是說,假如同一個屬性通過 props
分別流向 父 -> 子 -> 孫
這個路徑,那麼收集到依賴的先後也是這個順序,但是觸發 updated
鉤子確是 孫 -> 子 -> 父
這個順序去觸發的。
function callUpdatedHooks (queue) { let i = queue.length while (i--) { const watcher = queue[i] const vm = watcher.vm if (vm._watcher === watcher && vm._isMounted) { callHook(vm, 'updated') } } } 複製程式碼
updated被調用完成
至此,渲染更新流程完畢。
銷毀流程
在剛剛所說的更新後的 patch
過程中,如果發現有組件在下一輪渲染中消失了,比如 v-for
對應的數組中少了一個數據。那麼就會調用 removeVnodes
進入組件的銷毀流程。
removeVnodes
會調用 vnode
的 destroy
生命周期,而 destroy
內部則會調用我們相對比較熟悉的 vm.$destroy()
。(keep-alive 包裹的子組件除外)
這時,就會調用 callHook(vm, 'beforeDestroy')
beforeDestroy被調用完成
之後就會經歷一系列的清理
邏輯,清除父子關係、watcher
關閉等邏輯。但是注意,$destroy
並不會把組件從視圖上移除,如果想要手動銷毀一個組件,則需要我們自己去完成這個邏輯。
然後,調用最後的 callHook(vm, 'destroyed')
destroyed被調用完成
細節
$attrs 和 $listener 的一些處理。
這裡額外提一下 $attrs
之所以只有第一層被定義為響應式,是因為一般來說深層次的響應式定義已經在父組件中定義做好了,只要保證 vm.$attrs = newAttrs
這樣的操作能觸發子組件的響應式更新即可。(在子組件的模板中使用了 $attrs
的情況下)
在更新子組件 updateChildComponent
操作中,會去取收集到的 vnode
上的 attrs
和 listeners
去更新 $attrs
屬性,這樣就運算元組件的模板上用了 $attrs
的屬性也可觸發響應式的更新。
import { emptyObject } from '../util/index' vm.$attrs = parentVnode.data.attrs || emptyObject vm.$listeners = listeners || emptyObject 複製程式碼
有一個比較細節的操作是這樣的:
這裡的 emptyObject
永遠是同樣的引用,也就能保證在沒有 attrs
或 listeners
傳遞的時候,能夠永遠用同一個引用而不去觸發響應式更新。
因為 defineReactive
的 set
函數中會做這樣的判斷:
set: function reactiveSetter (newVal) { const value = getter ? getter.call(obj) : val // 這裡引用相等 直接返回了 if (newVal === value || (newVal !== newVal && value !== value)) { return } } 複製程式碼
子組件的初始化
上文中提到,子組件的初始化也一樣會走 _init
方法,但是和根 Vue
實例不同的是,在 _init
中會有一個分支邏輯。
if (options && options._isComponent) { // 如果是組件的話 走這個邏輯 initInternalComponent(vm, options) } else { vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor), options || {}, vm ) } 複製程式碼
根級別 Vue 實例,也就是 new Vue(options)
生成是實例,它的 $options
對象大概是這種格式的,我們定義在 new Vue(options)
中的 options
對象直接合併到了 $options
上。
beforeCreate: [ƒ] beforeMount: [ƒ] components: {test: {…}} created: [ƒ] data: ƒ mergedInstanceDataFn() directives: {} el: "#app" filters: {} methods: {change: ƒ} mixins: [{…}] mounted: [ƒ] name: "App" render: ƒ anonymous( ) 複製程式碼
而子組件實例上的 $options
則是這樣的:
parent: Vue {_uid: 0, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: Vue, …} propsData: {msg: "hello"} render: ƒ anonymous( ) staticRenderFns: [] _componentTag: "test" _parentListeners: undefined _parentVnode: VNode {tag: "vue-component-1-test", data: {…}, children: undefined, text: undefined, elm: li, …} _propKeys: ["msg"] _renderChildren: [VNode] __proto__: Object 複製程式碼
那有人會問了,為啥我在子組件里通過 this.$options
也能訪問到定義在 options
里的屬性啊?
我們展開 __proto__
屬性看一下:
beforeCreate: [ƒ] beforeMount: [ƒ] created: [ƒ] directives: {} filters: {} mixins: [{…}] mounted: [ƒ] props: {msg: {…}} _Ctor: {0: ƒ} _base: ƒ Vue(options) 複製程式碼
原來是被掛在原型上了,具體是 initInternalComponent
中的這段話做的:
const opts = vm.$options = Object.create(vm.constructor.options) 複製程式碼
$vnode 和 _vnode 的區別
實例上有兩個屬性總是讓人摸不著頭腦,就是 $vnode
和 _vnode
,
舉個例子來說,我們寫了個這樣的組件 App
:
<div class="class-app"> <test /> </div> 複製程式碼
test
組件
<li class="class-test"> Hi, I'm test </li> 複製程式碼
接下來我們都以 test
組件舉例,請仔細看清楚它們的父子關係以及使用的標籤和類名。
$vnode
在渲染 App
組件的時候,遇到了 test
標籤,會把 test
組件包裹成一個 vnode
:
<div class="class-app"> // 渲染到這裡 <test /> </div> 複製程式碼
形如此:
tag: "vue-component-1-test" elm: li.class-test componentInstance: VueComponent {_uid: 1, _isVue: true, $options: {…}, componentOptions: {propsData: {…}, listeners: undefined, tag: "test", children: Array(1), Ctor: ƒ} context: Vue {_uid: 0, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: Vue, …} data: {attrs: {…}, on: undefined, hook: {…}, pendingInsert: null} child: (...) 複製程式碼
這個 tag
為 vue-component-1-test
的 vnode
,其實可以說是把整個組件給包裝了起來,通過 componentInstance
屬性可以訪問到實例 this
,
在 test
組件(比如說 test.vue
文件)的視角來看,它應該算是 外部 的 vnode
。(父組件在模板中讀取到 test.vue
組件後才生成)
它的 elm
屬性指向組件內部的 根元素
,也就是 li.class-test
。
此時,它在 test
組件的實例 this
上就保存為 this.$vnode
。
_vnode
在 test
組件實例上,通過 this._vnode
訪問到的 vnode
形如這樣:
tag: "li" elm: li.class-test children: (2) [VNode, VNode] context: VueComponent {_uid: 1, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: VueComponent, …} data: {staticClass: "class-test"} parent: VNode {tag: "vue-component-1-test", data: {…}, children: undefined, text: undefined, elm: li.test, …} 複製程式碼
可以看到,它的 tag
是 li
,也就是 test
組件的 template
上聲明的 最外層的節點
,
它的 elm
屬性也指向組件內部的 根元素
,也就是 li.class-test
。
它其實就是 test
組件的 render
函數返回的 vnode
,
在 _update
方法中也找到了來源:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this vm._vnode = vnode } 複製程式碼
回憶一下組件是怎麼初始化掛載和更新的,是不是 vm._update(vm._render())
?
所謂的 diff
演算法,diff
的其實就是 this
上保存的_vnode
,和新調用 _render
去生成的 vnode
進行 patch
。
而根 Vue
實例,也就是 new Vue()
的那層實例, this.$vnode
就是 null
,因為並沒有外層組件去渲染它。
總結關係
$vnode
外層組件渲染到當前組件標籤時,生成的 vnode
實例。
_vnode
是組件內部調用 render
函數返回的 vnode
實例。
_vnode.parent === $vnode
他們的 elm
,也就是實際 dom元素
,都指向組件內部的根元素
。
this.$children 和 _vnode.children
$children
只保存當前實例的直接子組件 實例,所以你訪問不到 button
,li
這些 原生html標籤
。注意是實例而不是 vnode
,也就是通過 this
訪問到的那玩意。
_vnode.children
,則會把當前組件的 vnode
樹全部保存起來,不管是組件vnode
還是原生 html 標籤生成的vnode
,並且 原生 html生成的 vnode
內部還可以通過children
進一步訪問子vnode
。
總結
至此為止,Vue 的生命周期我們就完整的回顧了一遍。知道各個生命周期之間發生了什麼事,可以讓我們在編寫 Vue 組件的過程中更加胸有成竹。
希望這篇文章對你有幫助。