Vue3 源碼解析(十):watch 的實現原理

本篇文章筆者會講解 Vue3 中偵聽器相關的 api:watchEffect 和 watch 。在 Vue3 以前 watch 是 option 寫法中一個很經常使用的選項,使用它能夠很是方便的監聽一個數據源的變化,而在 Vue3 中隨着 Composition API 的寫法推行也將 watch 獨立成了一個 響應式 api,今天咱們就一塊兒來學習 watch 相關的偵聽器是如何實現的。vue

👇 儲備知識要求:react

在閱讀本文前,建議你已經學習過本系列的第 7 篇文章的 effect 反作用函數的相關知識,不然在講解反作用的相關部分可能會出現不理解的狀況。git

watchEffect

因爲 watch api 中的許多行爲都與 watchEffect api 一致,因此筆者將 watchEffect 放在首位講解,爲了根據響應式狀態自動應用和從新應用反作用,咱們可使用 watchEffect 方法。它當即執行傳入的一個函數,同時響應式追蹤其依賴,並在以來變動時從新運行該函數。github

watchEffect 函數的實現很是簡潔:api

export function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase
): WatchStopHandle {
  return doWatch(effect, null, options)
}

首先來看參數類型:數組

export type WatchEffect = (onInvalidate: InvalidateCbRegistrator) => void

export interface WatchOptionsBase {
  flush?: 'pre' | 'post' | 'sync'
  onTrack?: ReactiveEffectOptions['onTrack']
  onTrigger?: ReactiveEffectOptions['onTrigger']
}

export type WatchStopHandle = () => void

第一個參數 effect,接收函數類型的變量,而且在這個函數中會傳入 onInvalidate 參數,用以清除反作用。閉包

第二個參數 options 是一個對象,在這個對象中有三個屬性,你能夠修改 flush 來改變反作用的刷新時機,默認爲 pre,當修改成 post 時,就能夠在組件更新後觸發這個反作用偵聽器,改同 sync 會強制同步觸發。而 onTrack 和 onTrigger 選項能夠用於調試偵聽器的行爲,而且兩個參數只能在開發模式下工做。併發

參數傳入後,函數會執行並返回 doWatch 函數的返回值。異步

因爲 watch api 也會調用 doWatch 函數,因此 doWatch 函數的具體邏輯咱們會放在後邊講。先看 watch api 的函數實現。函數

watch

這個獨立出來的 watch api 與組件中的 watch option 是徹底等同的,watch 須要偵聽特定的數據源,並在回調函數中執行反作用。默認狀況下這個偵聽是惰性的,即只有當被偵聽的源發生變化時才執行回調。

與 watchEffect 相比,watch 有如下不一樣:

  • 懶性執行反作用
  • 更具體地說明說明狀態應該處罰偵聽器從新運行
  • 可以訪問偵聽狀態變化先後的值

watch 函數的函數簽名有許多種重載狀況,且代碼行數較多,因此筆者不許備分析每一個重載狀況,一塊兒來看一下 watch api 的實現。

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 參數

source 的類型以下:

export type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
type MultiWatchSources = (WatchSource<unknown> | object)[]

從兩個類型定義看出,數據源支持傳入單個的 Ref、Computed 響應式對象,或者傳入一個返回相同泛型類型的函數,以及 source 支持傳入數組,以便能同時監聽多個數據源。

cb 參數

在這個最通用的聲明中,cb 的類型是 any,可是其實 cb 這個回調函數也有他本身的類型:

export type WatchCallback<V = any, OV = any> = (
  value: V,
  oldValue: OV,
  onInvalidate: InvalidateCbRegistrator
) => any

在回調函數中,會提供最新的 value、舊 value,以及 onInvalidate 函數用以清除反作用。

options

export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
  immediate?: Immediate
  deep?: boolean
}

能夠看到 options 的類型 WatchOptions 繼承了 WatchOptionsBase,這也就是 watch 除了 immediate 和 deep 這兩個特有的參數外,還能夠傳遞 WatchOptionsBase 中的全部參數以控制反作用執行的行爲。

