Vue3源碼分析偵聽器watch的實現原理

watch 的本質

所謂的watch,其本質就是觀測一個響應式數據,當數據發生變化時通知並執行相應的回調函數。實際上,watch 的實現本質就是利用瞭 effect 和 options.scheduler 選項。如下例子所示:

// watch 函數接收兩個參數,source 是響應式數據,cb 是回調函數
function watch(source, cb){
  effect(
    // 觸發讀取操作,從而建立聯系
  	() => source.foo,
    {
      scheduler(){
        // 當數據變化時,調用回調函數 cb
        cb()
      }
    }
  )
}

如上面的代碼所示嗎,source 是響應式數據,cb 是回調函數。如果副作用函數中存在 scheduler 選項,當響應式數據發生變化時,會觸發 scheduler 函數執行,而不是直接觸發副作用函數執行。從這個角度來看, scheduler 調度函數就相當於是一個回調函數,而 watch 的實現就是利用瞭這點。

watch 的函數簽名

偵聽多個源

偵聽的數據源可以 是一個數組,如下面的函數簽名所示:

// packages/runtime-core/src/apiWatch.ts

// 數據源是一個數組
// overload: array of multiple sources + cb
export function watch<
  T extends MultiWatchSources,
  Immediate extends Readonly<boolean> = false
>(
  sources: [...T],
  cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
  options?: WatchOptions<Immediate>
): WatchStopHandle

也可以使用數組同時偵聽多個源,如下面的函數簽名所示:

// packages/runtime-core/src/apiWatch.ts

// 使用數組同時偵聽多個源
// overload: multiple sources w/ `as const`
// watch([foo, bar] as const, () => {})
// somehow [...T] breaks when the type is readonly
export function watch<
  T extends Readonly<MultiWatchSources>,
  Immediate extends Readonly<boolean> = false
>(
  source: T,
  cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
  options?: WatchOptions<Immediate>
): WatchStopHandle

偵聽單一源

偵聽的數據源是一個 ref 類型的數據 或者是一個具有返回值的 getter 函數,如下面的函數簽名所示:

// packages/runtime-core/src/apiWatch.ts

// 數據源是一個 ref 類型的數據 或者是一個具有返回值的 getter 函數
// overload: single source + cb
export function watch<T, Immediate extends Readonly<boolean> = false>(
source: WatchSource<T>,
 cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
 options?: WatchOptions<Immediate>
): WatchStopHandle

export type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)

偵聽的數據源是一個響應式的 obj 對象,如下面的函數簽名所示:

// packages/runtime-core/src/apiWatch.ts

// 數據源是一個響應式的 obj 對象
// overload: watching reactive object w/ cb
export function watch<
  T extends object,
  Immediate extends Readonly<boolean> = false
>(
  source: T,
  cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
  options?: WatchOptions<Immediate>
): WatchStopHandle

watch 的實現

watch 函數

// packages/runtime-core/src/apiWatch.ts

// implementation
export function watch<T = any, Immediate extends Readonly<boolean> = false>(
  source: T | WatchSource<T>,
  cb: any,
  options?: WatchOptions<Immediate>
): WatchStopHandle {
  if (__DEV__ && !isFunction(cb)) {
    warn(
      `\`watch(fn, options?)\` signature has been moved to a separate API. ` +
        `Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
        `supports \`watch(source, cb, options?) signature.`
    )
  }
  return doWatch(source as any, cb, options)
}

可以看到,watch 函數接收3個參數,分別是:source 偵聽的數據源,cb 回調函數,options 偵聽選項。

source 參數

從watch的函數重載中可以知道,當偵聽的是單一源時,source 可以是一個 ref 類型的數據 或者是一個具有返回值的 getter 函數,也可以是一個響應式的 obj 對象。當偵聽的是多個源時,source 可以是一個數組。

cb 參數

在 cb 回調函數中,給開發者提供瞭最新的value,舊的value以及onCleanup函數用與清除副作用。如下面的類型定義所示:

export type WatchCallback<V = any, OV = any> = (
  value: V,
  oldValue: OV,
  onCleanup: OnCleanup
) => any

options 參數

options 選項可以控制 watch 的行為,例如通過options的選項參數immediate來控制watch的回調是否立即執行,通過options的選項參數來控制watch的回調函數是同步執行還是異步執行。options 參數的類型定義如下:

export interface WatchOptionsBase extends DebuggerOptions {
  flush?: 'pre' | 'post' | 'sync'
}
export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
  immediate?: Immediate
  deep?: boolean
}

