前言
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皮膚中開啟我們的程式碼啦。如下圖
然後給watch函式打個斷點,如下圖:
接著重新整理頁面,此時程式碼將會停留在斷點出。將斷點走進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();
}
首先定義了兩個變數effect
和getter
,effect
是ReactiveEffect
類的例項。
接著就是使用isRef(source)
判斷watch監聽的是不是一個ref變數,如果是就將getter
函式賦值為getter = () => source.value
。這麼做的原因是為了保持一致(watch也可以直接監聽一個getter函式),並且後面會對這個getter函式進行讀操作觸發依賴收集。
我們知道watch的回撥中有oldValue
和newValue
這兩個欄位,在watch
函式內部有個欄位也名為oldValue
用於存舊的值。
接著就是定義了一個job
函式,我們先不看裡面的程式碼,執行這個job
函式就會執行watch的回撥。
然後執行effect = new ReactiveEffect(getter)
,這個ReactiveEffect
類是一個底層的類。在Vue的設計中,所有的訂閱者都是繼承的這個ReactiveEffect
類。比如watchEffect、computed()、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
,同時再次進行依賴收集。如果oldValue
和newValue
不相等,那麼就觸發watch的回撥,並且將oldValue
和newValue
作為引數傳過去。
關注公眾號:【前端歐陽】,給自己一個進階vue的機會
另外歐陽寫了一本開源電子書vue3編譯原理揭秘,看完這本書可以讓你對vue編譯的認知有質的提升。這本書初、中級前端能看懂,完全免費,只求一個star。