七、vue計算屬性
細節流程圖
初始化
計算屬性的初始化是發生在 Vue 例項初始化階段的 initState 函式中,執行了 if (opts.computed) initComputed(vm, opts.computed),initComputed 的定義在 src/core/instance/state.js 中:
const computedWatcherOptions = { computed: true } function initComputed (vm: Component, computed: Object) { // $flow-disable-line const watchers = vm._computedWatchers = Object.create(null) // computed properties are just getters during SSR const isSSR = isServerRendering() for (const key in computed) { const userDef = computed[key] const getter = typeof userDef === 'function' ? userDef : userDef.get if (process.env.NODE_ENV !== 'production' && getter == null) { warn( `Getter is missing for computed property "${key}".`, vm ) } if (!isSSR) { // create internal watcher for the computed property. watchers[key] = new Watcher( vm, getter || noop, noop, computedWatcherOptions ) } // component-defined computed properties are already defined on the // component prototype. We only need to define computed properties defined // at instantiation here. if (!(key in vm)) { defineComputed(vm, key, userDef) } else if (process.env.NODE_ENV !== 'production') { if (key in vm.$data) { warn(`The computed property "${key}" is already defined in data.`, vm) } else if (vm.$options.props && key in vm.$options.props) { warn(`The computed property "${key}" is already defined as a prop.`, vm) } } } }
函式首先建立 vm._computedWatchers 為一個空物件,接著對 computed 物件做遍歷,拿到計算屬性的每一個 userDef,然後嘗試獲取這個 userDef 對應的 getter 函式,拿不到則在開發環境下報警告。接下來為每一個 getter 建立一個 watcher,這個 watcher 和渲染 watcher 有一點很大的不同,它是一個 computed watcher,因為 const computedWatcherOptions = { computed: true }。computed watcher 和普通 watcher 的差別我稍後會介紹。最後對判斷如果 key 不是 vm 的屬性,則呼叫 defineComputed(vm, key, userDef),否則判斷計算屬性對於的 key 是否已經被 data 或者 prop 所佔用,如果是的話則在開發環境報相應的警告。
接下來需要重點關注 defineComputed 的實現:
export function defineComputed ( target: any, key: string, userDef: Object | Function ) { const shouldCache = !isServerRendering() if (typeof userDef === 'function') { sharedPropertyDefinition.get = shouldCache ? createComputedGetter(key) : userDef sharedPropertyDefinition.set = noop } else { sharedPropertyDefinition.get = userDef.get ? shouldCache && userDef.cache !== false ? createComputedGetter(key) : userDef.get : noop sharedPropertyDefinition.set = userDef.set ? userDef.set : noop } if (process.env.NODE_ENV !== 'production' && sharedPropertyDefinition.set === noop) { sharedPropertyDefinition.set = function () { warn( `Computed property "${key}" was assigned to but it has no setter.`, this ) } } Object.defineProperty(target, key, sharedPropertyDefinition) }
這段邏輯很簡單,其實就是利用 Object.defineProperty 給計算屬性對應的 key 值新增 getter 和 setter,setter 通常是計算屬性是一個物件,並且擁有 set 方法的時候才有,否則是一個空函式。在平時的開發場景中,計算屬性有 setter 的情況比較少,我們重點關注一下 getter 部分,快取的配置也先忽略,最終 getter 對應的是 createComputedGetter(key) 的返回值,來看一下它的定義:
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
watcher.depend()
return watcher.evaluate()
}
}
}
createComputedGetter 返回一個函式 computedGetter,它就是計算屬性對應的 getter。
整個計算屬性的初始化過程到此結束,我們知道計算屬性是一個 computed watcher,它和普通的 watcher 有什麼區別呢,為了更加直觀,接下來來我們來通過一個例子來分析 computed watcher 的實現。
例子
以上關於計算屬性相關初始化工作已經完成了,初始化計算屬性的過程中主要建立了計算屬性觀察者以及將計算屬性定義到元件例項物件上,接下來我們將通過一些例子來分析計算屬性是如何實現的,假設我們有如下程式碼:
data () {
return {
a: 1
}
},
computed: {
compA () {
return this.a + 1
}
}
如上程式碼中,我們定義了本地資料 data,它擁有一個響應式的屬性 a,我們還定義了計算屬性 compA,它的值將依據 a 的值來計算求得。另外我們假設有如下模板:
<div>{{compA}}</div>
模板中我們使用到了計算屬性,我們知道模板會被編譯成渲染函式,渲染函式的執行將觸發計算屬性 compA 的 get 攔截器函式,那麼 compA 的攔截器函式是什麼呢?就是我們前面分析的 sharedPropertyDefinition.get 函式,我們知道在非服務端渲染的情況下,這個函式為:
sharedPropertyDefinition.get = function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
watcher.depend()
return watcher.evaluate()
}
}
也就是說當 compA 屬性被讀取時,computedGetter 函式將會執行,在 computedGetter 函式內部,首先定義了 watcher 常量,它的值為計算屬性 compA 的觀察者物件,緊接著如果該觀察者物件存在,則會分別執行觀察者物件的 depend 方法和 evaluate 方法。
我們首先找到 Watcher 類的 depend 方法,如下:
depend () {
if (this.dep && Dep.target) {
this.dep.depend()
}
}
depend 方法的內容很簡單,檢查 this.dep 和 Dep.target 是否全部有值,如果都有值的情況下便會執行 this.dep.depend 方法。這裡我們首先要知道 this.dep 屬性是什麼,實際上計算屬性的觀察者與其他觀察者物件不同,不同之處首先會體現在建立觀察者例項物件的時候,如下是 Watcher 類的 constructor 方法中的一段程式碼:
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
// 省略...
> if (this.computed) {
> this.value = undefined
> this.dep = new Dep()
> } else {
this.value = this.get()
}
}
如上高亮程式碼所示,當建立計算屬性觀察者物件時,由於第四個選項引數中 options.computed 為真,所以計算屬性觀察者物件的 this.computed 屬性的值也會為真,所以對於計算屬性的觀察者來講,在建立時會執行 if 條件分支內的程式碼,而對於其他觀察者物件則會執行 else 分支內的程式碼。同時我們能夠看到在 else 分支內直接呼叫 this.get() 方法求值,而 if 分支內並沒有呼叫 this.get() 方法求值,而是定義了 this.dep 屬性,它的值是一個新建立的 Dep 例項物件。這說明計算屬性的觀察者是一個惰性求值的觀察者。
現在我們再回到 Watcher 類的 depend 方法中:
depend () {
if (this.dep && Dep.target) {
this.dep.depend()
}
}
此時我們已經知道了 this.dep 屬性是一個 Dep 例項物件,所以 this.dep.depend() 這句程式碼的作用就是用來收集依賴。那麼它收集到的東西是什麼呢?這就要看 Dep.target 屬性的值是什麼了,我們回想一下整個過程:首先渲染函式的執行會讀取計算屬性 compA 的值,從而觸發計算屬性 compA 的 get 攔截器函式,最終呼叫了 this.dep.depend() 方法收集依賴。這個過程中的關鍵一步就是渲染函式的執行,我們知道在渲染函式執行之前 Dep.target 的值必然是 渲染函式的觀察者物件。所以計算屬性觀察者物件的 this.dep 屬性中所收集的就是渲染函式的觀察者物件。
記得此時計算屬性觀察者物件的 this.dep 中所收集的是渲染函式觀察者物件,假設我們把渲染函式觀察者物件稱為 renderWatcher,那麼:
this.dep.subs = [renderWatcher]
這樣 computedGetter 函式中的 watcher.depend() 語句我們就講解完了,但 computedGetter 函式還沒執行完,接下來要執行的是 watcher.evaluate() 語句:
sharedPropertyDefinition.get = function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
watcher.depend()
return watcher.evaluate()
}
}
我們找到 Watcher 類的 evaluate 方法看看它做了哪些事情,如下:
evaluate () {
if (this.dirty) {
this.value = this.get()
this.dirty = false
}
return this.value
}
我們知道計算屬性的觀察者是惰性求值,所以在建立計算屬性觀察者時除了 watcher.computed 屬性為 true 之外,watcher.dirty 屬性的值也為 true,代表著當前觀察者物件沒有被求值,而 evaluate 方法的作用就是用來手動求值的。可以看到在 evaluate 方法內部對 this.dirty 屬性做了真假判斷,如果為真則呼叫觀察者物件的 this.get 方法求值,同時將this.dirty 屬性重置為 false。最後將求得的值返回:return this.value。
這段程式碼的關鍵在於求值的這句程式碼,如下高亮部分所示:
evaluate () {
if (this.dirty) {
> this.value = this.get()
this.dirty = false
}
return this.value
}
我們在計算屬性的初始化一節中講過了,在建立計算屬性觀察者物件時傳遞給 Watcher 類的第二個引數為 getter 常量,它的值就是開發者在定義計算屬性時的函式(或 userDef.get),如下高亮程式碼所示:
function initComputed (vm: Component, computed: Object) {
// 省略...
for (const key in computed) {
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get
// 省略...
if (!isSSR) {
// create internal watcher for the computed property.
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
}
// 省略...
}
}
所以在 evaluate 方法中求值的那句程式碼最終所執行的求值函式就是使用者定義的計算屬性的 get 函式。舉個例子,假設我們這樣定義計算屬性:
computed: {
compA () {
return this.a +1
}
}
那麼對於計算屬性 compA 來講,執行其計算屬性觀察者物件的 wather.evaluate 方法求值時,本質上就是執行如下函式進行求值:
compA () {
return this.a +1
}
大家想一想這個函式的執行會發生什麼事情?我們知道資料物件的 a 屬性是響應式的,所以如上函式的執行將會觸發屬性 a 的 get 攔截器函式。所以這會導致屬性 a 將會收集到一個依賴,這個依賴實際上就是計算屬性的觀察者物件。
現在思路大概明朗了,如果計算屬性 compA 依賴了資料物件的 a 屬性,那麼屬性 a 將收集計算屬性 compA 的 計算屬性觀察者物件,而 計算屬性觀察者物件 將收集 渲染函式觀察者物件,整個路線是這樣的:
假如此時我們修改響應式屬性 a 的值,那麼將觸發屬性 a 所收集的所有依賴,這其中包括計算屬性的觀察者。我們知道觸發某個響應式屬性的依賴實際上就是執行該屬性所收集到的所有觀察者的 update 方法,現在我們就找到 Watcher 類的 update 方法,如下:
update () {
/* istanbul ignore else */
if (this.computed) {
// A computed property watcher has two modes: lazy and activated.
// It initializes as lazy by default, and only becomes activated when
// it is depended on by at least one subscriber, which is typically
// another computed property or a component's render function.
if (this.dep.subs.length === 0) {
// In lazy mode, we don't want to perform computations until necessary,
// so we simply mark the watcher as dirty. The actual computation is
// performed just-in-time in this.evaluate() when the computed property
// is accessed.
this.dirty = true
} else {
// In activated mode, we want to proactively perform the computation
// but only notify our subscribers when the value has indeed changed.
this.getAndInvoke(() => {
this.dep.notify()
})
}
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
如上高亮程式碼所示,由於響應式資料收集到了計算屬性觀察者物件,所以當計算屬性觀察者物件的 update 方法被執行時,如上 if 語句塊的程式碼將被執行,因為 this.computed 屬性為真。接著檢查了 this.dep.subs.length === 0 的真假,我們知道既然是計算屬性的觀察者,那麼 this.dep 中將收集渲染函式作為依賴(或其他觀察該計算屬性變化的觀察者物件作為依賴),所以當依賴的數量不為 0 時,在 else 語句塊內會呼叫 this.dep.notify() 方法繼續觸發響應,這會導致 this.dep.subs 屬性中收集到的所有觀察者物件的更新,如果此時 this.dep.subs 中包含渲染函式的觀察者,那麼這就會導致重新渲染,最終完成檢視的更新。
以上就是計算屬性的實現思路,本質上計算屬性觀察者物件就是一個橋樑,它搭建在響應式資料與渲染函式觀察者中間,另外大家注意上面的程式碼中並非直接呼叫 this.dep.notify() 方法觸發響應,而是將這個方法作為 this.getAndInvoke 方法的回撥去執行的,為什麼這麼做呢?那是因為 this.getAndInvoke 方法會重新求值並對比新舊值是否相同,如果滿足相同條件則不會觸發響應,只有當值確實變化時才會觸發響應,這就是文件中的描述,現在你明白了吧:
通過以上的分析,我們知道計算屬性本質上就是一個 computed watcher,也瞭解了它的建立過程和被訪問觸發 getter 以及依賴更新的過程,其實這是最新的計算屬性的實現,之所以這麼設計是因為 Vue 想確保不僅僅是計算屬性依賴的值發生變化,而是當計算屬性最終計算的值發生變化才會觸發渲染 watcher 重新渲染,本質上是一種優化。