可以看到 options 的類型定義 WatchOptions 繼承瞭 WatchOptionsBase。也就是說,watch 的 options 中除瞭 immediate 和 deep 這兩個特有的參數外,還可以傳遞 WatchOptionsBase 中的所有參數以控制副作用執行的行為。

在 watch 的函數體中調用瞭 doWatch 函數,我們來看看它的實現。

doWatch 函數

實際上,無論是watch函數,還是 watchEffect 函數,在執行時最終調用的都是 doWatch 函數。

doWatch 函數簽名

function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle

doWatch 的函數簽名與 watch 的函數簽名基本一致,也是接收三個參數。在 doWatch 函數中,為瞭便於options 選項的使用,對 options 進行瞭解構。

初始化變量

首先從 component 中獲取當前的組件實例,然後分別定義三個變量。其中 getter 是一個函數,她或作為副作用的函數參數傳入到副作用函數中。forceTrigger 變量是一個佈爾值,用來標識是否需要強制觸發副作用函數執行。isMultiSource 變量同樣也是一個佈爾值,用來標記偵聽的數據源是單一源還是以數組形式傳入的多個源,初始值為 false,表示偵聽的是單一源。如下面的代碼所示:

  const instance = currentInstance
  let getter: () => any
  // 是否需要強制觸發副作用函數執行   
  let forceTrigger = false
  // 偵聽的是否是多個源
  let isMultiSource = false

接下來根據偵聽的數據源來初始化這三個變量。

偵聽的數據源是一個 ref 類型的數據

當偵聽的數據源是一個 ref 類型的數據時,通過返回 source.value 來初始化 getter,也就是說,當 getter 函數被觸發時,會通過source.value 獲取到實際偵聽的數據。然後通過 isShallow 函數來判斷偵聽的數據源是否是淺響應,並將其結果賦值給 forceTrigger,完成 forceTrigger 變量的初始化。如下面的代碼所示:

if (isRef(source)) {
  // 偵聽的數據源是 ref
  getter = () => source.value
  // 判斷數據源是否是淺響應
  forceTrigger = isShallow(source)
}

偵聽的數據源是一個響應式數據

當偵聽的數據源是一個響應式數據時,直接返回 source 來初始化 getter ,即 getter 函數被觸發時直接返回 偵聽的數據源。由於響應式數據中可能會是一個object 對象,因此將 deep 設置為 true,在觸發 getter 函數時可以遞歸地讀取對象的屬性值。如下面的代碼所示:

else if (isReactive(source)) {
  // 偵聽的數據源是響應式數據
  getter = () => source
  deep = true
}

偵聽的數據源是一個數組

當偵聽的數據源是一個數組,即同時偵聽多個源。此時直接將 isMultiSource 變量設置為 true,表示偵聽的是多個源。接著通過數組的 some 方法來檢測偵聽的多個源中是否存在響應式對象,將其結果賦值給 forceTrigger 。然後遍歷數組,判斷每個源的類型,從而完成 getter 函數的初始化。如下面的代碼所示:

else if (isArray(source)) {
  // 偵聽的數據源是一個數組,即同時偵聽多個源
  isMultiSource = true
  forceTrigger = source.some(isReactive)
  getter = () =>
    // 遍歷數組,判斷每個源的類型 
    source.map(s => {
      if (isRef(s)) {
        // 偵聽的數據源是 ref  
        return s.value
      } else if (isReactive(s)) {
        // 偵聽的數據源是響應式數據 
        return traverse(s)
      } else if (isFunction(s)) {
        // 偵聽的數據源是一個具有返回值的 getter 函數 
        return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
      } else {
        __DEV__ && warnInvalidSource(s)
      }
    })
} 

偵聽的數據源是一個函數

當偵聽的數據源是一個具有返回值的 getter 函數時,判斷 doWatch 函數的第二個參數 cb 是否有傳入。如果有傳入,則處理的是 watch 函數的場景,此時執行 source 函數,將執行結果賦值給 getter 。如果沒有傳入,則處理的是 watchEffect 函數的場景。在該場景下,如果組件實例已經卸載,則直接返回,不執行 source 函數。否則就執行 cleanup 清除依賴,然後執行 source 函數,將執行結果賦值給 getter 。如下面的代碼所示:

