初看runtime源碼,如入迷宮,小模塊間跳來跳去,我是誰,我在哪,我為什麼要打開它;再看runtime,眉目初現,繪出調用棧,如坐時光機,骨架漸漸明晰。再再看,炳如觀火,代碼層次結構已瞭然於胸。Vue 運行時模塊主要是圍繞 Vue 實例的生命周期展開的,它涵蓋了 Vue 實例生命周期內所需要的全部... ...
閱讀目錄
研究 runtime
一邊 Vue
一邊源碼
初看 Vue 是 Vue
源碼是源碼
再看 Vue 不是 Vue
源碼不是源碼
再再看
Vue 是調用棧
源碼也是調用棧
—— By DOM哥
Vue 運行時這一塊是非常有意思的,不像 Vue 編譯器那麼枯燥,這裡面有大量的實用技巧和設計思想可以學習。使用過 Vue 的小伙伴應該對 Vue 【響應的數據綁定】(也叫雙向綁定)的印象非常深刻,在修改了數據之後,視圖就會實時得到相應更新,這無疑極大地減輕了開發者的負擔,使得開發人員可以專註於處理業務邏輯和操作數據,也就是聞名遐邇的【數據驅動開發】。至於操作 DOM 更新視圖這件苦臟累的活,Vue 已經幫你妥善處理完畢並且對你完全透明(意思是它就像空氣一樣你完全註意不到它,卻又深度依賴它,離不開它)。
Vue 運行時模塊主要是圍繞 Vue 實例的生命周期展開的,它涵蓋了 Vue 實例生命周期內所需要的全部設施,包括實例創建,響應的數據綁定,掛載到 DOM 節點以及數據變化時自動更新視圖等關鍵部分。本篇也將沿著 Vue 實例的生命周期路線,結合運行時關鍵實現偽代碼,一步步清晰地描繪出 Vue 運行時的空中鳥瞰圖。
Vue 實例的生命周期
本段的部分內容參考自 Vue 官網的生命周期描述。
就像每個人的生命周期有 幼年、童年、少年、青年、中年、老年,每個 Vue 實例的生命周期也有 beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、activated、deactivated、beforeDestroy、destroyed 等多個階段。
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 的生命周期圖示
你不需要立馬弄明白圖上所有的東西,不過隨著你的不斷學習和使用,它的參考價值會越來越高。
實例創建
眾所周知 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)
}
}
initEvents
和 initRender
函數主要用來初始化 Vue 實例的一些容器欄位,現在可暫時忽略它們。接下來重點來了,在 initState
函數中封裝了實現【響應的數據綁定】的關鍵代碼,雖然這不是 Vue 最流弊的部分,但卻是咱對 Vue 最好奇的地方,也是咱開始本源碼系列的最初動力。在 initState
之前和之後分別調用了 Vue 的生命周期鉤子函數 beforeCreate
和 created
,接下來看看 Vue 是如何實現響應的數據綁定的。
響應的數據綁定
響應的數據綁定並不是 Vue 獨創的,而是 MVVVM 模式理論的一部分,它是 View 層和 ViewModel 層的連接方式。如下圖所示:
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 對象每個屬性描述符的 setter
和 getter
里做文章,就能實時捕捉 JavaScript 對象的變化。
需要註意的是,Object.defineProperty()
是 JS 語言本身的一個 API 而不是 Vue 實現的,Object.defineProperty 是 ES5 中一個無法 shim 的特性,這也是為什麼 Vue 不支持 IE8 以及更低版本瀏覽器的原因。如果想支持 IE8 以及更低版本瀏覽器怎麼辦呢?那就只有放棄 Vue,選擇 Knockout。更好的解決方案就是直接讓 IE8 以及更 low 的家伙見鬼去吧。不過基本上不用擔心這個問題了,因為據最新瀏覽器使用調查報告,IE8 以及更低版本瀏覽器的市場份額已經微不足道,直接忽略不計就行了。
既然 JS 已經支持在對象屬性變化時添加自定義處理,Vue 需要做的事就是遍歷傳入的 data
選項,為 data
的每個屬性設置 setter
和 getter
。這就解決瞭如何得知【被觀察者】發生了變化這個問題。
接下來說說這兩者是如何收集對方的。【觀察者】和【被觀察者】就好比單身男和單身女,得有人安排相親才能建立起聯繫呵,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
設置 setter
和 getter
將其變成【被觀察對象】。當 data
的數據發生變化時,被觀察對象就會通知觀察者,觀察者就會再次調用 update
方法打補丁式地更新視圖。
本篇完,將在下一篇中開始深究運行時實現細節。
本系列會以每周一篇的速度持續更新,喜歡的小伙伴記得點關註哦