概況
最近對一個基於 Vue 項目的 Sentry Issue 進行治理時,發現了大量 Issue 都是 Vue 內部邏輯引起的,為了更好地去解決問題,因此也複習了一遍 Vue2 的原理。
相比起 Vue3 更清晰的項目結構和實現,Vue2 中各個部分的實現存在較多的耦合,也導致其邏輯梳理起來較為複雜。其中「響應式」的部分是最為複雜也是最重要的一環,實際項目中大部分的 Issue 也與其相關,如 Vue2 官網中所述的那樣:
“Vue 最獨特的特性之一,是其非侵入性的響應式系統。數據模型僅僅是普通的 JavaScript 對象。而當你修改它們時,視圖會進行更新。這使得狀態管理非常簡單直接,不過理解其工作原理同樣重要,這樣你可以避開一些常見的問題”。
在系統地梳理「響應式」工作原理的過程中,也參考了不少現有的文章,大部分都是圍繞“依賴收集”、“派發更新”或者“Watcher”,“Dep”這些響應式相關的概念邏輯展開講述,當然這些概念和邏輯是必不可少的要展開講述的內容,但是如果單純圍繞這些內容展開來編寫一篇文章,對於理解「響應式」在整個 Vue 中的工作過程可能會感到困惑。因此,本文會換一個角度,從 Vue 使用的過程展開説明「響應式」的工作原理,即從「實例化」、「渲染」、「數據更新」三條線講述「響應式」的工作過程,分別對應的是如何定義響應式數據、如何觸發響應式邏輯執行,以及如何觸發響應式數據更新。
在介紹了「響應式」的工作原理之後,也會基於工作原理解決一些常見的數據更新相關的問題。
從實例化到渲染
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
new Vue({
render: h => h(App),
}).$mount('#app')
以上是一段大家應該都很熟悉的代碼,即 Vue Cli 創建的示例項目實例化 Vue 的代碼,雖然是實例化代碼,但實際上這裏做了兩件事:
- new Vue,即創建了一個 Vue 實例。
- 調用實例的 $mount 方法,即掛載 Vue 的渲染結果到
#app這個節點上。
這裏是 Vue 中兩條重要的工作線,接下來看看在 Vue 內部這兩個操作具體做了什麼,當然會着重於「響應式」相關部分。
實例化過程
// 精簡了非 production 的邏輯
function Vue (options) {
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue
首先是定義了 Vue 的構造函數,構造函數內會調用 _init 方法,定義構造函數後會調用 initMixin,stateMixin 等方法,其中 initMixin 內會定義構造函數內的 _init 方法,因此先關注一下 initMixin。
// 精簡了非 production 邏輯
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
vm._uid = uid++
vm._isVue = true
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
initMixin 方法內部會給 Vue 的原型擴展大量方法,其中初始的就是 _init 方法,包括生命週期、渲染函數(把模板構造成 render 函數,render 函數負責輸出虛擬節點)、data/props、調用 created hook 等,對數據進行響應式封裝的邏輯也是從這裏開始的。
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
initState 是負責處理 data 的核心,props、methods、computed、watch 這些常用的 Vue 的 options,也是在這裏進行處理,主要的處理內容包括做一些檢查,例如有名字衝突,比如比較常見的 warning:"Method xxx has already been defined as a prop.",就是在這個階段做的檢查,另外最重要的就是對數據進行響應式封裝,接下來會以最常用也是最直觀的 data 作為例子。
// 精簡了非 production 邏輯
function initData (vm: Component) {
let data = vm.$options.data
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
const keys = Object.keys(data)
const props = vm.$options.props
let i = keys.length
while (i--) {
const key = keys[i]
if (props && hasOwn(props, key)) {
} else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
}
observe(data, true /* asRootData */)
}
上圖是 initData 的主體邏輯,主要的作用是對 data 的內容進行格式檢查,比如必須是一個 isPlainObject(至於這是什麼後面會詳細説明),另外就是如上面提到的,進行名字校檢防止衝突,例如如果有 data 的 key 跟 props 衝突了,就會報那個大家應該都很熟悉的 warning:"The data property xxx is already declared as a prop. Use prop default value instead.",最後就是真正的響應式邏輯 observe 方法。
到這裏,實例化的主線已經梳理出來了,可以看到 new Vue 之後 Vue 的處理步驟,以及 data 這類 options 是如何走到數據響應式處理的。
調用 $mount,掛載實例
在 Vue 的示例中,實例化之後會調用 $mount 把渲染出來的 DOM 掛載到頁面上,$mount 實際上是觸發渲染的入口。
// 精簡了非 production 邏輯
export function mountComponent (vm: Component, el: ?Element, hydrating?: boolean): Component {
vm.$el = el
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
}
callHook(vm, 'beforeMount')
let updateComponent
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
$mount 首先會調用 mountComponent 方法,這是渲染的核心主線邏輯,按順序分別做了以下的事情:
- 判斷是否有傳入
render方法,render方法是把 Vue 模板轉換成 VNode 的方法,在 Vue 內部,如果 new Vue 時有render會優先使用,上面 new Vue 的示例就傳入了render方法,也是大家比較熟悉的把 App.vue 傳入的邏輯。如果沒有傳入render則會把render賦值成創建一個空 VNode 節點的方法。 - 調用
beforeMount的鈎子。 - 定義好
updateComponent方法,該方法負責執行實例的渲染和更新,內部會調用 Vue 實例的_update,而_update則傳入了render的調用結果,即計算好的 VNode。_update方法的內最重要的就是調用了patch,即把 VNode 轉換成真實 DOM 的方法,轉換過程跟「響應式」關聯不大,因此這裏不針對patch展開太多。 - 創建一個
Watcher實例,傳入當前 Vue 的實例vm,updateComponent,還有一些 options,例如before參數。 - 調用
mounted鈎子。
在梳理了 $mount 的過程後,可以梳理出一個清晰的 Vue 實例渲染主線,調用 new Vue 實例化 Vue,然後把 data、props 等 options 進行校檢和「響應式」封裝,接着調用 $mount 開始進行渲染,首先創建一個 Watcher 對象跟 Vue 實例關聯起來,並通過傳入 updateComponent 方法維護實例的渲染和更新,render 作為 updateComponent,負責把模板轉換成虛擬節點 VNode,後面的 patch 方法則把 VNode 轉換成真實 DOM,最後掛載到頁面上。而在這個過程中,實例化時定義好響應式數據,渲染時調用響應式數據的更新邏輯,最終實現整個更新邏輯。
訂閲者模式
在上面的整個更新邏輯中,核心的「響應式」邏輯,應用了訂閲者模式這種設計模式,在説明 Vue 具體是如何基於訂閲者模式實現「響應式」之前,先來介紹一下訂閲者模式。
什麼是訂閲者模式?
“一個目標對象管理所有相依於它的觀察者對象,並且在它本身的狀態改變時主動發出通知”。這是對訂閲者的簡單描述,在 JavaScript 中,訂閲者模式是最常用的模式之一,例如經常用到的 DOM 事件監聽也是一種訂閲者模式,比如:
document.body.addEventListener('click', () => {
console.log('clicked1');
});
document.body.addEventListener('click', () => {
console.log('clicked2');
});
body 作為觀察目標,訂閲了 click 事件,當 body 被點擊時就會向訂閲者發出通知,訂閲者依次輸出 clicked1 和 clicked2,完成了一個訂閲 - 通知 - 響應的過程。
訂閲者模式的基礎實現
根據上面的例子可以總結出訂閲者模式的基礎特徵:
- 一個觀察目標對象通常會有觀察者管理類,包括了添加、刪除、通知觀察者更新三個主要操作。
- 一個或多個觀察者,接收觀察目標的通知並作出處理。
也就是説,觀察目標類,觀察者管理類,觀察者是訂閲者模式中的三個基本要素。基於以上特徵,這裏實現了一個簡單的訂閲者模式示例,其中觀察者集合類 ObserverList 作為一個工具類用於管理觀察者,觀察者目標類 Subject 調用 ObserverList 進行實際的觀察者(Observer)管理,以及在需要時發送更新通知給觀察者,示例中的更新通知是更新隨機數,觀察者接受通知把最新的隨機數輸出。
到這裏,Vue 實例化和渲染的基本邏輯已經梳理出來,下一篇文章會詳細説明 Vue「響應式」的具體實現。