else if (isFunction(source)) {

  // 處理 watch 和 watchEffect 的場景
  // watch 的第二個參數可以是一個具有返回值的 getter 參數,第二個參數是一個回調函數
  // watchEffect 的參數是一個 函數

  // 偵聽的數據源是一個具有返回值的 getter 函數 
  if (cb) {
    // getter with cb
    // 處理的是 watch 的場景
    // 執行 source 函數,將執行結果賦值給 getter   
    getter = () =>
      callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
  } else {
    // no cb -> simple effect
    // 沒有回調,即為 watchEffect 的場景  
    getter = () => {
      // 件實例已經卸載,則不執行,直接返回
      if (instance && instance.isUnmounted) {
        return
      }
      // 清除依賴
      if (cleanup) {
        cleanup()
      }
      // 執行 source 函數
      return callWithAsyncErrorHandling(
        source,
        instance,
        ErrorCodes.WATCH_CALLBACK,
        [onCleanup]
      )
    }
  }
}

遞歸讀取響應式數據

如果偵聽的數據源是一個響應式數據,需要遞歸讀取響應式數據中的屬性值。如下面的代碼所示:

// 處理的是 watch 的場景
// 遞歸讀取對象的屬性值  
if (cb && deep) {
  const baseGetter = getter
  getter = () => traverse(baseGetter())
}

在上面的代碼中,doWatch 函數的第二個參數 cb 有傳入,說明處理的是 watch 中的場景。deep 變量為 true ,說明此時偵聽的數據源是一個響應式數據,因此需要調用 traverse 函數來遞歸讀取數據源中的每個屬性,對其進行監聽,從而當任意屬性發生變化時都能夠觸發回調函數執行。

定義清除副作用函數

聲明 cleanup 和 onCleanup 函數,並在 onCleanup 函數的執行過程中給 cleanup 函數賦值,當副作用函數執行一些異步的副作用時,這些響應需要在其失效是清除。如下面的代碼所示:

// 清除副作用函數
let cleanup: () => void
let onCleanup: OnCleanup = (fn: () => void) => {
  cleanup = effect.onStop = () => {
    callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
  }
}

封裝 scheduler 調度函數

為瞭便於控制 watch 的回調函數 cb 的執行時機,需要將 scheduler 調度函數封裝為一個獨立的 job 函數,如下面的代碼所示:

// 將 scheduler 調度函數封裝為一個獨立的 job 函數,便於在初始化和變更時執行它
const job: SchedulerJob = () => {
  if (!effect.active) {
    return
  }
  if (cb) {
    // 處理 watch 的場景 
    // watch(source, cb)

    // 執行副作用函數獲取新值
    const newValue = effect.run()
    
    // 如果數據源是響應式數據或者需要強制觸發副作用函數執行或者新舊值發生瞭變化
    // 則執行回調函數,並更新舊值
    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))
    ) {
      
      // 當回調再次執行前先清除副作用
      // cleanup before running cb again
      if (cleanup) {
        cleanup()
      }

      // 執行watch 函數的回調函數 cb,將舊值和新值作為回調函數的參數
      callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
        newValue,
        
        // 首次調用時,將 oldValue 的值設置為 undefined
        // pass undefined as the old value when it's changed for the first time
        oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
        onCleanup
      ])
      // 更新舊值,不然下一次會得到錯誤的舊值
      oldValue = newValue
    }
  } else {
    // watchEffect
    // 處理 watchEffect 的場景
    effect.run()
  }
}

在 job 函數中,判斷回調函數 cb 是否傳入,如果有傳入,那麼是 watch 函數被調用的場景,否則就是 watchEffect 函數被調用的場景。

如果是 watch 函數被調用的場景,首先執行副作用函數,將執行結果賦值給 newValue 變量,作為最新的值。然後判斷需要執行回調函數 cb 的情況:

  • 如果偵聽的數據源是響應式數據,需要深度偵聽,即 deep 為 true
  • 如果需要強制觸發副作用函數執行,即 forceTrigger 為 true
  • 如果新舊值發生瞭變化

隻要滿足上面三種情況中的其中一種,就需要執行 watch 函數的回調函數 cb。如果回調函數 cb 是再次執行,在執行之前需要先清除副作用。然後調用 callWithAsyncErrorHandling 函數執行回調函數cb,並將新值newValue 和舊值 oldValue 傳入回調函數cb中。在回調函數cb執行後,更新舊值oldValue,避免在下一次執行回調函數cb時獲取到錯誤的舊值。

如果是 watchEffect 函數被調用的場景,則直接執行副作用函數即可。

設置 job 的 allowRecurse 屬性

根據是否傳入回調函數cb,設置 job 函數的 allowRecurse 屬性。這個設置十分重要,它能夠讓 job 作為偵聽器的回調,這樣調度器就能知道它允許調用自身。

// important: mark the job as a watcher callback so that scheduler knows
// it is allowed to self-trigger (#1727)
// 重要:讓調度器任務作為偵聽器的回調以至於調度器能知道它可以被允許自己派發更新
job.allowRecurse = !!cb

