Vue3 Watch API 到底是怎麼實現的?

語言: CN / TW / HK

我正在參與掘金創作者訓練營第4期,點擊瞭解活動詳情,一起學習吧!

前言

在之前的文章,我們已經介紹過 vue3 的響應式原理。如果還沒看過的同學,強烈建議先看看《六千字詳解!vue3 響應式是如何實現的?》,該文章用 vue3 ref 的例子,詳細地介紹了響應式原理的實現。

而這篇則是在響應式原理的基礎上,進一步介紹 Vue3 的另外一個 API —— watch

watch 用法

Vue3 的 watchApi 主要有兩類:watch 和 watchEffect。(watchPostEffect 和 watchSyncEffect 只是 watchEffect 的不同參數 flush 的別名)

watch 的用法

  1. 偵聽單一源

```typescript // 偵聽一個 getter 函數 const state = reactive({ count: 0 }) watch( () => state.count, (count, prevCount) => { / ... / } )

// 直接偵聽一個 ref const count = ref(0) watch(count, (count, prevCount) => { / ... / }) ```

  1. 偵聽多個源

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 = false>( source: T | WatchSource, cb: any, options?: WatchOptions ): WatchStopHandle { return doWatch(source as any, cb, options) } ```

doWatch 的參數如下:

  • source:為 watch / watchEffect 的第一個參數,該參數的類型非常多,在 doWatch 內部會進行標準化處理
  • cb:僅僅 watch 有該 cb 回調
  • options:watch 的配置,有 immediate、deep、flush

doWatch

doWatch 函數主要分為以下幾個部分:

  1. 標準化 source,組裝成為 getter 函數
  2. 組裝 job 函數。判斷偵聽的值是否有變化,有變化則執行 getter 函數和 cb 回調
  3. 組裝 scheduler 函數,scheduler 負責在合適的時機調用 job 函數(根據 options.flush,即副作用刷新的時機),默認在組件更新前執行
  4. 開啟偵聽
  5. 返回停止偵聽函數

getter、scheduler、job、cb 它們之間的關係

image-20220123210417284

這個圖目前看不懂沒有關係,後面還會出現並解釋

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 對象的作用:

  1. ReactiveEffect,接受 fn 和 scheduler 參數。ReactiveEffect 被創建時,會立即執行 fn

  2. 當 fn 函數中使用到響應式變量(如 ref)時,該響應式變量就會用數組收集 ReactiveEffect 對象的引用

image-20211231112331231

  1. 響應式變量被改變時,會觸發所有的 ReactiveEffect 對象,觸發規則如下:
  2. 如果沒有 scheduler 參數,則執行ReactiveEffect 的 fn
  3. 如果有 scheduler 參數,則執行 scheduler,這時需要在 scheduler 中手動調用 fn
  4. 執行 fn 時,使用到響應式變量,依賴又會被重新收集

接下來,我們會從 ReactiveEffect 作為切入點,進行介紹(並非按照代碼順序介紹)

開啟偵聽

typescript // 開啟偵聽,偵聽的是 getter 函數 const effect = new ReactiveEffect(getter, scheduler)

這裏會立即調用 getter 函數,進行依賴收集。

如果依賴有變化,則執行 scheduler 函數

image-20220125210032545

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() } } } ```

queuePostRenderEffectqueuePreFlushCb 在該文章不會詳細介紹,只需要知道,這兩個函數是在 DOM 更新前/後執行傳入的函數(這裏是 job 函數)即可,這兩個函數是 Vue 調度系統的一部分,詳情見文章《七千字深度剖析 Vue3 的調度系統》

三個執行時機分別有什麼區別

  • pre::組件 DOM 更新前,此時拿到的是更新後的 DOM 對象
  • post:組件 DOM 更新後,此時拿到的是更新後的 DOM 對象
  • sync:在響應式變量改變時,同步執行 job,此時 watch 的 cb 回調還沒執行,組件 DOM 也沒有更新。這種方式是低效的,因為沒有延遲執行,就失去了防抖的效果,也沒有辦法判斷最終的值是否發生變化。儘量避免使用

組裝 job 函數

Job 函數在 scheduler 函數中被直接或間接調用

job 負責執行 effect.run(即執行 getter 函數重新收集依賴)和 cb(watch 才有),對應的是圖中的紅色部分

image-20220125205911675

```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

其他閲讀

最後

如果這篇文章對您有所幫助,請幫忙點個贊👍,您的鼓勵是我創作路上的最大的動力。