分析完參數後,能夠看到函數體內的邏輯與 watchEffect 幾乎一致,可是多了在開發環境下檢測回調函數是不是函數類型,若是回調函數不是函數,就會報警。

執行 doWatch 時的傳參與 watchEffect 相比,多了第二個參數回調函數。

下面就讓咱們揭開這個終極 boss doWatch 的廬山真面目吧。

doWatch

不論是 watchEffect、watch 仍是組件內的 watch 選項,在執行時最終調用的都是 doWatch 中的邏輯,這個強大的 doWatch 函數爲了兼容各個 api 的邏輯源碼也是挺長的大約有 200 行,因此老規矩,筆者會將長源碼拆分開來說。若想閱讀完整源碼請戳這裏

先從 doWatch 的函數簽名看起:

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

這個函數簽名與 watch 基本一致,多了一個 instance 的參數,默認值爲 currentInstance,currentInstance 是當前調用組件暴露出來的一個變量,方便該偵聽器找到本身對應的組件。

而 source 在這裏的類型就比較清晰,支持單個的 source 或者數組,也只是一個普通對象。

接着會建立三個變量,getter 最終會當作反作用的函數參數傳入,forceTrigger 標識是否須要強制更新,isMultiSource 標記傳入的是單個數據源仍是以數組形式傳入的多個數據源。

let getter: () => any
let forceTrigger = false
let isMultiSource = false

而後會開始判斷 source 的類型,根據不一樣的類型重置這三個參數的值。

  • ref 類型

    • 訪問 getter 函數會獲取到 source.value 值,直接解包。
    • forceTrigger 標記會根據是不是 shallowRef 來設置。
  • reactive 類型

    • 訪問 getter 函數直接返回 source,由於 reactive 的值不須要解包獲取。
    • 因爲 reactive 中每每有多個屬性,因此會將 deep 設置爲 true,這裏能夠看出從外部給 reactive 設置 deep 是無效的。
  • 數組 array 類型

    • 將 isMultiSource 設置爲 true。
    • forceTrigger 會根據數組中是否存在 reactive 響應式對象來判斷。
    • getter 是一個數組形式,是 source 內各個元素的單個 getter 結果。
  • source 是函數 function 類型

    • 若是有回調函數

      • getter 就是 source 函數執行的結果,這種狀況通常是 watch api 中的數據源以函數的形式傳入。
    • 若是沒有回調函數,那麼此時就是 watchEffect api 的場景了。

      • 此時會爲 watchEffect 設置 getter 函數,getter 函數邏輯以下:

        • 若是組件實例已經卸載,則不執行,直接返回
        • 不然執行 cleanup 清除依賴
        • 執行 source 函數
  • 若是 source 不是以上的狀況,則將 getter 設置爲空函數,而且報出 source 不合法的警告⚠️。

相關代碼以下,因爲邏輯已經完整的一絲不落的在上面分析了,因此就容筆者偷個懶,不加註釋了。

if (isRef(source)) { // ref 類型的數據源,更新 getter 與 forceTrigger
  getter = () => (source as Ref).value
  forceTrigger = !!(source as Ref)._shallow
} else if (isReactive(source)) { // reactive 類型的數據源,更新 getter 與 deep
  getter = () => source
  deep = true
} else if (isArray(source)) { // 多個數據源,更新 isMultiSource、forceTrigger、getter
  isMultiSource = true
  forceTrigger = source.some(isReactive)
  // getter 會以數組形式返回數組中數據源的值
  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)) { // 數據源是函數的狀況
  if (cb) {
    // 若是有回調,則更新 getter,讓數據源做爲 getter 函數
    getter = () =>
      callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
  } else {
    // 沒有回調即爲 watchEffect 場景
    getter = () => {
      if (instance && instance.isUnmounted) {
        return
      }
      if (cleanup) {
        cleanup()
      }
      return callWithAsyncErrorHandling(
        source,
        instance,
        ErrorCodes.WATCH_CALLBACK,
        [onInvalidate]
      )
    }
  }
} else {
  // 其他狀況 getter 爲空函數,併發出警告
  getter = NOOP
  __DEV__ && warnInvalidSource(source)
}