flush 選項指定回調函數的執行時機

在調用 watch 函數時,可以通過 options 的 flush 選項來指定回調函數的執行時機:

  • 當 flush 的值為 sync 時,代表調度器函數是同步執行,此時直接將 job 賦值給 scheduler,這樣調度器函數就會直接執行。

  • 當 flush 的值為 post 時,代表調度函數需要將副作用函數放到一個微任務隊列中,並等待 DOM 更新結束後再執行。

  • 當 flush 的值為 pre 時,即調度器函數默認的執行方式,這時調度器會區分組件是否已經掛載。如果組件未掛載,則先執行一次調度函數,即執行回調函數cb。在組件掛載之後,將調度函數推入一個優先執行時機的隊列中。

    // 這裡處理的是回調函數的執行時機
    let scheduler: EffectScheduler if (flush === 'sync') { // 同步執行,將 job 直接賦值給調度器 scheduler = job as any // the scheduler function gets called directly } else if (flush === 'post') { // 將調度函數 job 添加到微任務隊列中執行 scheduler = () => queuePostRenderEffect(job, instance && instance.suspense) } else { // default: 'pre' // 調度器函數默認的執行模式 scheduler = () => { if (!instance || instance.isMounted) { // 組件掛載後將 job 推入一個優先執行時機的隊列中 queuePreFlushCb(job) } else { // with 'pre' option, the first call must happen before // the component is mounted so it is called synchronously. // 在 pre 選型中,第一次調用必須發生在組件掛載之前 // 所以這次調用是同步的 job() } } }

創建副作用函數

初始化完 getter 函數和調度器函數 scheduler 後,調用 ReactiveEffect 類來創建一個副作用函數

// 創建一個副作用函數
const effect = new ReactiveEffect(getter, scheduler)

執行副作用函數

在執行副作用函數之前,首先判斷是否傳入瞭回調函數cb,如果有傳入,則根據 options 的 immediate 選項來判斷是否需要立即執行回調函數cb,如果指定瞭immediate 選項,則立即執行 job 函數,即 watch 的回調函數會在 watch 創建時立即執行一次。否則就手動調用副作用函數,並將返回值作為舊值,賦值給 oldValue。如下面的代碼所示:

if (cb) {
  // 選項參數 immediate 來指定回調是否需要立即執行
  if (immediate) {
    // 回調函數會在 watch 創建時立即執行一次
    job()
  } else {
    // 手動調用副作用函數,拿到的就是舊值
    oldValue = effect.run()
  }
}

如果 options 的 flush 選項的值為 post ,需要將副作用函數放入到微任務隊列中,等待組件掛載完成後再執行副作用函數。如下面的代碼所示:

else if (flush === 'post') {
  // 在調度器函數中判斷 flush 是否為 'post',如果是,將其放到微任務隊列中執行
  queuePostRenderEffect(
    effect.run.bind(effect),
    instance && instance.suspense
  )
}

其餘情況都是立即執行副作用函數。如下面的代碼所示:

else {
  // 其餘情況立即首次執行副作用
  effect.run()
}

返回匿名函數,停止偵聽

doWatch 函數最後返回瞭一個匿名函數,該函數用以結束數據源的偵聽。因此在調用 watch 或者 watchEffect 時,可以調用其返回值類結束偵聽。

return () => {
  effect.stop()
  if (instance && instance.scope) {
    // 返回一個函數,用以顯式的結束偵聽
    remove(instance.scope.effects!, effect)
  }
}

總結

watch 的本質就是觀測一個響應式數據,當數據發生變化時通知並執行相應的回調函數。watch的實現利用瞭effect 和 options.scheduler 選項。

watch 可以偵聽單一源,也可以偵聽多個源。偵聽單一源時數據源可以是一個具有返回值的getter 函數,或者是一個 ref 對象,也可以是一個響應式的 object 對象。偵聽多個源時,其數據源是一個數組。

在watch的實現中,根據偵聽的數據源的類型來初始化getter 函數和 scheduler 調度函數,根據這兩個函數創建一個副作用函數,並根據 options 的 immediate 選項以及 flush 選項來指定回調函數和副作用函數的執行時機。當 immediate 為 true 時,在watch 創建時會立即執行一次回調函數。當 flush 的值為 post 時,scheduler 調度函數和副作用函數都會被添加到微任務隊列中,會等待 DOM 更新結束後再執行。

到此這篇關於Vue3源碼分析偵聽器watch的實現原理的文章就介紹到這瞭,更多相關Vue3偵聽器watch內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: