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

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

?? 儲備知識要求:

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

watchEffect

由于 watch api 中的許多行為都與 watchEffect api 一致,所以筆者將 watchEffect 放在首位講解,為了根據響應式狀態自動應用和重新應用副作用,我們可以使用 watchEffect 方法。它立即執行傳入的一個函數,同時響應式追蹤其依賴,并在以來變更時重新運行該函數。

watchEffect 函數的實現非常簡潔:

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,再次謝謝各位可愛的看官老爺。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容