Vue3 Watch API 到底是怎麼實現的?
我正在參與掘金創作者訓練營第4期,點選瞭解活動詳情,一起學習吧!
前言
在之前的文章,我們已經介紹過 vue3 的響應式原理。如果還沒看過的同學,強烈建議先看看《六千字詳解!vue3 響應式是如何實現的?》,該文章用 vue3 ref 的例子,詳細地介紹了響應式原理的實現。
而這篇則是在響應式原理的基礎上,進一步介紹 Vue3 的另外一個 API —— watch
watch 用法
Vue3 的 watchApi 主要有兩類:watch 和 watchEffect。(watchPostEffect 和 watchSyncEffect 只是 watchEffect 的不同引數 flush 的別名)
watch 的用法
- 偵聽單一源
```typescript // 偵聽一個 getter 函式 const state = reactive({ count: 0 }) watch( () => state.count, (count, prevCount) => { / ... / } )
// 直接偵聽一個 ref const count = ref(0) watch(count, (count, prevCount) => { / ... / }) ```
- 偵聽多個源
js
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
/* ... */
})
watchEffect 用法
立即執行傳入的一個函式,同時響應式追蹤其依賴,並在其依賴變更時重新執行該函式。
```typescript const count = ref(0)
watchEffect(() => console.log(count.value)) // -> logs 0
setTimeout(() => { count.value++ // -> logs 1 }, 100) ```
watch 的測試用例
```typescript it('effect', async () => { const state = reactive({ count: 0 }) let dummy watchEffect(() => { dummy = state.count }) expect(dummy).toBe(0)
state.count++ // dummy 沒有立即被修改 expect(dummy).toBe(0) await nextTick() // nextTick 之後 dummy 才會被修改 expect(dummy).toBe(1) })
it('watching single source: getter', async () => { const state = reactive({ count: 0 }) let dummy watch( () => state.count, (count, prevCount) => { dummy = [count, prevCount] // assert types count + 1 if (prevCount) { prevCount + 1 } } ) state.count++ // dummy 沒有立即被賦值 expect(dummy).toBe(undefined) await nextTick() // nextTick 之後 dummy 才會被修改 expect(dummy).toMatchObject([1, 0]) }) ```
從上面測試用例中,我們可以看出,響應式變數被修改後,並不是馬上執行 watchEffect 和 watch 的回撥函式,而是在 nextTick 只有才執行完成。
為什麼會延遲執行 watch 回撥?
考慮以下程式碼:
typescript
it('watch 最終的值沒有變,則不執行 watch 回撥', async () => {
const state = reactive({ count: 0 })
let dummy = 0
watch(
() => state.count,
(count) => {
dummy++
}
)
state.count++
state.count--
// dummy 沒有立即被賦值
expect(dummy).toBe(0)
await nextTick()
// nextTick 之後 watch 回撥沒有被執行
expect(dummy).toBe(0)
})
最終 state.count 的值沒有變,沒有執行 watch 回撥(這個行為是 Vue watch API 所定義的),而不是執行兩遍 watch 回撥
- 要實現【watch 的最終值不變,則不執行 watch 回撥】的行為,就必須要延遲執行,就需要在當前的所有 js 程式碼(整個 js 執行棧)都執行完之後,再對值的變化進行判斷。
- 防止多次修改響應式變數,導致多次執行 watch 回撥,導致 vue3 的響應式鏈路混亂,起到防抖的作用。要知道,watch 的回撥,還可能引起其他響應式變數的變化
這個與我們在《六千字詳解!vue3 響應式是如何實現的?》文章中,提到過,effect 函式,有什麼區別
typescript
it('should be reactive', () => {
const a = ref(1)
let dummy
let calls = 0
effect(() => {
calls++
dummy = a.value
})
expect(calls).toBe(1)
expect(dummy).toBe(1)
a.value = 2
expect(calls).toBe(2)
expect(dummy).toBe(2)
})
與 watchEffect 的行為非常的相似,他們主要的區別是:
| | effect 函式 | watchEffect 函式 |
| -------------------- | -------------------------------------- | ------------------------------------------------------------ |
| 副作用函式的執行時機 | 響應式變數變化後,立即執行 | 響應式變數變化後,延遲執行 |
| 作用 | 僅僅用於響應式變數開發過程中的除錯 | 1. Vue3 官方提供的一個 API,與元件狀態耦合
(元件銷燬時,watchEffect 不再執行)
2. 延遲執行,目的是為了確定元件更新前,判斷響應式資料是否被改變
(可能一開始被改變,但是後來又被改回去,此時不需要更新) |
原始碼解析
watchEffect 和 watch 的實現,都是 doWatch 函式
```typescript export function watchEffect( effect: WatchEffect, options?: WatchOptionsBase ): WatchStopHandle { return doWatch(effect, null, options) }
export function watch
doWatch 的引數如下:
- source:為 watch / watchEffect 的第一個引數,該引數的型別非常多,在 doWatch 內部會進行標準化處理
- cb:僅僅 watch 有該 cb 回撥
- options:watch 的配置,有 immediate、deep、flush
doWatch
doWatch 函式主要分為以下幾個部分:
- 標準化 source,組裝成為 getter 函式
- 組裝 job 函式。判斷偵聽的值是否有變化,有變化則執行 getter 函式和 cb 回撥
- 組裝 scheduler 函式,scheduler 負責在合適的時機呼叫 job 函式(根據 options.flush,即副作用重新整理的時機),預設在元件更新前執行
- 開啟偵聽
- 返回停止偵聽函式
getter、scheduler、job、cb 它們之間的關係
這個圖目前看不懂沒有關係,後面還會出現並解釋
doWatch 大概程式碼結構如下(有刪減):
```typescript function doWatch( source: WatchSource | WatchSource[] | WatchEffect | object, cb: WatchCallback | null, { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ ): WatchStopHandle {
// 1. 根據 source 的型別組裝 getter
let getter: () => any
if (isRef(source)) {
getter = ...
} else if (isReactive(source)) {
getter = ...
} else {
...
}
// 2. 組裝 job const job: SchedulerJob = () => { // ... }
// 3. 組裝 scheduler let scheduler: EffectScheduler = ...
// 4. 開啟偵聽,偵聽的是 getter 函式 const effect = new ReactiveEffect(getter, scheduler) effect.run()
// 5. 返回停止偵聽函式 return () => { effect.stop() if (instance && instance.scope) { remove(instance.scope.effects!, effect) } } } ```
可以看出,watch 響應式也是通過 ReactiveEffect 物件實現的,不瞭解 ReactiveEffect 物件的同學,可以看看該文章:《六千字詳解!vue3 響應式是如何實現的?》
這裡也大概回顧一下 ReactiveEffect 物件的作用:
-
ReactiveEffect,接受 fn 和 scheduler 引數。ReactiveEffect 被建立時,會立即執行 fn
-
當 fn 函式中使用到響應式變數(如 ref)時,該響應式變數就會用陣列收集 ReactiveEffect 物件的引用
- 當響應式變數被改變時,會觸發所有的 ReactiveEffect 物件,觸發規則如下:
- 如果沒有 scheduler 引數,則執行ReactiveEffect 的 fn
- 如果有 scheduler 引數,則執行 scheduler,這時需要在 scheduler 中手動呼叫 fn
- 執行 fn 時,使用到響應式變數,依賴又會被重新收集
接下來,我們會從 ReactiveEffect 作為切入點,進行介紹(並非按照程式碼順序介紹)
開啟偵聽
typescript
// 開啟偵聽,偵聽的是 getter 函式
const effect = new ReactiveEffect(getter, scheduler)
這裡會立即呼叫 getter 函式,進行依賴收集。
如果依賴有變化,則執行 scheduler 函式
getter 函式
getter 函式是最終被偵聽的函式,即函式裡面用到的響應式變數的改變,都會觸發執行 scheduler 函式。
由於 watch/watchEffect 的入參,多種多樣,doWatch 在處理時,需要進行標準化處理
下面是 getter 部分的原始碼:
```typescript // 節選自 doWatch 內部實現 const instance = currentInstance let getter: () => any let forceTrigger = false // 標記為 forceTrigger ,則強制執行 cb,無論 getter 返回值是否改變 let isMultiSource = false // 標記是否為多偵聽源
if (isRef(source)) { // ref 處理 // 執行 getter,就會獲取 ref 的值,從而 track 收集依賴 getter = () => source.value forceTrigger = !!source._shallow } else if (isReactive(source)) { // reactive 物件 getter = () => source // reactive 需要深度遍歷 deep = true } else if (isArray(source)) { // 偵聽多個源,source 為陣列。需要設定 isMultiSource 標記為多資料來源。 isMultiSource = true forceTrigger = source.some(isReactive)
// 遍歷陣列,處理每個元素,處理方式跟單個源相同 getter = () => source.map(s => { if (isRef(s)) { return s.value } else if (isReactive(s)) { return traverse(s) } else if (isFunction(s)) { return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER) } else { DEV && warnInvalidSource(s) } }) } else if (isFunction(source)) { // source 是函式 if (cb) { // 直接用錯誤處理函式包一層,getter 函式實際上就是直接執行 source 函式 // callWithErrorHandling 中做了一些 vue 錯誤資訊的統一處理,有更好的錯誤提示 getter = () => callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER) } else { // 沒有 cb,最後還是直接執行 source getter = () => { if (instance && instance.isUnmounted) { return } if (cleanup) { cleanup() } return callWithAsyncErrorHandling( source, instance, ErrorCodes.WATCH_CALLBACK, [onInvalidate] ) } } } else { // 兜底處理,到這裡證明傳入 source 的值是錯誤的,開發環境下會警告 // 如 watch(ref.value,()={}),而此時 ref.value === undefined getter = NOOP DEV && warnInvalidSource(source) }
// 如果深度監聽,則需要深度遍歷整個 getter 的返回值 // 例如 reactive,需要訪問物件內部的每一個屬性,需要進行深度遍歷訪問 // 當執行 getter 時,由於深度訪問了每一個屬性,因此每個屬性都會 track 收集依賴 if (cb && deep) { const baseGetter = getter getter = () => traverse(baseGetter()) } ```
總的來說,這部分就是根據 source 的不同型別,標準化包裝成 getter 函式
- ref:
() => source.value
- reactive:
() => traverse(source)
- 陣列:分別根據子元素型別,包裝成 getter 函式
- 函式:用
callWithErrorHandling
包裝,實際上就是直接呼叫 source 函式
traverse 的作用是什麼?
對於 reactive 物件或設定了引數 deep,需要偵聽到深層次的變化,這需要深度遍歷整個物件,深層次的訪問其所有的響應式變數,並收集依賴。
typescript
// 深度遍歷物件,只是訪問響應式變數,不做任何處理
// 訪問就會觸發響應式變數的 getter,從而觸發依賴收集
export function traverse(value: unknown, seen?: Set<unknown>) {
if (!isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value
}
seen = seen || new Set()
if (seen.has(value)) {
return value
}
seen.add(value)
if (isRef(value)) {
traverse(value.value, seen)
} else if (isArray(value)) {
// 繼續深入遍歷陣列
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => {
traverse(v, seen)
})
} else if (isPlainObject(value)) {
// 是物件則繼續深入遍歷
for (const key in value) {
traverse((value as any)[key], seen)
}
}
return value
}
scheduler 函式
當 getter 中偵聽的響應式變數發生改變時,就會執行 scheduler 函式
scheduler 用於控制 job 的執行時機,scheduler 會在對應的時機,執行 job,該時機取決於 options 的 flush 引數(pre、sync、post)
```typescript // 如果有 cb,則允許 job 遞迴 // 如:cb 導致 getter 又被改變 trigger 了,這時候應該允許繼續又將 cb 加入執行佇列 job.allowRecurse = !!cb
let scheduler: EffectScheduler if (flush === 'sync') { // 同步呼叫 job,官方不建議同步呼叫 scheduler = job as any // the scheduler function gets called directly } else if (flush === 'post') { // 非同步呼叫 job,在元件 DOM 更新之後 scheduler = () => queuePostRenderEffect(job, instance && instance.suspense) } else { // default: 'pre' scheduler = () => { if (!instance || instance.isMounted) { // 非同步呼叫 job,在元件 DOM 更新前 queuePreFlushCb(job) } else { // 元件未 mounted 時,watch cb 是同步呼叫的 job() } } } ```
queuePostRenderEffect
和 queuePreFlushCb
在該文章不會詳細介紹,只需要知道,這兩個函式是在 DOM 更新前/後執行傳入的函式(這裡是 job 函式)即可,這兩個函式是 Vue 排程系統的一部分,詳情見文章《七千字深度剖析 Vue3 的排程系統》
三個執行時機分別有什麼區別
- pre::元件 DOM 更新前,此時拿到的是更新後的 DOM 物件
- post:元件 DOM 更新後,此時拿到的是更新後的 DOM 物件
- sync:在響應式變數改變時,同步執行 job,此時 watch 的 cb 回撥還沒執行,元件 DOM 也沒有更新。這種方式是低效的,因為沒有延遲執行,就失去了防抖的效果,也沒有辦法判斷最終的值是否發生變化。儘量避免使用
組裝 job 函式
Job 函式在 scheduler 函式中被直接或間接呼叫。
job 負責執行 effect.run(即執行 getter 函式重新收集依賴)和 cb(watch 才有),對應的是圖中的紅色部分
```typescript let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE const job: SchedulerJob = () => { // 如果偵聽已經停止,則直接 return if (!effect.active) { return } if (cb) { // watch(source, cb) 會走這個分支 // 在 scheduler 中需要手動直接執行 effect.run,這裡會執行 getter 函式 // 先執行 getter 獲取返回值,如果返回值變化,才執行 cb。 const newValue = effect.run()
// 判斷是否需要執行 cb
// 1. getter 函式的值被改變,沒有發生改變則不執行 cb 回撥
// 2. 設定了 deep 深度監聽
// 3. forceTrigger 為 true
if (
deep ||
forceTrigger ||
(isMultiSource
? (newValue as any[]).some((v, i) =>
hasChanged(v, (oldValue as any[])[i])
)
: hasChanged(newValue, oldValue)) ||
(__COMPAT__ &&
isArray(newValue) &&
isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))
) {
// 執行 cb,並傳入 newValue、oldValue、onInvalidate
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
onInvalidate
])
// 快取 getter 的返回值
oldValue = newValue
}
} else { // watchEffect // 在 scheduler 中需要手動直接執行 effect.run,這裡會執行 getter 函式 effect.run() } } ```
返回停止偵聽函式
typescript
// 返回一個停止偵聽 effect 的函式
return () => {
effect.stop()
// 移除當前元件上的對應的 effect
if (instance && instance.scope) {
remove(instance.scope.effects!, effect)
}
}
呼叫該函式會清除 watch
其他閱讀
最後
如果這篇文章對您有所幫助,請幫忙點個贊👍,您的鼓勵是我創作路上的最大的動力。