天天看點

Vue 的生命周期之間到底做了什麼事清?(源碼詳解,帶你從頭梳理元件化流程)

前言

相信大家對 Vue 有哪些生命周期早就已經爛熟于心,但是對于這些生命周期的前後分别做了哪些事情,可能還有些不熟悉。

本篇文章就從一個完整的流程開始,詳細講解各個生命周期之間發生了什麼事情。

注意本文不涉及

keep-alive

的場景和錯誤處理的場景。

初始化流程

new Vue

new Vue(options)

開始作為入口,

Vue

隻是一個簡單的構造函數,内部是這樣的:

function Vue (options) {
  this._init(options)
}
複制代碼           

複制

進入了

_init

函數之後,先初始化了一些屬性。

  1. initLifecycle

    :初始化一些屬性如

    $parent

    $children

    。根執行個體沒有

    $parent

    $children

    開始是空數組,直到它的

    子元件

    執行個體進入到

    initLifecycle

    時,才會往父元件的

    $children

    裡把自身放進去。是以

    $children

    裡的一定是元件的執行個體。
  2. initEvents

    :初始化事件相關的屬性,如

    _events

    等。
  3. initRender

    :初始化渲染相關如

    $createElement

    ,并且定義了

    $attrs

    $listeners

    淺層

    響應式屬性。具體可以檢視

    細節

    章節。并且還定義了

    $slots

    $scopedSlots

    ,其中

    $slots

    是立刻指派的,但是

    $scopedSlots

    初始化的時候是一個

    emptyObject

    ,直到元件的

    vm._render

    過程中才會通過

    normalizeScopedSlots

    去把真正的

    $scopedSlots

    整合後挂到

    vm

    上。

然後開始第一個生命周期:

callHook(vm, 'beforeCreate')
複制代碼           

複制

beforeCreate被調用完成

beforeCreate

之後

  1. 初始化

    inject

  2. 初始化

    state

    • 初始化

      props

    • 初始化

      methods

    • 初始化

      data

    • 初始化

      computed

    • 初始化

      watch

  3. 初始化

    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 元件的過程中更加胸有成竹。

希望這篇文章對你有幫助。

❤️感謝大家