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

  • 2020 年 4 月 11 日
  • 筆記

前言

相信大家對 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被調用完成

然後經歷了一系列的 patchdiff 流程後,組件重新渲染完畢,調用 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 會調用 vnodedestroy 生命周期,而 destroy 內部則會調用我們相對比較熟悉的 vm.$destroy()。(keep-alive 包裹的子組件除外)

這時,就會調用 callHook(vm, 'beforeDestroy')

beforeDestroy被調用完成

之後就會經歷一系列的清理邏輯,清除父子關係、watcher 關閉等邏輯。但是注意,$destroy 並不會把組件從視圖上移除,如果想要手動銷毀一個組件,則需要我們自己去完成這個邏輯。

然後,調用最後的 callHook(vm, 'destroyed')

destroyed被調用完成

細節

$attrs 和 $listener 的一些處理。

這裡額外提一下 $attrs 之所以只有第一層被定義為響應式,是因為一般來說深層次的響應式定義已經在父組件中定義做好了,只要保證 vm.$attrs = newAttrs 這樣的操作能觸發子組件的響應式更新即可。(在子組件的模板中使用了 $attrs 的情況下)

在更新子組件 updateChildComponent 操作中,會去取收集到的 vnode 上的 attrslisteners 去更新 $attrs 屬性,這樣就運算元組件的模板上用了 $attrs 的屬性也可觸發響應式的更新。

import { emptyObject } from '../util/index'    vm.$attrs = parentVnode.data.attrs || emptyObject  vm.$listeners = listeners || emptyObject  複製程式碼

有一個比較細節的操作是這樣的:

這裡的 emptyObject 永遠是同樣的引用,也就能保證在沒有 attrslisteners 傳遞的時候,能夠永遠用同一個引用而不去觸發響應式更新。

因為 defineReactiveset 函數中會做這樣的判斷:

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: (...)  複製程式碼

這個 tagvue-component-1-testvnode,其實可以說是把整個組件給包裝了起來,通過 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, …}  複製程式碼

可以看到,它的 tagli,也就是 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 只保存當前實例的直接子組件 實例,所以你訪問不到 buttonli 這些 原生html標籤。注意是實例而不是 vnode,也就是通過 this 訪問到的那玩意。

_vnode.children,則會把當前組件的 vnode 樹全部保存起來,不管是組件vnode還是原生 html 標籤生成的vnode,並且 原生 html生成的 vnode 內部還可以通過children進一步訪問子vnode

總結

至此為止,Vue 的生命周期我們就完整的回顧了一遍。知道各個生命周期之間發生了什麼事,可以讓我們在編寫 Vue 組件的過程中更加胸有成竹。

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

❤️感謝大家