大白話Vue原始碼系列(05):執行時鳥瞰圖

DOM哥發表於2018-01-02

研究 runtime
一邊 Vue
一邊原始碼

初看 Vue 是 Vue
原始碼是原始碼

再看 Vue 不是 Vue
原始碼不是原始碼

再再看
Vue 是呼叫棧
原始碼也是呼叫棧

—— By DOM哥

Vue 執行時這一塊是非常有意思的,不像 Vue 編譯器那麼枯燥,這裡面有大量的實用技巧和設計思想可以學習。使用過 Vue 的小夥伴應該對 Vue 【響應的資料繫結】(也叫雙向繫結)的印象非常深刻,在修改了資料之後,檢視就會實時得到相應更新,這無疑極大地減輕了開發者的負擔,使得開發人員可以專注於處理業務邏輯和運算元據,也就是聞名遐邇的【資料驅動開發】。至於操作 DOM 更新檢視這件苦髒累的活,Vue 已經幫你妥善處理完畢並且對你完全透明(意思是它就像空氣一樣你完全注意不到它,卻又深度依賴它,離不開它)。

Vue 執行時模組主要是圍繞 Vue 例項的生命週期展開的,它涵蓋了 Vue 例項生命週期內所需要的全部設施,包括例項建立,響應的資料繫結,掛載到 DOM 節點以及資料變化時自動更新檢視等關鍵部分。本篇也將沿著 Vue 例項的生命週期路線,結合執行時關鍵實現虛擬碼,一步步清晰地描繪出 Vue 執行時的空中鳥瞰圖。

Vue 例項的生命週期

本段的部分內容參考自 Vue 官網的生命週期描述

就像每個人的生命週期有 幼年童年少年青年中年老年,每個 Vue 例項的生命週期也有 beforeCreatecreatedbeforeMountmountedbeforeUpdateupdatedactivateddeactivatedbeforeDestroydestroyed 等多個階段。

Vue 例項生命週期程式碼示例:

<div id='index'>{{msg}}</div>
複製程式碼
new Vue({
    el: '#index',
    data: {
        msg: 'lifecycle',
    },

    beforeCreate(){ console.log('beforeCreate')},
    created(){ console.log('created')},
    beforeMount(){ console.log('beforeMount')},
    mounted(){ console.log('mounted')},
})

// Console output:
//      beforeCreate
//      created
//      beforeMount
//      mounted
複製程式碼

每個 Vue 例項在被建立時都要經過一系列的初始化過程,例如設定資料監聽,編譯 HTML 模板,將例項掛載到 DOM 等。在這個初始化的過程中會在特定的地方執行一些叫做【生命週期鉤子】的函式,這些鉤子其實就是開發者可以自定義的回撥函式,如上面傳入的 created 函式就會在 Vue 例項 created 時被呼叫。

下面一張圖可以非常清晰地說明 Vue 各個生命週期鉤子的呼叫時機(圖片來自 Vue 官網生命週期圖示):

大白話Vue原始碼系列(05):執行時鳥瞰圖

Vue 的生命週期圖示

你不需要立馬弄明白圖上所有的東西,不過隨著你的不斷學習和使用,它的參考價值會越來越高。

例項建立

眾所周知 Vue 是通過 new Vue() 的方式進行使用的,也就是說 Vue 內部將自己封裝成了一個類。然而 Vue 並沒有使用 ES6 最新的 class 方式進行實現,而是用了原來 prototype 那一套,這是讓寶寶有些傷心的。閒話待會再敘,先看一下原始碼:

// vue/src/core/instance/index.js
function Vue (options) {
  this._init(options)
}
複製程式碼

Vue 將初始化工作全部放在了 Vue.prototype._init() 方法裡。去偽存真,_init 方法主程式碼如下:

// vue/src/core/instance/init.js
Vue.prototype._init = function (options) {
    const vm = this

    vm.$options = mergeOptions(options || {})

    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initState(vm)
    callHook(vm, 'created')

    if (vm.$options.el) {
        vm.$mount(vm.$options.el)
    }
}
複製程式碼

initEventsinitRender 函式主要用來初始化 Vue 例項的一些容器欄位,現在可暫時忽略它們。接下來重點來了,在 initState 函式中封裝了實現【響應的資料繫結】的關鍵程式碼,雖然這不是 Vue 最流弊的部分,但卻是我們對 Vue 最好奇的地方,也是我們開始本原始碼系列的最初動力。在 initState 之前和之後分別呼叫了 Vue 的生命週期鉤子函式 beforeCreatecreated,接下來看看 Vue 是如何實現響應的資料繫結的。

響應的資料繫結

響應的資料繫結並不是 Vue 獨創的,而是 MVVVM 模式理論的一部分,它是 View 層和 ViewModel 層的連線方式。如下圖所示:

大白話Vue原始碼系列(05):執行時鳥瞰圖

