「Vue 設計與實現」響應系統原理(十)- watch 監聽器
「Vue.js 設計與實現」之讀書筆記與整理 - watch 監聽器
watch 的設計
接下來要來設計,在 vue.js 當中經常用到的 API 「watch」,watch 本質就是觀測一個響應式數據,變且傳入一個回調函數,當修改響應式數據的值時,會觸發該回調函式執行,用來起來像這樣:
watch(proxy, () => {
console.log('值變了')
})
// 修改響應式數據的值,會導致回調函數執行
proxy.age++
跟 computed 很像,有了 effectRegister + scheduler 就可以拿來追蹤響應式數據的變化,與在其更新時使用調度器。如:
// watch 函數接收兩個參數,source 是響應式數據,cb 是回調函數
function watch(source, cb) {
effectRegister(
// 觸發讀取操作,從而建立聯繫
() => proxy.age,
{
// 設置 scheduler,避免執行副作用函式
scheduler() {
// 當數據變化時,調用回調函數 cb
cb()
}
})
}
const data = { age: 1 }
const proxy = new Proxy(data, { /* ... */ })
watch(proxy, () => {
console.log('資料變更了')
})
proxy.foo++
利用 effectRegister 來追蹤 proxy.age 的變化,並使用 scheduler 避免副作用被執行,在 scheduler 內調用傳入的 cb 在 trigger 時回調想要執行的函式。
深度遞迴讀取響應數據
但這樣的問題是 watch 內「硬編碼」了 proxy.age 的屬性,為了讓 watch 函數有具有通用性,我們需要封裝一個通用的讀取操作:
function traversal(value, seen = new Set()) {
// 如果要讀取的數據是原始值,或者已經被讀取過了,那麼什麼都不做
if (typeof value !== 'object' || value === null || seen.has(value))
return
// 將數據添加到 seen 中,代表遍歷地讀取過了,避免循環引用引起的死循環
seen.add(value)
// 暫時不考慮陣列等其他結構
// 假設 value 就是一個物件,使用 for...in 讀取對象的每一個值,並遞迴的調用 traverse 進行處理
for (k in value)
traversal(value[k], seen)
return value
}
function watch(source, cb) {
effectRegister(() => traversal(source), {
scheduler() {
cb()
},
})
}
watch(proxy, () => {
console.log('資料變化了')
})
proxy.age++
在 watch 內部的 effect 中調用 traverse 函數進行遞迴的讀取操作,代替硬編碼的方式,這樣就能讀取一個物件上的任意屬性,從而當任意屬性發生變化時都能夠觸發回調函數執行。
watch getter
watch 函數除了可以監測響應式數據,還可以接收一個 getter 函數:
watch(
// getter 函數
() => proxy.age,
// 回調函數
() => {
console.log('proxy.age 的值變了')
}
)
傳遞給 watch 函數的第一個參數不再是一個響應式數據,而是一個 getter 函數。在 getter 函數內部,使用者可以指定該 watch 依賴哪些響應式數據,只有當這些數據變化時,才會觸發回調函數執行。
function watch(source, cb) {
let getter
// 如果 source 是函數,說明使用者傳遞的是 getter,所以直接把 source 賦值給 getter
if (typeof source === 'function')
getter = source
else
getter = () => traversal(source)
effectRegister(() => getter(), {
scheduler() {
cb()
},
})
}
watch(
() => proxy.age,
() => {
console.log('資料變化了')
}
)
proxy.age++
// 資料變化了
首先判斷 source 的型別,如果是函數型別,說明使用者直接傳遞了 getter 函數,這時直接使用使用者的 getter 函數。如果不是函數型別,那麼保留之前的做法,即調用 traverse 函數遞迴地讀取。
完整程式碼 - watch - 基本實現與遞迴 getter - stackblitz
新值與舊值
通常我們在使用 Vue.js 中的 watch 函數時,能夠在回調函數中得到變化前後的值:
watch(
() => proxy.age,
(newValue, oldValue) => {
console.log(newValue, oldValue) // 2, 1
}
)
proxy.age++
為了實現這個,我們可以透過 lazy 選項來,手動執行 effect 拿到 getter 回傳的值,並在每次 trigger 執行 scheduler 的時候,更新新值與舊值:
function watch(source, cb) {
let getter
if (typeof source === 'function')
getter = source
else getter = () => traversal(source)
// 定義舊值與新值
let oldValue, newValue
// 使用 effect 註冊副作用函數時,開啟 lazy 選項,並把返回值存儲到 effectFn 中以便後續手動調用
const effectFn = effectRegister(() => getter(), {
lazy: true,
scheduler() {
// 在 scheduler 中重新執行副作用函數,得到的是新值
newValue = effectFn()
// 將舊值和新值作為回調函數的參數
cb(newValue, oldValue)
// 更新舊值,不然下一次會得到錯誤的舊值
oldValue = newValue
},
})
// 手動調用副作用函數,拿到的值就是舊值
oldValue = effectFn()
}
watch(
() => proxy.age,
(newVal, oldVal) => {
console.log('資料變化了')
console.log('newVal: ', newVal)
console.log('oldVal: ', oldVal)
}
)
proxy.age++
// 資料變化了
// newVal: 2
// oldVal: 1
完整程式碼 - watch - 新值與舊值 - stackblitz
立即執行的 watch
預設情況下,一個 watch 的回調只會在響應式數據發生變化時才執行:
// 回調函數只有在響應式數據 proxy 後續發生變化時才執行
watch(proxy, () => {
console.log('變化了')
})
在 Vue.js 中可以透過 options.immediate 來指定回調是否要立即執行:
watch(obj, () => {
console.log('變化了')
}, {
// 回調函數會在 watch 創建時立即執行一次
immediate: true
})
要實現 immediate 非常簡單,響應式資料變更後執行的函式與立即執行函式,其實要執行的東西都是一樣的,只需要將 scheduler 原本做的事情,封裝成一個 job 函式,就可以拿來使用:
function watch(source, cb, options = {}) {
let getter
let oldValue, newValue
if (typeof source === 'function')
getter = source
else getter = () => traversal(source)
const effectFn = effectRegister(() => getter(), {
lazy: true,
// 需要把 scheduler 執行的內容,封裝成 job 函式
scheduler() {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
},
})
oldValue = effectFn()
}
function watch(source, cb, options = {}) {
let getter
let oldValue, newValue
if (typeof source === 'function')
getter = source
else getter = () => traversal(source)
// 封裝 scheduler 調度函數為一個獨立的 job 函數
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effectRegister(() => getter(), {
lazy: true,
scheduler: job,
})
// 當 immediate 為 true 時立即執行 job,從而觸發回調執行
if (options.immediate)
job()
else oldValue = effectFn()
}
如此一來便實現了 watch 的立即執行功能。
使用 flush 來指定 watch 回調的執行時機
在 Vue.js 3 中 watch 可以使用 flush 選項來指定執行回調的時機
watch(proxy, () => {
console.log('變化了')
}, {
// 回調函數會在 組件更新前 執行
flush: 'pre' // 還可以指定為 'post' | 'sync'
})
flush 可指定的三個執行時機:
- pre: 組件更新前執行 (預設)
- sync: 偵測到變更後立即執行
- post: 組件更新後執行,能訪問被 Vue 更新之後的 DOM
當 flush 的值為 post 時,代錶調度函數需要將副作用函式放到一個微任務隊列中,並等待 DOM 更新結束後再執行,如以下程式碼為例:
function watch(source, cb, options = {}) {
let getter
let oldValue, newValue
if (typeof source === 'function')
getter = source
else getter = () => traversal(source)
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effectRegister(() => getter(), {
lazy: true,
// 在調度函數中判斷 flush 是否為 'post',如果是,將其放到微任務隊列中執行
scheduler() {
if (options.flush === 'post') {
const p = Promise.resolve(
p.then(job)
)
}
else {
job()
}
}
})
if (options.immediate)
job()
else oldValue = effectFn()
}
在調度器函數內檢測 options.flush 的值是否為 post,如果是,則將 job 函數放到微任務隊列中,從而實現非同步延遲執行。