看不懂來打我,Vue3的watch是如何實現監聽的?

前端欧阳發表於2024-11-26

前言

watch這個API大家都很熟悉,今天這篇文章歐陽來帶你搞清楚Vue3的watch是如何實現對響應式資料進行監聽的。注:本文使用的Vue版本為3.5.13

關注公眾號:【前端歐陽】,給自己一個進階vue的機會

看個demo

我們來看個簡單的demo,程式碼如下:

<template>
  <button @click="count++">count++</button>
</template>

<script setup lang="ts">
import { ref, watch } from "vue";
const count = ref(0);
watch(count, (preVal, curVal) => {
  console.log("count is changed", preVal, curVal);
});
</script>

這個demo很簡單,使用watch監聽了響應式變數count,在watch回撥中進行了console列印。如何有個button按鈕,點選後會count++。

開始打斷點

現在我們第一個斷點應該打在哪裡呢?

我們要看watch的實現,那麼當然是給我們demo中的watch函式打個斷點。

首先執行yarn dev將我們的demo跑起來,然後在瀏覽器的network皮膚中找到對應的vue檔案,右鍵點選Open in Sources panel就可以在source皮膚中開啟我們的程式碼啦。如下圖
source

然後給watch函式打個斷點,如下圖:
debug

接著重新整理頁面,此時程式碼將會停留在斷點出。將斷點走進watch函式,程式碼如下:

function watch(source, cb, options) {
  return doWatch(source, cb, options);
}

從上面的程式碼可以看到在watch函式中直接返回了doWatch函式。

將斷點走進doWatch函式,在我們這個場景中簡化後的程式碼如下(為了方便大家理解,本文中會將scheduler任務排程相關的程式碼移除掉,因為這個不影響watch的主流程):

function doWatch(source, cb, options = EMPTY_OBJ) {
  const baseWatchOptions = extend({}, options);
  const watchHandle = baseWatch(source, cb, baseWatchOptions);
  return watchHandle;
}

從上面的程式碼可以看到底層實際是在執行baseWatch函式,而這個baseWatch就是由@vue/reactivity包中匯出的watch函式。關於這個baseWatch函式的由來可以看看歐陽之前的文章: Vue3.5新增的baseWatch讓watch函式和Vue元件徹底分手

baseWatch函式

將斷點走進baseWatch函式,在我們這個場景中簡化後的程式碼如下:

const INITIAL_WATCHER_VALUE = {}

function watch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb?: WatchCallback | null,
  options: WatchOptions = EMPTY_OBJ
): WatchHandle {
  let effect: ReactiveEffect;
  let getter: () => any;

  if (isRef(source)) {
    getter = () => source.value;
  }

  let oldValue: any = INITIAL_WATCHER_VALUE;

  const job = () => {
    if (cb) {
      const newValue = effect.run();
      if (hasChanged(newValue, oldValue)) {
        const args = [
          newValue,
          oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
          boundCleanup,
        ];

        cb(...args);
        oldValue = newValue;
      }
    }
  };
  effect = new ReactiveEffect(getter);
  effect.scheduler = job;

  oldValue = effect.run();
}

首先定義了兩個變數effectgettereffectReactiveEffect類的例項。

接著就是使用isRef(source)判斷watch監聽的是不是一個ref變數,如果是就將getter函式賦值為getter = () => source.value。這麼做的原因是為了保持一致(watch也可以直接監聽一個getter函式),並且後面會對這個getter函式進行讀操作觸發依賴收集。

我們知道watch的回撥中有oldValuenewValue這兩個欄位,在watch函式內部有個欄位也名為oldValue用於存舊的值。

接著就是定義了一個job函式,我們先不看裡面的程式碼,執行這個job函式就會執行watch的回撥。

然後執行effect = new ReactiveEffect(getter),這個ReactiveEffect類是一個底層的類。在Vue的設計中,所有的訂閱者都是繼承的這個ReactiveEffect。比如watchEffectcomputed()、render函式等。

在我們這個場景中new ReactiveEffect時傳入的getter函式就是getter = () => source.value,這裡的source就是watch監聽的響應式變數count

接著將job函式賦值給effect.scheduler屬性,在ReactiveEffect類中依賴觸發時就會執行effect.scheduler方法(接下來會講)。

最後就是執行effect.run()拿到初始化時watch監聽變數的值,這個run方法也是在ReactiveEffect類中。接下來也會講。

ReactiveEffect

前面我們講過了ReactiveEffect是Vue的一個底層類,所有的訂閱者都是繼承的這個類。將斷點走進ReactiveEffect類,在我們這個場景中簡化後的程式碼如下:

class ReactiveEffect<T = any> implements Subscriber, ReactiveEffectOptions {
  constructor(fn) {
    this.fn = fn;
  }

  run(): T {
    const prevEffect = activeSub;
    activeSub = this;
    try {
      return this.fn();
    } finally {
      activeSub = prevEffect;
    }
  }

  trigger(): void {
    this.scheduler();
  }
}

在new一個ReactiveEffect例項時傳入的getter函式會賦值給例項的fn方法。(實際的ReactiveEffect程式碼比這個要複雜很多,感興趣的同學可以去看原始碼)

