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

其他閱讀

最後

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