前言
相信大家對 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 元件的過程中更加胸有成竹。
希望這篇文章對你有幫助。