我們回到前面講過的baseWatch函式中的最後一塊:oldValue = effect.run()。這裡執行了effect例項的run方法拿到watch監聽變數的值,並且賦值給oldValue變數。

因為我們如果不使用immediate: true,那麼Vue會等watch監聽的變數改變後才會觸發watch回撥,回撥中有個欄位叫oldValue,這個oldValue就是初始化時執行run方法拿到的。

比如我們這裡count初始化的值是0,初始化執行oldValue = effect.run()後就會給oldValue賦值為0。當點選count++按鈕後,count的值就變成了1,所以在watch回撥第一次觸發的時候他就知道oldValue的值是0啦。

除此之外,在run方法中還有收集依賴的作用。Vue維護了一個全域性變數activeSub表示當前active的訂閱者是誰,在同一時間只可能有一個active的訂閱者,不然觸發get攔截進行依賴收集時就不知道該把哪個訂閱者給收集了。

run方法中將當前的activeSub給存起來,等下面的程式碼執行完了後將全域性變數activeSub改回去。

接著就是執行activeSub = this;將當前的watch設定為全域性變數activeSub

接下來就是執行return this.fn(),前面我們講過了這個this.fn()方法就是watch監聽的getter函式。由於我們watch監聽的是一個響應式變數count,在前面處理後他的getter函式就是getter = () => source.value;。這裡的source就是watch監聽的變數,這個getter函式實際就是getter = () => count.value;

那麼這裡執行return this.fn()就是執行() => count.value,將會觸發響應式變數count的get攔截。在get攔截中會進行依賴收集,由於此時的全域性變數activeSub已經變成了訂閱者watch,所以響應式變數count在依賴收集的過程中收集的訂閱者就是watch。這樣響應式變數count就和訂閱者watch建立了依賴收集的關係。關於Vue3.5依賴收集和依賴觸發可以看看歐陽之前的文章: 看不懂來打我!讓效能提升56%的Vue3.5響應式重構

當我們點選count++後會修改響應式變數count的值,就會進行依賴觸發,經過一堆操作後最後就會執行到這裡的trigger方法中。在trigger方法中直接執行this.scheduler(),在前面已經對scheduler方法進行了賦值,回憶一下baseWatch函式的程式碼。如下:

const INITIAL_WATCHER_VALUE = {}

function watch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb?: WatchCallback | null,
  options: WatchOptions = EMPTY_OBJ
): WatchHandle {
  let effect: ReactiveEffect;
  let getter: () => any;

  if (isRef(source)) {
    getter = () => source.value;
  }

  let oldValue: any = INITIAL_WATCHER_VALUE;

  const job = () => {
    if (cb) {
      const newValue = effect.run();
      if (hasChanged(newValue, oldValue)) {
        const args = [
          newValue,
          oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
          boundCleanup,
        ];

        cb(...args);
        oldValue = newValue;
      }
    }
  };
  effect = new ReactiveEffect(getter);
  effect.scheduler = job;

  oldValue = effect.run();
}

這裡將job函式賦值給effect.scheduler方法,所以當響應式變數count的值改變後實際就是在執行這裡的job函式。

job函式中首先判斷是否有傳入watch的callback函式,然後執行const newValue = effect.run()

執行這行程式碼有兩個作用:

第一個作用是重新執行getter函式,也就是getter = () => count.value;,拿到最新count的值,將其賦值給newValue

第二個作用是watch除了監聽響應式變數之外還可以監聽一個getter函式,那麼在getter函式中就可以類似computed一樣在某些條件下監聽變數A,某些條件下監聽變數B。這裡的第二個作用是重新收集依賴,因為此時watch可能從監聽變數A變成了監聽變數B。

接著就是執行if (hasChanged(newValue, oldValue))判斷watch監聽的變數新的值和舊的值是否相等,如果不相等才去執行cb(...args)觸發watch的回撥。最後就是將當前的newValue賦值給oldValue,下次觸發watch回撥時作為oldValue欄位。

總結

這篇文章講了watch如何對響應式變數進行監聽,其實底層依賴的是@vue/reactivity包的baseWatch函式。在baseWatch函式中會使用ReactiveEffect類new一個effect例項,這個ReactiveEffect類是一個底層的類,Vue的訂閱者都是基於這個類去實現的。

如果沒有使用immediate: true,初始化時會去執行一次effect.run()對watch監聽的響應式變數進行讀操作並且將其賦值給oldValue。讀操作會觸發get攔截進行響應式變數的依賴收集,會將當前watch作為訂閱者進行收集。

當響應式變數的值改變後會觸發set攔截,進而依賴觸發。前一步將watch也作為訂閱者進行了收集,依賴觸發時也會通知到watch,所以此時會執行watch中的job函式。在job函式中會再次執行effect.run()拿到響應式變數最新的值賦值給newValue,同時再次進行依賴收集。如果oldValuenewValue不相等,那麼就觸發watch的回撥,並且將oldValuenewValue作為引數傳過去。

關注公眾號:【前端歐陽】,給自己一個進階vue的機會

另外歐陽寫了一本開源電子書vue3編譯原理揭秘,看完這本書可以讓你對vue編譯的認知有質的提升。這本書初、中級前端能看懂,完全免費,只求一個star。

相關文章