MVVM 分層示意圖

Vue 通過【觀察者模式】實現了一套響應式系統。觀察者模式(也叫釋出/訂閱模式)會將觀察者和被觀察的物件嚴格分離開,當被觀察物件的狀態發生變化時,所有依賴於它的觀察者都將得到通知並自動重新整理。舉個栗子,使用者介面可以作為一個觀察者,業務資料是被觀察者,使用者介面觀察業務資料的變化,當資料發生變化時,使用者介面就會自動更新。

該模式必須包含兩個角色:觀察者和被觀察物件。Vue 定義了一個 Watcher 類來建立觀察者,定義了一個 Dep 類來建立被觀察物件。 Dep 是 Dependent 的縮寫,意思是作為觀察者的依賴存在,也就是被觀察物件。

首先看一下【觀察者】 Watcher 的定義:

// vue/src/core/observer/watcher.js
import Dep from './dep'
export default class Watcher {
    constructor(vm) {
        this.vm = vm
        this.newDeps = []
        Dep.target = this
    }

    // 新增一個觀察者,或者說註冊一個依賴
    addDep(dep) {
        this.newDeps.push(dep)
        // 在【觀察者】收集【被觀察者】的同時,【被觀察者】也會收集【觀察者】
        // 這好比王八看綠豆對眼兒了,遂互存了電話號碼,就有了後來的相識相知
        dep.addSub(this)
    }

    // 在被觀察物件狀態發生變化時呼叫此方法
    update() {
        let {vm} = this
        // 更新檢視
        vm._update(vm._render())
    }
}
複製程式碼

每一個【觀察者】都會收集自己要觀察的資料物件(Dep),當【被觀察物件】發生變化時,【被觀察物件】會通知【觀察者】,【觀察者】收到通知後執行 update 方法更新檢視。

接下來看一下【被觀察者】 Dep

export default class Dep {
    constructor () {
        this.subs = []
    }
    addSub (sub) {
        this.subs.push(sub)
    }
    depend () {
        if (Dep.target) {
            Dep.target.addDep(this)
        }
    }
    // 通知所有對自己有依賴的觀察者
    notify () {
        const subs = this.subs
        for (let i = 0; i < subs.length; i++) {
            subs[i].update()
        }
    }
}
Dep.target = null
複製程式碼

每個【被觀察物件】同樣會收集依賴自己的【觀察者】,當自己發生變化時,就會通知(notify)這些觀察者 update

那麼問題來了,這兩個角色是如何收集對方的呢?又如何得知【被觀察者】發生變化了呢? 這就用到了並不常用的 Object.defineProperty() 方法,通過在 JavaScript 物件每個屬性描述符的 settergetter 裡做文章,就能實時捕捉 JavaScript 物件的變化。

需要注意的是,Object.defineProperty() 是 JS 語言本身的一個 API 而不是 Vue 實現的,Object.defineProperty 是 ES5 中一個無法 shim 的特性,這也是為什麼 Vue 不支援 IE8 以及更低版本瀏覽器的原因。如果想支援 IE8 以及更低版本瀏覽器怎麼辦呢?那就只有放棄 Vue,選擇 Knockout。更好的解決方案就是直接讓 IE8 以及更 low 的傢伙見鬼去吧。不過基本上不用擔心這個問題了,因為據最新瀏覽器使用調查報告,IE8 以及更低版本瀏覽器的市場份額已經微不足道,直接忽略不計就行了。

既然 JS 已經支援在物件屬性變化時新增自定義處理,Vue 需要做的事就是遍歷傳入的 data 選項,為 data 的每個屬性設定 settergetter。這就解決了如何得知【被觀察者】發生了變化這個問題。

接下來說說這兩者是如何收集對方的。【觀察者】和【被觀察者】就好比單身男和單身女,得有人安排相親才能建立起聯絡呵,Vue 就是這個牽線搭橋的媒婆。下面是相親原始碼:

// vue/src/core/observer/index.js
import Dep from './dep'
export function observe (obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
        let key = keys[i], value = obj[key];
        // 深度優先遍歷
        observe(value)

        const dep = new Dep()
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                // 【觀察者】收集【被觀察者】
                // 同時【被觀察者】也會收集【觀察者】
                if (Dep.target) {
                    Dep.target.addDep(dep)
                }
                return value
            },
            set(newVal) {
                value = newVal
                // 【被觀察者】通知【觀察者】
                dep.notify()
            }
        })
    }
}
複製程式碼

可以看到,Vue 在遍歷 data 物件時完成了【觀察者】和【被觀察物件】彼此之間的收集工作。並且在 data 的某欄位發生變化時,相應的依賴就會通知【觀察者】自己發生了變化,【觀察者】就可以做出反應。

