好的,昨天在 passive 那部分耽擱了不少時間在研究其目的,沒有研究到 MVVM 與資料雙向繫結的這個重頭戲,那麼今天就來挑戰這個魔王吧。
在研究這部分是如何利用 Object.defineProperty()
前,我先簡單用文字做個概念介紹吧。這邊預設讀者已經知道 MVVM,也就是 Model-View-ViewModel 的架構模式了,Vue.js 這部分用了 Observer、Compiler、Watcher 三者去建置這個架構。
Observer 作為 Model 這個角色,在實際的程式碼中,就是 vm.$data
這個角色,也就是我們在建立 Vue instance,在 data
給予的資料物件。但是一個資料物件資料上的變動是要怎麼被發現呢?相信一路看這系列文的朋友馬上就能想到,就是利用 Object.defineProperty()
的 getter 和 setter。而資料物件可能不會只有一層,所以在設定屬性時,我們還要一層層深入,為每一個屬性都透過 Object.defineProperty()
定義讓我們有辦法觀察到屬性質的變動,Observer 是不是人如其名就像是一個觀察者呢w。
既然我們要在觀察到變動時去通知需要知道變動的程式,那就是在 setter 動手腳。而一個屬性可能不只一段程式會想知道他的變動,所以我們需要透過某種方式去記錄有哪些地方會想知道這個屬性的變動,讓他們訂閱變動通知,就讓我們將他們稱為訂閱者吧。
另一個問題來了,我們要怎麼知道這些訂閱者是想訂閱哪些屬性的變動呢?既然這些屬性是透過 getter 獲取這個屬性的值,那我們就可以透過 getter 將他們加入這個屬性的訂閱清單裡,並在觸發 setter、也就是變動時,通知這份清單的訂閱者。
解決了訂閱和通知的問題後,先讓我們來看看會需要知道屬性變化的最末端——也就是 View 的部分。
在此之前,也先簡介 Vue.js 的基本概念:由於直接對 DOM 操作是比較耗成本的,所以 View 是建立了一層虛擬的 DOM,也就是 Virtual DOM 作關聯對照,Virtual DOM 是 JS 實作的,所以對他操作成本遠低於直接對 DOM 操作,所以任何要對 View 做的變動,都會改對 Virtual DOM 操作,最後再比對 Virtual DOM 與 DOM 的差異,針對差異的部分進行更新。採用 Virtual DOM 也有解偶 HTML 的相依,得以渲染到 DOM 外的考量,這邊不是本文重點,就提過,不再詳述。
在 View 層,會將作為根元素 el 與其下面的子元素透過 compiler 進行解析,然後解析出 interpolations 和 directives,並根據種類不同制定各自的更新方法。然後,再從裡面去解析出會用到哪些屬性(Properties),並為他們各建立一個收到屬性變動通知時,會呼叫他們更新方法的角色,讓他們的更新方法去重新渲染那部份的 View,顯然訂閱者就是這個角色。
乍聽之下整個流程串起來了。解析 View 層裡有關 interpolations 和 directives 的部分,然後為他們各建立對應的訂閱者,這些訂閱者在解析裡面會用到屬性後,會透過一個全域變數將自己暫存進去,然後再向資料屬性讀取資料,也就是該屬性的 getter,如此一來就算是被加入了該屬性的訂閱者清單,之後只要 setter 被觸發,就會讓那個訂閱者清單通知所有的訂閱者,再讓訂閱者呼叫對應 interpolations 或 directives 的更新方法,最後成功因為屬性變動重新渲染那部份。
這邊嘗試不要用太多額外名詞去描述,但在講完後為了方便讀者與程式碼對照,還是簡單列個簡單的對應關係。
在前面研究原理的部分,花費了不少時間,那就讓我們最後看相關原始碼做個收尾吧。
// src/core/observer/index.js
import Dep from './dep'
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
// src/core/instance/state.js
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
}
export function proxy (target: Object, sourceKey: string, key: string) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
}
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
// src/core/instance/state.js
const dataDef = {}
dataDef.get = function () { return this._data }
const propsDef = {}
propsDef.get = function () { return this._props }
if (process.env.NODE_ENV !== 'production') {
dataDef.set = function (newData: Object) {
warn(
'Avoid replacing instance root $data. ' +
'Use nested data properties instead.',
this
)
}
propsDef.set = function () {
warn(`$props is readonly.`, this)
}
}
Object.defineProperty(Vue.prototype, '$data', dataDef)
Object.defineProperty(Vue.prototype, '$props', propsDef)