一文詳解Vue3的watch是如何實(shí)現(xiàn)監(jiān)聽的
前言
watch這個API大家都很熟悉,今天這篇文章歐陽來帶你搞清楚Vue3的watch是如何實(shí)現(xiàn)對響應(yīng)式數(shù)據(jù)進(jìn)行監(jiān)聽的。注:本文使用的Vue版本為3.5.13。
看個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監(jiān)聽了響應(yīng)式變量count,在watch回調(diào)中進(jìn)行了console打印。如何有個button按鈕,點(diǎn)擊后會count++。
開始打斷點(diǎn)
現(xiàn)在我們第一個斷點(diǎn)應(yīng)該打在哪里呢?
我們要看watch的實(shí)現(xiàn),那么當(dāng)然是給我們demo中的watch函數(shù)打個斷點(diǎn)。
首先執(zhí)行yarn dev將我們的demo跑起來,然后在瀏覽器的network面板中找到對應(yīng)的vue文件,右鍵點(diǎn)擊Open in Sources panel就可以在source面板中打開我們的代碼啦。如下圖

然后給watch函數(shù)打個斷點(diǎn),如下圖:

接著刷新頁面,此時代碼將會停留在斷點(diǎn)出。將斷點(diǎn)走進(jìn)watch函數(shù),代碼如下:
function watch(source, cb, options) {
return doWatch(source, cb, options);
}
從上面的代碼可以看到在watch函數(shù)中直接返回了doWatch函數(shù)。
將斷點(diǎn)走進(jìn)doWatch函數(shù),在我們這個場景中簡化后的代碼如下(為了方便大家理解,本文中會將scheduler任務(wù)調(diào)度相關(guān)的代碼移除掉,因?yàn)檫@個不影響watch的主流程):
function doWatch(source, cb, options = EMPTY_OBJ) {
const baseWatchOptions = extend({}, options);
const watchHandle = baseWatch(source, cb, baseWatchOptions);
return watchHandle;
}
從上面的代碼可以看到底層實(shí)際是在執(zhí)行baseWatch函數(shù),而這個baseWatch就是由@vue/reactivity包中導(dǎo)出的watch函數(shù)。關(guān)于這個baseWatch函數(shù)的由來可以看看歐陽之前的文章: Vue3.5新增的baseWatch讓watch函數(shù)和Vue組件徹底分手
baseWatch函數(shù)
將斷點(diǎn)走進(jìn)baseWatch函數(shù),在我們這個場景中簡化后的代碼如下:
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類的實(shí)例。
接著就是使用isRef(source)判斷watch監(jiān)聽的是不是一個ref變量,如果是就將getter函數(shù)賦值為getter = () => source.value。這么做的原因是為了保持一致(watch也可以直接監(jiān)聽一個getter函數(shù)),并且后面會對這個getter函數(shù)進(jìn)行讀操作觸發(fā)依賴收集。
我們知道watch的回調(diào)中有oldValue和newValue這兩個字段,在watch函數(shù)內(nèi)部有個字段也名為oldValue用于存舊的值。
接著就是定義了一個job函數(shù),我們先不看里面的代碼,執(zhí)行這個job函數(shù)就會執(zhí)行watch的回調(diào)。
然后執(zhí)行effect = new ReactiveEffect(getter),這個ReactiveEffect類是一個底層的類。在Vue的設(shè)計(jì)中,所有的訂閱者都是繼承的這個ReactiveEffect類。比如watchEffect、computed()、render函數(shù)等。
在我們這個場景中new ReactiveEffect時傳入的getter函數(shù)就是getter = () => source.value,這里的source就是watch監(jiān)聽的響應(yīng)式變量count。
接著將job函數(shù)賦值給effect.scheduler屬性,在ReactiveEffect類中依賴觸發(fā)時就會執(zhí)行effect.scheduler方法(接下來會講)。
最后就是執(zhí)行effect.run()拿到初始化時watch監(jiān)聽變量的值,這個run方法也是在ReactiveEffect類中。接下來也會講。
ReactiveEffect類
前面我們講過了ReactiveEffect是Vue的一個底層類,所有的訂閱者都是繼承的這個類。將斷點(diǎn)走進(jìn)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實(shí)例時傳入的getter函數(shù)會賦值給實(shí)例的fn方法。(實(shí)際的ReactiveEffect代碼比這個要復(fù)雜很多,感興趣的同學(xué)可以去看源代碼)
我們回到前面講過的baseWatch函數(shù)中的最后一塊:oldValue = effect.run()。這里執(zhí)行了effect實(shí)例的run方法拿到watch監(jiān)聽變量的值,并且賦值給oldValue變量。
因?yàn)槲覀內(nèi)绻皇褂?code>immediate: true,那么Vue會等watch監(jiān)聽的變量改變后才會觸發(fā)watch回調(diào),回調(diào)中有個字段叫oldValue,這個oldValue就是初始化時執(zhí)行run方法拿到的。
比如我們這里count初始化的值是0,初始化執(zhí)行oldValue = effect.run()后就會給oldValue賦值為0。當(dāng)點(diǎn)擊count++按鈕后,count的值就變成了1,所以在watch回調(diào)第一次觸發(fā)的時候他就知道oldValue的值是0啦。
除此之外,在run方法中還有收集依賴的作用。Vue維護(hù)了一個全局變量activeSub表示當(dāng)前active的訂閱者是誰,在同一時間只可能有一個active的訂閱者,不然觸發(fā)get攔截進(jìn)行依賴收集時就不知道該把哪個訂閱者給收集了。
在run方法中將當(dāng)前的activeSub給存起來,等下面的代碼執(zhí)行完了后將全局變量activeSub改回去。
接著就是執(zhí)行activeSub = this;將當(dāng)前的watch設(shè)置為全局變量activeSub。
接下來就是執(zhí)行return this.fn(),前面我們講過了這個this.fn()方法就是watch監(jiān)聽的getter函數(shù)。由于我們watch監(jiān)聽的是一個響應(yīng)式變量count,在前面處理后他的getter函數(shù)就是getter = () => source.value;。這里的source就是watch監(jiān)聽的變量,這個getter函數(shù)實(shí)際就是getter = () => count.value;
那么這里執(zhí)行return this.fn()就是執(zhí)行() => count.value,將會觸發(fā)響應(yīng)式變量count的get攔截。在get攔截中會進(jìn)行依賴收集,由于此時的全局變量activeSub已經(jīng)變成了訂閱者watch,所以響應(yīng)式變量count在依賴收集的過程中收集的訂閱者就是watch。這樣響應(yīng)式變量count就和訂閱者watch建立了依賴收集的關(guān)系。關(guān)于Vue3.5依賴收集和依賴觸發(fā)可以看看歐陽之前的文章: 看不懂來打我!讓性能提升56%的Vue3.5響應(yīng)式重構(gòu)
當(dāng)我們點(diǎn)擊count++后會修改響應(yīng)式變量count的值,就會進(jìn)行依賴觸發(fā),經(jīng)過一堆操作后最后就會執(zhí)行到這里的trigger方法中。在trigger方法中直接執(zhí)行this.scheduler(),在前面已經(jīng)對scheduler方法進(jìn)行了賦值,回憶一下baseWatch函數(shù)的代碼。如下:
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函數(shù)賦值給effect.scheduler方法,所以當(dāng)響應(yīng)式變量count的值改變后實(shí)際就是在執(zhí)行這里的job函數(shù)。
在job函數(shù)中首先判斷是否有傳入watch的callback函數(shù),然后執(zhí)行const newValue = effect.run()。
執(zhí)行這行代碼有兩個作用:
第一個作用是重新執(zhí)行g(shù)etter函數(shù),也就是getter = () => count.value;,拿到最新count的值,將其賦值給newValue。
第二個作用是watch除了監(jiān)聽響應(yīng)式變量之外還可以監(jiān)聽一個getter函數(shù),那么在getter函數(shù)中就可以類似computed一樣在某些條件下監(jiān)聽變量A,某些條件下監(jiān)聽變量B。這里的第二個作用是重新收集依賴,因?yàn)榇藭rwatch可能從監(jiān)聽變量A變成了監(jiān)聽變量B。
接著就是執(zhí)行if (hasChanged(newValue, oldValue))判斷watch監(jiān)聽的變量新的值和舊的值是否相等,如果不相等才去執(zhí)行cb(...args)觸發(fā)watch的回調(diào)。最后就是將當(dāng)前的newValue賦值給oldValue,下次觸發(fā)watch回調(diào)時作為oldValue字段。
總結(jié)
這篇文章講了watch如何對響應(yīng)式變量進(jìn)行監(jiān)聽,其實(shí)底層依賴的是@vue/reactivity包的baseWatch函數(shù)。在baseWatch函數(shù)中會使用ReactiveEffect類new一個effect實(shí)例,這個ReactiveEffect類是一個底層的類,Vue的訂閱者都是基于這個類去實(shí)現(xiàn)的。
如果沒有使用immediate: true,初始化時會去執(zhí)行一次effect.run()對watch監(jiān)聽的響應(yīng)式變量進(jìn)行讀操作并且將其賦值給oldValue。讀操作會觸發(fā)get攔截進(jìn)行響應(yīng)式變量的依賴收集,會將當(dāng)前watch作為訂閱者進(jìn)行收集。
當(dāng)響應(yīng)式變量的值改變后會觸發(fā)set攔截,進(jìn)而依賴觸發(fā)。前一步將watch也作為訂閱者進(jìn)行了收集,依賴觸發(fā)時也會通知到watch,所以此時會執(zhí)行watch中的job函數(shù)。在job函數(shù)中會再次執(zhí)行effect.run()拿到響應(yīng)式變量最新的值賦值給newValue,同時再次進(jìn)行依賴收集。如果oldValue和newValue不相等,那么就觸發(fā)watch的回調(diào),并且將oldValue和newValue作為參數(shù)傳過去。
到此這篇關(guān)于一文詳解Vue3的watch是如何實(shí)現(xiàn)監(jiān)聽的的文章就介紹到這了,更多相關(guān)Vue3 watch實(shí)現(xiàn)監(jiān)聽內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Vue.js實(shí)現(xiàn)按鈕的動態(tài)綁定效果及實(shí)現(xiàn)代碼
本文通過實(shí)例代碼給大家介紹了Vue.js實(shí)現(xiàn)按鈕的動態(tài)綁定效果,代碼簡單易懂,非常不錯,具有參考借鑒價值,需要的的朋友參考下吧2017-08-08
vue3-pinia-ts項(xiàng)目中的使用示例詳解
這篇文章主要介紹了vue3-pinia-ts項(xiàng)目中的使用,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-08-08
vue3中emit('update:modelValue')使用簡單示例
這篇文章主要給大家介紹了關(guān)于vue3中emit('update:modelValue')使用的相關(guān)資料,文中通過實(shí)例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2022-09-09
富文本編輯器vue2-editor實(shí)現(xiàn)全屏功能
這篇文章主要介紹了富文本編輯器vue2-editor實(shí)現(xiàn)全屏功能,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價值 ,需要的朋友可以參考下2019-05-05
關(guān)于vue.js組件數(shù)據(jù)流的問題
本篇文章主要介紹了關(guān)于vue.js組件數(shù)據(jù)流的問題,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-07-07
Vue2中無法監(jiān)聽數(shù)組和對象的某些變化問題
這篇文章主要介紹了Vue2中無法監(jiān)聽數(shù)組和對象的某些變化問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-08-08