Vue 接下來就會在 initState() 中呼叫 observe(vm.$options.data),執行之後例項化 Vue 時傳入的 data 物件就會成為響應式的,當你修改 data 物件的資料時(通常是根據使用者操作執行對應的業務邏輯),【被觀察者】就會通知已收集的所有【觀察者】,觀察者就會呼叫自己的 update 方法,從而更新檢視。這基本上就是 Vue 所實現的響應的資料繫結的工作原理。

掛載到 DOM 節點

在構建完響應式系統之後,Vue 接下來會檢查使用者是否傳入了 el 選項,因為 Vue 在將包含指令的 HTML 模板編譯成最終的樸素的 HTML 之後會執行 DOM 替換操作,最終展示在頁面上,如果沒有 el 選項,Vue 就不知道要把產出的 HTML 放到哪裡去展示。

掛載到 DOM 節點並非替換一下 DOM 那麼簡單,它包括將模板編譯成 render 函式,執行 render 函式生成虛擬DOM,計算出新舊虛擬DOM之間的最小變更,打補丁式地更新頁面檢視等幾大步。

將模板編譯成 render 函式

這個編譯過程在前幾篇的 Vue 編譯器模組裡已經講得很清楚了,主要分為根據模板生成 AST,對 AST 進行優化,根據 AST 生成 render 函式這三步,這裡不再贅述,感興趣的可前往檢視

執行 render 函式生成虛擬DOM

【虛擬DOM】並非 Vue 提出的概念,而是老早就被髮掘出來的新型DOM操作方式,MVVM 框架在引入虛擬DOM之後如虎添翼。之所以叫做虛擬DOM,是相對於真實DOM而言的。直接操作DOM很慢,因為真實的DOM物件很重,操作真實DOM物件(HTMLElement)花銷很大,而且操作完之後往往會引起瀏覽器對頁面的重繪和重排。如果頻繁的進行DOM操作,頁面效能會急劇下降。於是聰明的 Jser 決定使用簡單的 JS 物件格式來表示真實 DOM,也就是虛擬DOM。先執行對虛擬DOM的操作(這會執行的很快,因為是純 JS 操作),最後對比操作前後的新舊虛擬DOM樹,找出最小變更,一次性地應用到真實DOM上。雖然還是要對真實DOM操作,但次數卻大大減少,從而在更新檢視的同時可有效保證頁面效能。

Vue 的虛擬DOM系統是在開源虛擬DOM庫 Snabbdom 的基礎上做了適當的改進。

下面是 Vue 的 VNode 定義(正是一個個這樣的 VNode 組成了一棵虛擬DOM樹):

// vue/src/core/vdom/vnode.js
export default class VNode {
    constructor (tag, data, children, text, elm) {
        this.tag = tag
        this.data = data
        this.children = children
        this.text = text
        this.elm = elm  // 此欄位存放真實DOM
    }
}
複製程式碼

計算出新舊虛擬DOM之間的最小變更

在上一步執行 render 函式生成虛擬DOM後,接下來就需要對比新舊虛擬DOM之間的差異,從而獲得DOM的最小變更。比較兩棵DOM樹的差異是虛擬DOM庫最核心的部分,這也是所謂的 Virtual DOM 的 diff 演算法。就像版本控制系統 Git 的 diff 可以計算出兩次提交之間的變更,虛擬DOM的 diff 也可以計算出新舊虛擬DOM之間的差異。計算出來的差異稱為一個 patch,也就是補丁。

打補丁式更新頁面檢視

如果是首次渲染,也就是頁面剛載入進來第一次渲染,Vue 會用模板編譯後的DOM替換掉傳入的 el 元素。請注意這一點,對模板內DOM的操作(繫結事件,引用DOM等)應該始終放在 Vue 的 mounted 之後,否則所有處理都將丟失,因為模板會被替換掉。

如果是後續資料發生變化,Vue 就會用打補丁的方式更新檢視,儘可能重用現有DOM,將真實的DOM操作減到最少。

結論

在上面【觀察者】 Watcher 的定義中 update 方法裡執行檢視更新。因此 Vue 執行時的整個工作流程基本上是這樣的:

使用者呼叫 new Vue(options) 例項化 Vue,Vue 在 _init 方法中初始化相關欄位和事件,最重要的,建立起響應式系統,Vue 例項的後續執行重度依賴於此響應式系統。Vue 會新建一個【觀察者】,該觀察者在建立時會執行 update 方法首次渲染檢視,包含 Vue 指令的模板會被替換成編譯後的樸素 HTML。Vue 會遍歷傳入的 data 選項,通過 Object.defineProperty 設定 settergetter 將其變成【被觀察物件】。當 data 的資料發生變化時,被觀察物件就會通知觀察者,觀察者就會再次呼叫 update 方法打補丁式地更新檢視。

本篇完,將在下一篇中開始深究執行時實現細節。

大白話 Vue 原始碼系列目錄

本系列會以每週一篇的速度持續更新,喜歡的小夥伴記得點關注哦

相關文章