$watch
實際上無論是 $watch 方法還是 watch 選項,他們的實現都是基於 Watcher 的封裝。首先我們來看一下 $watch 方法,它定義在 src/core/instance/state.js 文件的 stateMixin 函數中,如下:
偵聽屬性的初始化也是發生在 Vue 的實例初始化階段的 initState 函數中,在 computed 初始化之后,執行了:
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
cb.call(vm, watcher.value)
}
return function unwatchFn () {
watcher.teardown()
}
}
$watch 方法允許我們觀察數據對象的某個屬性,當屬性變化時執行回調。所以 $watch 方法至少接收兩個參數,一個要觀察的屬性,以及一個回調函數。通過上面的代碼我們發現,$watch 方法接收三個參數,除了前面介紹的兩個參數之后還接收第三個參數,它是一個選項參數,比如是否立即執行回調或者是否深度觀測等。我們可以發現這三個參數與 Watcher 類的構造函數中的三個參數相匹配,如下:
export default class Watcher {
constructor (
vm: Component,
> expOrFn: string | Function,
> cb: Function,
> options?: ?Object,
isRenderWatcher?: boolean
) {
// 省略...
}
}
因為 $watch 方法的實現本質就是創建了一個 Watcher 實例對象。另外通過官方文檔的介紹可知 $watch 方法的第二個參數既可以是一個回調函數,也可以是一個純對象,這個對象中可以包含 handler 屬性,該屬性的值將作為回調函數,同時該對象還可以包含其他屬性作為選項參數,如 immediate 或 deep
假設傳遞給$watch 方法的第二個參數是一個函數,看看它是怎么實現的,在 $watch 方法內部首先執行的是如下代碼:
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
定義了 vm 常量,它是當前組件實例對象,接着檢測傳遞給 $watch 的第二個參數是否是純對象,由於我們現在假設參數 cb 是一個函數,所以這段 if 語句塊內的代碼不會執行。再往下是這段代碼:
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
首先如果沒有傳遞 options 選項參數,那么會給其一個默認的空對象,接着將 options.user 的值設置為 true,我們前面講到過這代表該觀察者實例是用戶創建的,然后就到了關鍵的一步,即創建 Watcher 實例對象,多么簡單的實現
再往下是一段 if 語句塊:
if (options.immediate) {
cb.call(vm, watcher.value)
}
immediate 選項用來在屬性或函數被偵聽后立即執行回調,如上代碼就是其實現原理,如果發現 options.immediate 選項為真,那么會執行回調函數,不過此時回調函數的參數只有新值沒有舊值。同時取值的方式是通過前面創建的觀察者實例對象的 watcher.value 屬性。我們知道觀察者實例對象的 value 屬性,保存着被觀察屬性的值。
最后 $watch 方法還有一個返回值,如下:
return function unwatchFn () {
watcher.teardown()
}
$watch 函數返回一個函數,這個函數的執行會解除當前觀察者對屬性的觀察。它的原理是通過調用觀察者實例對象的 watcher.teardown 函數實現的。我們可以看一下 watcher.teardown 函數是如何解除觀察者與屬性之間的關系的,如下是 teardown 函數的代碼:
export default class Watcher {
// 省略...
/**
* Remove self from all dependencies' subscriber list.
*/
teardown () {
if (this.active) {
// remove self from vm's watcher list
// this is a somewhat expensive operation so we skip it
// if the vm is being destroyed.
if (!this.vm._isBeingDestroyed) {
remove(this.vm._watchers, this)
}
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
}
this.active = false
}
}
}
首先檢查 this.active 屬性是否為真,如果為假則說明該觀察者已經不處於激活狀態,什么都不需要做,如果為真則會執行 if 語句塊內的代碼,在 if 語句塊內首先執行的這段代碼:
if (!this.vm._isBeingDestroyed) {
remove(this.vm._watchers, this)
}
每個組件實例都有一個 vm._isBeingDestroyed 屬性,它是一個標識,為真說明該組件實例已經被銷毀了,為假說明該組件還沒有被銷毀,所以以上代碼的意思是如果組件沒有被銷毀,那么將當前觀察者實例從組件實例對象的 vm._watchers 數組中移除,我們知道 vm._watchers 數組中包含了該組件所有的觀察者實例對象,所以將當前觀察者實例對象從 vm._watchers 數組中移除是解除屬性與觀察者實例對象之間關系的第一步。由於這個參數的性能開銷比較大,所以僅在組件沒有被銷毀的情況下才會執行此操作。
將觀察者實例對象從 vm._watchers 數組中移除之后,會執行如下這段代碼:
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
}
我們知道當一個屬性與一個觀察者建立聯系之后,屬性的 Dep 實例對象會收集到該觀察者對象,同時觀察者對象也會將該 Dep 實例對象收集,這是一個雙向的過程,並且一個觀察者可以同時觀察多個屬性,這些屬性的 Dep 實例對象都會被收集到該觀察者實例對象的 this.deps 數組中,所以解除屬性與觀察者之間關系的第二步就是將當前觀察者實例對象從所有的 Dep 實例對象中移除,實現方法就如上代碼所示。
最后會將當前觀察者實例對象的 active 屬性設置為 false,代表該觀察者對象已經處於非激活狀態了:
this.active = false
以上就是 $watch 方法的實現,以及如何解除觀察的實現。不過不要忘了我們前面所講的這些內容是假設傳遞給 $watch 方法的第二個參數是一個函數,如果不是函數呢?比如是一個純對象,這時如下高亮的代碼就會被執行:
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
> if (isPlainObject(cb)) {
> return createWatcher(vm, expOrFn, cb, options)
> }
// 省略...
}
當參數 cb 不是函數,而是一個純對象,則會調用 createWatcher 函數,並將參數透傳,注意還多傳遞給 createWatcher 函數一個參數,即組件實例對象 vm,那么 createWatcher 函數做了什么呢?createWatcher 函數也定義在 src/core/instance/state.js 文件中,如下是 createWatcher 函數的代碼:
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
if (typeof handler === 'string') {
handler = vm[handler]
}
return vm.$watch(expOrFn, handler, options)
}
其實 createWatcher 函數的作用就是將純對象形式的參數規范化一下,然后再通過 $watch 方法創建觀察者。可以看到 createWatcher 函數的最后一句代碼就是通過調用 $watch 函數並將其返回。來看 createWatcher 函數的第一段代碼:
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
因為 createWatcher 函數除了在 $watch 方法中使用之外,還會用於 watch 選項,而這時就需要對 handler 進行檢測。總之如果 handler 是一個純對象,那么就將變量 handler 的值賦給 options 變量,然后用 handler.handler 的值重寫 handler 變量的值。舉個例子,如下代碼所示:
vm.$watch('name', {
handler () {
console.log('change')
},
immediate: true
})
如果你像如上代碼那樣使用 $watch 方法,那么對於 createWatcher 函數來講,其 handler 參數為:
handler = {
handler () {
console.log('change')
},
immediate: true
}
所以如下這段代碼:
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
等價於:
if (isPlainObject(handler)) {
options = {
handler () {
console.log('change')
},
immediate: true
}
handler = handler () {
console.log('change')
}
}
這樣就可正常通過 $watch 方法創建觀察者了。另外我們注意 createWatcher 函數中如下這段高亮代碼:
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
> if (typeof handler === 'string') {
> handler = vm[handler]
> }
return vm.$watch(expOrFn, handler, options)
}
這段代碼說明 handler 除了可以是一個純對象還可以是一個字符串,當 handler 是一個字符串時,會讀取組件實例對象的 handler 屬性的值並用該值重寫 handler 的值。然后再通過調用 $watch 方法創建觀察者,這段代碼實現的目的是什么呢?看如下例子就明白了:
watch: {
name: 'handleNameChange'
},
methods: {
handleNameChange () {
console.log('name change')
}
}
上面的代碼中我們在 watch 選項中觀察了 name 屬性,但是我們沒有指定回調函數,而是指定了一個字符串 handleNameChange,這等價於指定了 methods 選項中同名函數作為回調函數。這就是如上 createWatcher 函數中那段高亮代碼的目的。
上例中我們使用了 watch 選項,接下來我們就順便來看一下 watch 選項是如何初始化的,找到 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 */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
調用 initWatch 函數,這個函數用來初始化 watch 選項,至於判斷條件我們就不多講了,前面的講解中我們已經講解過類似的判斷條件。至於 initWatch 函數,它就定義在 createWatcher 函數的上方
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
來看一下 initWatch 的實現,它的定義在 src/core/instance/state.js 中:
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
if (Array.isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
createWatcher(vm, key, handler)
}
}
}
可以看到 initWatch 函數就是通過對 watch 選項遍歷,然后通過 createWatcher 函數創建觀察者對象的,需要注意的是上面代碼中有一個判斷條件,如下高亮代碼所示:
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
if (Array.isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
createWatcher(vm, key, handler)
}
}
}
通過這個條件我們可以發現 handler 常量可以是一個數組,handler 常量是什么呢?它的值是 watch[key],也就是說我們在使用 watch 選項時可以通過傳遞數組來實現創建多個觀察者,如下:
watch: {
name: [
function () {
console.log('name 改變了1')
},
function () {
console.log('name 改變了2')
}
]
}
總的來說,在 Watcher 類的基礎上,無論是實現 $watch 方法還是實現 watch 選項,都變得非常容易,這得益於一個良好的設計。
深度觀測的實現
接下來我們將會討論深度觀測的實現,在這之前我們需要回顧一下數據響應的原理,我們知道響應式數據的關鍵在於數據的屬性是訪問器屬性,這使得我們能夠攔截對該屬性的讀寫操作,從而有機會收集依賴並觸發響應。思考如下代碼:
watch: {
a () {
console.log('a 改變了')
}
}
這段代碼使用 watch 選項觀測了數據對象的 a 屬性,我們知道 watch 方法內部是通過創建 Watcher 實例對象來實現觀測的,在創建 Watcher 實例對象時會讀取 a 的值從而觸發屬性 a 的 get 攔截器函數,最終將依賴收集。但問題是如果屬性 a 的值是一個對象
data () {
return {
a: {
b: 1
}
}
},
watch: {
a () {
console.log('a 改變了')
}
}
如上高亮代碼所示,數據對象 data 的屬性 a 是一個對象,當實例化 Watcher 對象並觀察屬性 a 時,會讀取屬性 a 的值,這樣的確能夠觸發屬性 a 的 get 攔截器函數,但由於沒有讀取 a.b 屬性的值,所以對於 b 來講是沒有收集到任何觀察者的。這就是我們常說的淺觀察,直接修改屬性 a 的值能夠觸發響應,而修改 a.b 的值是觸發不了響應的。
深度觀測就是用來解決這個問題的,深度觀測的原理很簡單,既然屬性 a.b 中沒有收集到觀察者,那么我們就主動讀取一下 a.b 的值,這樣不就能夠觸發屬性 a.b 的 get 攔截器函數從而收集到觀察者了嗎,其實 Vue 就是這么做的,只不過你需要將 deep 選項參數設置為 true,主動告訴 Watcher 實例對象你現在需要的是深度觀測。我們找到 Watcher 類的 get 方法,如下:
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
> if (this.deep) {
> traverse(value)
> }
popTarget()
this.cleanupDeps()
}
return value
}
如上高亮代碼所示,我們知道 Watcher 類的 get 方法用來求值,在 get 方法內部通過調用 this.getter 函數對被觀察的屬性求值,並將求得的值賦值給變量 value,同時我們可以看到在 finally 語句塊內,如果 this.deep 屬性的值為真說明是深度觀測,此時會將被觀測屬性的值 value 作為參數傳遞給 traverse 函數,其中 traverse 函數的作用就是遞歸地讀取被觀察屬性的所有子屬性的值,這樣被觀察屬性的所有子屬性都將會收集到觀察者,從而達到深度觀測的目的。
traverse 函數來自 src/core/observer/traverse.js 文件,如下:
const seenObjects = new Set()
/**
* Recursively traverse an object to evoke all converted
* getters, so that every nested property inside the object
* is collected as a "deep" dependency.
*/
export function traverse (val: any) {
_traverse(val, seenObjects)
seenObjects.clear()
}
上面的代碼中定義了 traverse 函數,這個函數將接收被觀察屬性的值作為參數,拿到這個參數后在 traverse 函數內部會調用 _traverse 函數完成遞歸遍歷。其中 _traverse 函數就定義在 traverse 函數的下方,如下是 _traverse 函數的簽名:
function _traverse (val: any, seen: SimpleSet) {
// 省略...
}
_traverse 函數接收兩個參數:
- 第一個參數是被觀察屬性的值
- 第二個參數是一個 Set 數據結構的實例,可以看到在 traverse 函數中調用 _traverse 函數時傳遞的第二個參數 seenObjects 就是一個 Set 數據結構的實例,它定義在文件頭部:const seenObjects = new Set()。
接下來我們看一下 _traverse 函數是如何遍歷訪問數據對象的,如下是 _traverse 函數的全部代碼:
function _traverse (val: any, seen: SimpleSet) {
let i, keys
const isA = Array.isArray(val)
if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
return
}
> if (val.__ob__) {
> const depId = val.__ob__.dep.id
> if (seen.has(depId)) {
> return
> }
> seen.add(depId)
> }
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen)
}
}
注意上面代碼中高亮的部分,現在我們把高亮的代碼刪除,那么 _traverse 函數將變成如下這個樣子:
function _traverse (val: any, seen: SimpleSet) {
let i, keys
const isA = Array.isArray(val)
if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
return
}
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen)
}
}
之所以要刪除這段代碼是為了降低復雜度,現在我們就當做刪除的那段代碼不存在,來看一下 _traverse 函數的實現,在 _traverse 函數的開頭聲明了兩個變量,分別是 i 和 keys,這兩個變量在后面會使用到,接着檢查參數 val 是不是數組,並將檢查結果存儲在常量 isA 中。再往下是一段 if 語句塊:
if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
return
}
遞歸的終止條件
這段代碼是對參數 val 的檢查,后面我們統一稱 val 為 被觀察屬性的值,我們知道既然是深度觀測,所以被觀察屬性的值要么是一個對象要么是一個數組,並且該值不能是凍結的,同時也不應該是 VNode 實例(這是Vue單獨做的限制)。只有當被觀察屬性的值滿足這些條件時,才會對其進行深度觀測,只要有一項不滿足 _traverse 就會 return 結束執行。所以上面這段 if 語句可以理解為是在檢測被觀察屬性的值能否進行深度觀測,一旦能夠深度觀測將會繼續執行之后的代碼,如下:
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen)
}
這段代碼將檢測被觀察屬性的值是數組還是對象,無論是數組還是對象都會通過 while 循環對其進行遍歷,並遞歸調用 _traverse 函數,這段代碼的關鍵在於遞歸調用 _traverse 函數時所傳遞的第一個參數:val[i] 和 val[keys[i]]。這兩個參數實際上是在讀取子屬性的值,這將觸發子屬性的 get 攔截器函數,保證子屬性能夠收集到觀察者,僅此而已。
現在 _traverse 函數的代碼我們就解析完了,但大家有沒有想過目前 _traverse 函數存在什么問題?別忘了前面我們刪除了一段代碼,如下:
if (val.__ob__) {
const depId = val.__ob__.dep.id
if (seen.has(depId)) {
return
}
seen.add(depId)
}
這段代碼的作用不容忽視,它解決了循環引用導致死循環的問題,為了更好地說明問題我們舉個例子,如下:
const obj1 = {}
const obj2 = {}
obj1.data = obj2
obj2.data = obj1
上面代碼中我們定義了兩個對象,分別是 obj1 和 obj2,並且 obj1.data 屬性引用了 obj2,而 obj2.data 屬性引用了 obj1,這是一個典型的循環引用,假如我們使用 obj1 或 obj2 這兩個對象中的任意一個對象出現在 Vue 的響應式數據中,如果不做防循環引用的處理,將會導致死循環,如下代碼:
function _traverse (val: any, seen: SimpleSet) {
let i, keys
const isA = Array.isArray(val)
if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
return
}
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen)
}
}
如果被觀察屬性的值 val 是一個循環引用的對象,那么上面的代碼將導致死循環,為了避免這種情況的發生,我們可以使用一個變量來存儲那些已經被遍歷過的對象,當再次遍歷該對象時程序會發現該對象已經被遍歷過了,這時會跳過遍歷,從而避免死循環,如下代碼所示:
function _traverse (val: any, seen: SimpleSet) {
let i, keys
const isA = Array.isArray(val)
if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
return
}
if (val.__ob__) {
const depId = val.__ob__.dep.id
if (seen.has(depId)) {
return
}
seen.add(depId)
}
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen)
}
}
如上高亮的代碼所示,這是一個 if 語句塊,用來判斷 val.ob 是否有值,我們知道如果一個響應式數據是對象或數組,那么它會包含一個叫做 ob 的屬性,這時我們讀取 val.ob.dep.id 作為一個唯一的ID值,並將它放到 seenObjects 中:seen.add(depId),這樣即使 val 是一個擁有循環引用的對象,當下一次遇到該對象時,我們能夠發現該對象已經遍歷過了:seen.has(depId),這樣函數直接 return 即可。
以上就是深度觀測的實現以及避免循環引用造成的死循環的解決方案。
同步執行觀察者
通常情況下當數據狀態發生改變時,所有 Watcher 都為異步執行,這么做的目的是出於對性能的考慮。但在某些場景下我們仍需要同步執行的觀察者,我們可以使用 sync 選項定義同步執行的觀察者,如下:
new Vue({
watch: {
someWatch: {
handler () {/* ... */},
sync: true
}
}
})
如上代碼所示,我們在定義一個觀察者時使用 sync 選項,並將其設置為 true,此時當數據狀態發生變化時該觀察者將以同步的方式執行。這么做當然沒有問題,因為我們僅僅定義了一個觀察者而已。
Vue 官方推出了 vue-test-utils 測試工具庫,這個庫的一個特點是,當你使用它去輔助測試 Vue 單文件組件時,數據變更將會以同步的方式觸發組件變更,這對於測試而言會提供很大幫助。大家思考一下 vue-test-utils 庫是如何實現這個功能的?我們知道開發者在開發組件的時候基本不太可能手動地指定一個觀察者為同步的,所以 vue-test-utils 庫需要有能力拿到組件的定義並人為地把組件中定義的所有觀察者都轉換為同步的,這是一個繁瑣並容易引起 bug 的工作,為了解決這個問題,Vue 提供了 Vue.config.async 全局配置,它的默認值為 true,我們可以在 src/core/config.js 文件中看到這樣一句代碼,如下:
export default ({
// 省略...
/**
* Perform updates asynchronously. Intended to be used by Vue Test Utils
* This will significantly reduce performance if set to false.
*/
async: true,
// 省略...
}: Config)
這個全局配置將決定 Vue 中的觀察者以何種方式執行,默認是異步執行的,當我們將其修改為 Vue.config.async = false 時,所有觀察者都將會同步執行。其實現方式很簡單,我們打開 src/core/observer/scheduler.js 文件,找到 queueWatcher 函數:
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
// 省略...
// queue the flush
if (!waiting) {
waiting = true
> if (process.env.NODE_ENV !== 'production' && !config.async) {
> flushSchedulerQueue()
> return
> }
nextTick(flushSchedulerQueue)
}
}
}
在非生產環境下如果 !config.async 為真,則說明開發者配置了 Vue.config.async = false,這意味着所有觀察者需要同步執行,所以只需要把原本通過 nextTick 包裝的 flushSchedulerQueue 函數單獨拿出來執行即可。另外通過如上高亮的代碼我們也能夠明白一件事兒,那就是 Vue.config.async 這個配置項只會在非生產環境生效。
為了實現同步執行的觀察者,除了把 flushSchedulerQueue 函數從 nextTick 中提取出來之外,還需要做一件事兒,我們打開 src/core/observer/dep.js 文件,找到 notify 方法,如下:
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice()
> if (process.env.NODE_ENV !== 'production' && !config.async) {
> // subs aren't sorted in scheduler if not running async
> // we need to sort them now to make sure they fire in correct
> // order
> subs.sort((a, b) => a.id - b.id)
> }
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
在異步執行觀察者的時候,當數據狀態方式改變時,會通過如上 notify 函數通知變化,從而執行所有觀察者的 update 方法,在 update 方法內會將所有即將被執行的觀察者都添加到觀察者隊列中,並在 flushSchedulerQueue 函數內對觀察者回調的執行順序進行排序。但是當同步執行的觀察者時,由於 flushSchedulerQueue 函數是立即執行的,它不會等待所有觀察者入隊之后再去執行,這就沒有辦法保證觀察者回調的正確更新順序,這時就需要如上高亮的代碼,其實現方式是在執行觀察者對象的 update 更新方法之前就對觀察者進行排序,從而保證正確的更新順序。