編者按:我們會不時邀請工程師談談有意思的技術細節,希望知其所以然能讓大家在面試有更出色表現。
也給面試官提供更多思路。
雖然目前的技術棧已由 Vue 轉到了 React,但從之前使用 Vue 開發的多個專案實際經歷來看還是非常愉悅的,Vue 文件清晰規範,api 設計簡潔高效,對前端開發人員友好,上手快,甚至個人認為在很多場景使用 Vue 比 React 開發效率更高,之前也有斷斷續續研讀過 Vue 的原始碼,但一直沒有梳理總結,所以在此做一些技術歸納同時也加深自己對 Vue 的理解,那麼今天要寫的便是 Vue 中最常用到的 API 之一 computed
的實現原理。
基本介紹
話不多說,一個最基本的例子如下:
<div id="app">
<p>{{fullName}}</p>
</div>
複製程式碼
new Vue({
data: {
firstName: 'Xiao',
lastName: 'Ming'
},
computed: {
fullName: function () {
return this.firstName + ' ' + this.lastName
}
}
})
複製程式碼
Vue 中我們不需要在 template 裡面直接計算 {{this.firstName + ' ' + this.lastName}}
,因為在模版中放入太多宣告式的邏輯會讓模板本身過重,尤其當在頁面中使用大量複雜的邏輯表示式處理資料時,會對頁面的可維護性造成很大的影響,而 computed
的設計初衷也正是用於解決此類問題。
對比偵聽器 watch
當然很多時候我們使用 computed
時往往會與 Vue 中另一個 API 也就是偵聽器 watch
相比較,因為在某些方面它們是一致的,都是以 Vue 的依賴追蹤機制為基礎,當某個依賴資料發生變化時,所有依賴這個資料的相關資料或函式都會自動發生變化或呼叫。
雖然計算屬性在大多數情況下更合適,但有時也需要一個自定義的偵聽器。這就是為什麼 Vue 通過
watch
選項提供了一個更通用的方法來響應資料的變化。當需要在資料變化時執行非同步或開銷較大的操作時,這個方式是最有用的。
從 Vue 官方文件對 watch
的解釋我們可以瞭解到,使用 watch
選項允許我們執行非同步操作(訪問一個 API)或高消耗效能的操作,限制我們執行該操作的頻率,並在我們得到最終結果前,設定中間狀態,而這些都是計算屬性無法做到的。
下面還另外總結了幾點關於 computed
和 watch
的差異:
computed
是計算一個新的屬性,並將該屬性掛載到 vm(Vue 例項)上,而watch
是監聽已經存在且已掛載到vm
上的資料,所以用watch
同樣可以監聽computed
計算屬性的變化(其它還有data
、props
)computed
本質是一個惰性求值的觀察者,具有快取性,只有當依賴變化後,第一次訪問computed
屬性,才會計算新的值,而watch
則是當資料發生變化便會呼叫執行函式- 從使用場景上說,
computed
適用一個資料被多個資料影響,而watch
適用一個資料影響多個資料;
以上我們瞭解了 computed
和 watch
之間的一些差異和使用場景的區別,當然某些時候兩者並沒有那麼明確嚴格的限制,最後還是要具體到不同的業務進行分析。
原理分析
言歸正傳,回到文章的主題 computed
身上,為了更深層次地瞭解計算屬性的內在機制,接下來就讓我們一步步探索 Vue 原始碼中關於它的實現原理吧。
在分析 computed
原始碼之前我們先得對 Vue 的響應式系統有一個基本的瞭解,Vue 稱其為非侵入性的響應式系統,資料模型僅僅是普通的 JavaScript 物件,而當你修改它們時,檢視便會進行自動更新。
當你把一個普通的 JavaScript 物件傳給 Vue 例項的
data
選項時,Vue 將遍歷此物件所有的屬性,並使用Object.defineProperty
把這些屬性全部轉為getter/setter
,這些getter/setter
對使用者來說是不可見的,但是在內部它們讓 Vue 追蹤依賴,在屬性被訪問和修改時通知變化,每個元件例項都有相應的watcher
例項物件,它會在元件渲染的過程中把屬性記錄為依賴,之後當依賴項的setter
被呼叫時,會通知watcher
重新計算,從而致使它關聯的元件得以更新。
Vue 響應系統,其核心有三點:observe
、watcher
、dep
:
observe
:遍歷data
中的屬性,使用 Object.defineProperty 的get/set
方法對其進行資料劫持;dep
:每個屬性擁有自己的訊息訂閱器dep
,用於存放所有訂閱了該屬性的觀察者物件;watcher
:觀察者(物件),通過dep
實現對響應屬性的監聽,監聽到結果後,主動觸發自己的回撥進行響應。
對響應式系統有一個初步瞭解後,我們再來分析計算屬性。
首先我們找到計算屬性的初始化是在 src/core/instance/state.js
檔案中的 initState
函式中完成的
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 */)
}
// computed初始化
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
複製程式碼
呼叫了 initComputed
函式(其前後也分別初始化了 initData
和 initWatch
)並傳入兩個引數 vm
例項和 opt.computed
開發者定義的 computed
選項,轉到 initComputed
函式:
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)
}
}
}
}
複製程式碼
從這段程式碼開始我們觀察這幾部分:
-
獲取計算屬性的定義
userDef
和getter
求值函式const userDef = computed[key] const getter = typeof userDef === 'function' ? userDef : userDef.get 複製程式碼
定義一個計算屬性有兩種寫法,一種是直接跟一個函式,另一種是新增
set
和get
方法的物件形式,所以這裡首先獲取計算屬性的定義userDef
,再根據userDef
的型別獲取相應的getter
求值函式。 -
計算屬性的觀察者
watcher
和訊息訂閱器dep
watchers[key] = new Watcher( vm, getter || noop, noop, computedWatcherOptions ) 複製程式碼
這裡的
watchers
也就是vm._computedWatchers
物件的引用,存放了每個計算屬性的觀察者watcher
例項(注:後文中提到的“計算屬性的觀察者”、“訂閱者”和watcher
均指代同一個意思但注意和Watcher
建構函式區分),Watcher
建構函式在例項化時傳入了 4 個引數:vm
例項、getter
求值函式、noop
空函式、computedWatcherOptions
常量物件(在這裡提供給Watcher
一個標識{computed:true}
項,表明這是一個計算屬性而不是非計算屬性的觀察者,我們來到Watcher
建構函式的定義:class Watcher { constructor ( vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object, isRenderWatcher?: boolean ) { if (options) { this.computed = !!options.computed } if (this.computed) { this.value = undefined this.dep = new Dep() } else { this.value = this.get() } } get () { pushTarget(this) let value const vm = this.vm try { value = this.getter.call(vm, vm) } catch (e) { } finally { popTarget() } return value } update () { if (this.computed) { if (this.dep.subs.length === 0) { this.dirty = true } else { this.getAndInvoke(() => { this.dep.notify() }) } } else if (this.sync) { this.run() } else { queueWatcher(this) } } evaluate () { if (this.dirty) { this.value = this.get() this.dirty = false } return this.value } depend () { if (this.dep && Dep.target) { this.dep.depend() } } } 複製程式碼
為了簡潔突出重點,這裡我手動去掉了我們暫時不需要關心的程式碼片段。 觀察
Watcher
的constructor
,結合剛才講到的new Watcher
傳入的第四個引數{computed:true}
知道,對於計算屬性而言watcher
會執行if
條件成立的程式碼this.dep = new Dep()
,而dep
也就是建立了該屬性的訊息訂閱器。export default class Dep { static target: ?Watcher; subs: Array<Watcher>; constructor () { this.id = uid++ this.subs = [] } addSub (sub: Watcher) { this.subs.push(sub) } depend () { if (Dep.target) { Dep.target.addDep(this) } } notify () { const subs = this.subs.slice() for (let i = 0, l = subs.length; i < l; i++) { subs[i].update() } } } Dep.target = null 複製程式碼
Dep
同樣精簡了部分程式碼,我們觀察Watcher
和Dep
的關係,用一句話總結watcher
中例項化了dep
並向dep.subs
中新增了訂閱者,dep
通過notify
遍歷了dep.subs
通知每個watcher
更新。 -
defineComputed
定義計算屬性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) } } 複製程式碼
因為
computed
屬性是直接掛載到例項物件中的,所以在定義之前需要判斷物件中是否已經存在重名的屬性,defineComputed
傳入了三個引數:vm
例項、計算屬性的key
以及userDef
計算屬性的定義(物件或函式)。 然後繼續找到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
方法,其中傳入的第三個引數是屬性描述符sharedPropertyDefinition
,初始化為:const sharedPropertyDefinition = { enumerable: true, configurable: true, get: noop, set: noop } 複製程式碼
隨後根據
Object.defineProperty
前面的程式碼可以看到sharedPropertyDefinition
的get/set
方法在經過userDef
和shouldCache
等多重判斷後被重寫,當非服務端渲染時,sharedPropertyDefinition
的get
函式也就是createComputedGetter(key)
的結果,我們找到createComputedGetter
函式呼叫結果並最終改寫sharedPropertyDefinition
大致呈現如下:sharedPropertyDefinition = { enumerable: true, configurable: true, get: function computedGetter () { const watcher = this._computedWatchers && this._computedWatchers[key] if (watcher) { watcher.depend() return watcher.evaluate() } }, set: userDef.set || noop } 複製程式碼
當計算屬性被呼叫時便會執行
get
訪問函式,從而關聯上觀察者物件watcher
然後執行wather.depend()
收集依賴和watcher.evaluate()
計算求值。
分析完所有步驟,我們再來總結下整個流程:
- 當元件初始化的時候,
computed
和data
會分別建立各自的響應系統,Observer
遍歷data
中每個屬性設定get/set
資料攔截 - 初始化
computed
會呼叫initComputed
函式- 註冊一個
watcher
例項,並在內例項化一個Dep
訊息訂閱器用作後續收集依賴(比如渲染函式的watcher
或者其他觀察該計算屬性變化的watcher
) - 呼叫計算屬性時會觸發其
Object.defineProperty
的get
訪問器函式 - 呼叫
watcher.depend()
方法向自身的訊息訂閱器dep
的subs
中新增其他屬性的watcher
- 呼叫
watcher
的evaluate
方法(進而呼叫watcher
的get
方法)讓自身成為其他watcher
的訊息訂閱器的訂閱者,首先將watcher
賦給Dep.target
,然後執行getter
求值函式,當訪問求值函式裡面的屬性(比如來自data
、props
或其他computed
)時,會同樣觸發它們的get
訪問器函式從而將該計算屬性的watcher
新增到求值函式中屬性的watcher
的訊息訂閱器dep
中,當這些操作完成,最後關閉Dep.target
賦為null
並返回求值函式結果。
- 註冊一個
- 當某個屬性發生變化,觸發
set
攔截函式,然後呼叫自身訊息訂閱器dep
的notify
方法,遍歷當前dep
中儲存著所有訂閱者wathcer
的subs
陣列,並逐個呼叫watcher
的update
方法,完成響應更新。
文 / 亦然
一枚嚮往詩與遠方的 coder
編 / 熒聲
本文已由作者授權釋出,版權屬於創宇前端。歡迎註明出處轉載本文。本文連結:knownsec-fed.com/2018-09-12-…
想要訂閱更多來自知道創宇開發一線的分享,請搜尋關注我們的微信公眾號:創宇前端(KnownsecFED)。歡迎留言討論,我們會盡可能回覆。
感謝您的閱讀。