接着會處理 watch 中的場景,當有回調,而且 deep 選項爲 true 時,將使用 traverse 來包裹 getter 函數,對數據源中的每一個屬性遞歸遍歷進行監聽。

if (cb && deep) {
  const baseGetter = getter
  getter = () => traverse(baseGetter())
}

以後會聲明 cleanup 和 onInvalidate 函數,並在 onInvalidate 函數的執行過程當中給 cleanup 函數賦值,當反作用函數執行一些異步的反作用,這些響應須要在其失效時清除,因此偵聽反作用傳入的函數能夠接收一個 onInvalidate 函數做爲入參,用來註冊清理失效時的回調。當如下狀況發生時,這個失效回調會被觸發:

  • 反作用即將從新執行時。
  • 偵聽器被中止(若是在 setup() 或生命週期鉤子函數中使用了 watchEffect,則在組件卸載時)。
let cleanup: () => void
let onInvalidate: InvalidateCbRegistrator = (fn: () => void) => {
  cleanup = runner.options.onStop = () => {
    callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
  }
}

接着會初始化 oldValue 並賦值。

而後聲明一個 job 函數,這個函數最終會做爲調度器中的回調函數傳入,因爲是一個閉包形式依賴外部做用域中的許多變量,因此會放在後面講,避免出現還未聲明的變量形成理解困難。

根據是否有回調函數,設置 job 的 allowRecurse 屬性,這個設置很重要,可以讓 job 做爲一個觀察者的回調這樣調度器就能知道它容許調用自身。

接着聲明一個 scheduler 的調度器對象,根據 flush 的傳參來肯定調度器的執行時機。

  • 當 flush 爲 sync 同步時,直接將 job 賦值給 scheduler,這樣這個調度器函數就會直接執行。
  • 當 flush 爲 post 須要延遲執行時,將 job 傳入 queuePostRenderEffect 中,這樣 job 會被添加進一個延遲執行的隊列中,這個隊列會在組件被掛載後、更新的生命週期中執行。
  • 最後是 flush 爲默認的 pre 優先執行的狀況,這是調度器會區分組件是否已經掛載,反作用第一次調用時必須是在組件掛載以前,而掛載後則會被推入一個優先執行時機的隊列中。

這一部分邏輯的源碼以下:

// 初始化 oldValue
let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => { /*暫時忽略邏輯*/ } // 聲明一個 job 調度器任務,暫時不關注內部邏輯

// 重要:讓調度器任務做爲偵聽器的回調以致於調度器能知道它能夠被容許本身派發更新
job.allowRecurse = !!cb

let scheduler: ReactiveEffectOptions['scheduler'] // 聲明一個調度器
if (flush === 'sync') {
  scheduler = job as any // 這個調度器函數會當即被執行
} else if (flush === 'post') {
  // 調度器會將任務推入一個延遲執行的隊列中
  scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
    // 默認狀況 'pre'
  scheduler = () => {
    if (!instance || instance.isMounted) {
      queuePreFlushCb(job)
    } else {
      // 在 pre 選型中,第一次調用必須發生在組件掛載以前
      // 因此此次調用是同步的
      job()
    }
  }
}

在處理完以上的調度器部分後,會開始建立反作用。

首先聲明一個 runner 變量,它建立一個反作用並將以前處理好的 getter 函數做爲反作用函數傳入,並在反作用選項中設置了延遲調用,以及設置了對應的調度器。

並經過 recordInstanceBoundEffect 函數將該反作用函數加入組件實例的的 effects 屬性中,好讓組件在卸載時可以主動得中止這些反作用函數的執行。

接着會開始處理首次執行反作用函數。

  • 若是 watch 有回調函數

    • 若是 watch 設置了 immediate 選項,則當即執行 job 調度器任務。
    • 不然首次執行 runner 反作用,並將返回值賦值給 oldValue。
  • 若是 flush 的刷新時機是 post,則將 runner 放入延遲時機的隊列中,等待組件掛載後執行。
  • 其他狀況都直接首次執行 runner 反作用。

最後 doWatch 函數會返回一個函數,這個函數的做用是中止偵聽,因此你們在使用時能夠顯式的爲 watch、watchEffect 調用返回值以中止偵聽。

// 建立 runner 反作用
const runner = effect(getter, {
  lazy: true,
  onTrack,
  onTrigger,
  scheduler
})

// 將 runner 添加進 instance.effects 數組中
recordInstanceBoundEffect(runner, instance)

// 初始化調用反作用
if (cb) {
  if (immediate) {
    job() // 有回調函數且是 imeediate 選項的當即執行調度器任務
  } else {
    oldValue = runner() // 不然執行一次 runner,並將返回值賦值給 oldValue
  }
} else if (flush === 'post') {
     // 若是調用時機爲 post,則推入延遲執行隊列
  queuePostRenderEffect(runner, instance && instance.suspense)
} else {
  // 其他狀況當即首次執行反作用
  runner()
}

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

doWatch 函數到這裏就所有運行完畢了,如今全部的變量已經聲明完畢,尤爲是最後聲明的 runner 反作用。咱們能夠回過頭看看被調用了屢次的 job 中究竟作了什麼。

調度器任務中作的事情邏輯比較清晰,首先會判斷 runner 反作用是否被停用,若是已經被停用則當即返回,再也不執行後續邏輯。

以後區分場景,經過是否存在回調函數判斷是 watch api 調用仍是 watchEffect api 調用。

若是是 watch api 調用,則會執行 runner 反作用,將其返回值賦值給 newValue,做爲最新的值。若是是 deep 須要深度偵聽,或者是 forceTrigger 須要強制更新,或者新舊值發生了改變,這三種狀況都須要觸發 cb 回調,通知偵聽器發生了變化。在調用偵聽器以前會先經過 cleanup 清除反作用,接着觸發 cb 回調,將 newValue、oldValue、onInvalidate 三個參數傳入回調。在回調觸發後再去更新 oldValue 的值。

而若是沒有 cb 回調函數,即爲 watchEffect 的場景,此時調度器任務僅僅須要執行 runner 反作用函數就好。

job 調度器任務中的具體代碼邏輯以下:

const job: SchedulerJob = () => {
  if (!runner.active) { // 若是反作用以停用則直接返回
    return
  }
  if (cb) {
    // watch(source, cb) 場景
    // 調用 runner 反作用獲取最新的值 newValue
    const newValue = runner()
    // 若是是 deep 或 forceTrigger 或有值更新
    if (
      deep ||
      forceTrigger ||
      (isMultiSource
        ? (newValue as any[]).some((v, i) =>
            hasChanged(v, (oldValue as any[])[i])
          )
        : hasChanged(newValue, oldValue))
    ) {
      // 當回調再次執行前先清除反作用
      if (cleanup) {
        cleanup()
      }
      // 觸發 watch api 的回調,並將 newValue、oldValue、onInvalidate 傳入
      callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
        newValue,
        // 首次調用時,將 oldValue 的值設置爲 undefined
        oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
        onInvalidate
      ])
      oldValue = newValue // 觸發回調後,更新 oldValue
    }
  } else {
    // watchEffect 的場景,直接執行 runner
    runner()
  }
}

總結

在本文中,筆者給你們詳細講解了 Vue3 中提供的 watch、watchEffect 兩個 api 的實現,而且在組件的 option 選項中的 watch,其實也是經過 doWatch 函數來完成偵聽的。在講解的過程當中,咱們發現 Vue3 中的偵聽器也是經過反作用來實現的,因此理解偵聽器以前須要先了解透徹反作用究竟作了什麼。

咱們看到 watch、watchEffect 的背後都是調用並返回 doWatch 函數,筆者拆解分析了 doWatch 函數,讓讀者可以清楚的知道 doWatch 每一行代碼都作了什麼,以便於當咱們的偵聽器不如本身預期的工做時,能夠從細節之處分析緣由,而不至於瞎猜瞎試。

最後,若是這篇文章可以幫助到你瞭解更瞭解 Vue3 中的 watch 的原理以及它的工做方式,但願能給本文點一個喜歡❤️。若是想繼續追蹤後續文章,也能夠關注個人帳號或 follow 個人 github,再次謝謝各位可愛的看官老爺。

相關文章
相關標籤/搜索