
不可能放這種迷因圖八
安安搭家,還記得我們在 computed 的章節 講到的嗎:
不應該在 computed 物件其中像上述談到的:變更值、狀態,非同步等等。
官方文件:在之後的指引中我們會討論如何使用偵聽器根據其他響應式狀態的變更來創建副作用。
本菜跟著 Vue 的指引,乘風破浪地來還債了!
然後今天的內容也是炸多,請看~
- 偵聽器定義、語法
- 偵聽數據類型
- 即時回調的偵聽器
- 一次性偵聽器
- watchEffect
- watch vs. watchEffect
- 回調的觸發時機(Post Watchers、同步偵聽器)
- 停止偵聽器


Let's GET IT!
watch 是組合式 API 中,用來偵聽響應式狀態的變化、觸發 callback function 達成邏輯的工具,例如:API 請求、非同步,並適合用於處理響應式數據副作用。
watch(要偵聽的參數, (參數變更前的值, 參數變更後的值) => {
  // 當參數變更時,執行的邏輯
});
注意:偵聽器必須以同步的方式去定義,並會自動於該組件卸載時停止偵聽。
而非同步語句定義的偵聽器,會需要額外去停止它,可能會造成一些不必要的作用(後面會提到)。
語法內的參數可以包含這些類型。
- ref(包含 computed ref)
- reactive
- getter 函式
- 陣列
watch 可以偵聽 ref 響應式狀態的數據變動。
以計數器作為範例:
<script setup>
import { ref, watch } from "vue";
const count = ref(0);
watch(count, (newValue, oldValue) => {
  console.log(`count 變更: ${oldValue} -> ${newValue}`);
});
</script>
<template>
  <button @click="count++">計數器:{{ count }}</button>
</template>
watch 監聽了 count 的數據變動。count 的值變化時,watch 的 callback function 會被觸發,其中 newValue 是 count 的新的值,oldValue 是舊的值。count++,count 的值改變,執行 watch 的 callback,印出 console.log(`countDouble 變更: ${oldValue} -> ${newValue}`); 這段邏輯。看看瀏覽器上的呈現:
我們以剛剛 ref 的例子做 computed 的變化~
將 count 交給 computed 做 *2 計算!
<script setup>
import { ref, watch, computed } from "vue";
const count = ref(0);
const countDouble = computed(() => {
  return count.value * 2;
});
watch(countDouble, (newValue, oldValue) => {
  console.log(`countDouble 變更: ${oldValue} -> ${newValue}`);
});
</script>
<template>
  <button @click="count++">計數器:{{ count }}</button>
</template>
watch 偵聽了 countDouble 的數據變動。countDouble 的值變化時,watch 的 callback function 會被觸發,其中 newValue 是 countDouble 的新值,oldValue 是舊的值。count++,而 countDouble 由於是 computed 函式,依賴於響應式狀態 count,當 count 有資料變動時,countDouble 會自動重新計算為 count * 2。countDouble 的值改變,執行 watch 的 callback,印出  console.log(`countDouble 變更: ${oldValue} -> ${newValue}`); 這段邏輯。看看瀏覽器上的呈現:
官方文件:直接給 watch() 傳入一個響應式對象,會隱式地創建一個深層偵聽器——該回調函數在所有嵌套的變更時都會被觸發
深層偵聽器,也就是會偵聽物件內部「所有屬性」的變化,當任何屬性有更新,都會觸發 watch。
上範例!
<script setup>
import { reactive, watch } from "vue";
const ageData = reactive({
  name: "Jami",
  age: 29,
});
const addAge = function () {
  ageData.age++;
};
watch(
  ageData,
  (newAge, oldAge) => {
    console.log(`吃完湯圓長一歲: ${oldAge.age}歲 -> ${newAge.age}歲`);
  }
);
</script>
<template>
  <button @click="addAge">吃一顆湯圓</button>
  <p>{{ ageData.name }} {{ ageData.age }} 歲了</p>
</template>
watch 監聽了 ageData 這個響應式物件的深層數據變動。ageData.age,會導致 watch 在初始化時立即求值,並將這個值作為偵聽的對象,即使值有變動,也不會在將來的更新中重新評估。addAge,執行 ageData.age++;,當 ageData.age 的值改變時,執行 watch 的 callback,印出  console.log(`吃完湯圓長一歲: ${oldAge}歲 -> ${newAge}歲`); 這段邏輯。看看瀏覽器上的呈現:
官方文件有提到,這樣的操作會影響效能,所以使用上要注意!
深度偵聽需要遍歷被偵聽對象中的所有嵌套的屬性,當用於大型數據結構時,開銷很大。因此請只在必要時才使用它,並且要留意性能。
若傳入的響應式「物件」、或「陣列」不是深層偵聽,我們可以使用 deep: true 參數將其「強制轉成深層偵聽」。
watch(要偵聽的參數, (參數變更前的值, 參數變更後的值) => {
  // 當參數變更時,執行的邏輯 ,
  { deep: true }
});
我們先來看一個淺層偵聽的例子:
<script setup>
import { reactive, watch } from "vue";
const ageData = reactive({
  name: "Jami",
  age: 29,
});
const addAge = function () {
  ageData.age++;
};
watch(
  () => ageData,
  (newAge, oldAge) => {
    console.log(
      `() => ageData 偵聽器觸發!吃完湯圓長一歲:${oldAge.age}歲 -> ${newAge.age}歲`
    );
  }
);
</script>
watch 偵聽 () => ageData 參數,為淺層偵聽,因為是偵聽 ageData 的指向(因為它本身會回傳 ageData 自己),因此不會觸發 watch。
套用到剛剛的例子上試試看!
<script setup>
import { reactive, watch } from "vue";
const ageData = reactive({
  name: "Jami",
  age: 29,
});
const addAge = function () {
  ageData.age++;
};
watch(
  () => ageData,
  (newAge, oldAge) => {
    console.log(
      `強制啟用 deep : true!吃完湯圓長一歲:${oldAge.age}歲 -> ${newAge.age}歲`
    );
  },
  { deep: true }
);
</script>
<template>
  <button @click="addAge">吃一顆湯圓</button>
  <p>{{ ageData.name }} {{ ageData.age }} 歲了</p>
</template>
成功強迫 watch 偵聽到其內的屬性了!

以點擊按鈕觸發「物件內屬性值」的變化,觸發 watch 做範例:
<script setup>
import { reactive, watch } from "vue";
const personData = reactive({
  height: 1.65,
  weight: 99.9,
});
const getBmi = () => {
  return personData.weight / personData.height ** 2;
};
watch(
  () => getBmi(),
  (newBmi, oldBmi) => {
    console.log("BMI 變化:", oldBmi, "->", newBmi);
  }
);
const fatter = function () {
  personData.weight += 10; // 這裡直接賦值,會觸發 BMI 的重新計算
};
</script>
<template>
  <button @click="fatter">變胖</button>
  <p>bmi 變成:{{ getBmi() }}</p>
</template>
watch 監聽了 () => getBmi() 的數據變動,getBmi() 是對 personData 內屬性的計算。fatter,其中會執行 personData.weight += 10;,personData 內屬性值改變時,會 watch 的 callback,印出  console.log("BMI 變化:", oldBmi, "->", newBmi); 這段邏輯。 
也可以用陣列的形式偵聽多個數據,其中陣列的元素就為上述提到的幾種數據類型。
官方文件:watch 默認是懶執行的:僅當數據源變化時,才會執行回調。
可以透過傳入 immediate: true,在 watch 中指定:於「初始掛載完成」時,就進行初次偵聽,且在數據變動時繼續偵聽。
watch(要偵聽的參數, (參數變更前的值, 參數變更後的值) => {
  // 當參數變更時,執行的邏輯 ,
  { immediate: true }
});
我們以剛剛的 getter 例子為例,在 watch 中加上 immediate: true!
watch(
  () => getBmi(),
  (newBmi, oldBmi) => {
    console.log("BMI 變化:", oldBmi, "->", newBmi);
  },
  { immediate: true }
);
瀏覽器渲染好了後~會先執行 watch 中的邏輯。
(這邊由於還沒有 newValue,所以是 undefined)
可以透過傳入 once: true 參數,在 watch 中指定:只進行「單次」偵聽。
(此為 3.4+ 版本以上的語法哦)
watch(要偵聽的參數, (參數變更前的值, 參數變更後的值) => {
  // 當參數變更時,執行的邏輯 ,
  { once: true }
});
我們看看範例~
watch(
  () => getBmi(),
  (newBmi, oldBmi) => {
    console.log("BMI 變化:", oldBmi, "->", newBmi);
  },
  { once: true }
);
因此只觸發了一次的偵聽器,印出一次的 BMI 變化。
官方文件:偵聽器的回調使用與源完全相同的響應式狀態是很常見的。
watchEffect() 允許我們自動跟蹤回調的響應式依賴。
如我們上述使用的所有範例,在 watch 中,我們需要設置第一個參數為:欲追蹤的響應式狀態,第二個參數再定義:要對第一個參數執行的 callback 邏輯。watchEffect 則「無需明確指定」偵聽目標,它可以幫我們「自動追蹤」callback 中的響應式狀態。
watchEffect(() => {
  // 當響應式狀態發生變化時執行的邏輯
});
immediate: true 的作用,會在初次渲染時,自動執行 callback。watch 明確指定參數,會自動追蹤 callback 中使用到的響應式狀態。computed)。watchEffect 的依賴追蹤是同步進行的,非同步中的響應式狀態不會被追蹤。我們用 bmi 的例子來試試看:
<script setup>
import { reactive, watchEffect } from "vue";
const personData = reactive({
  height: 1.65,
  weight: 99.9,
});
watchEffect(() => {
  const bmi = personData.weight / personData.height ** 2;
  console.log(`當前 BMI: ${bmi.toFixed(2)}`);
});
// 增加體重
const fatter = () => {
  personData.weight += 10;
};
// 增加身高
const taller = () => {
  personData.height += 0.1;
};
</script>
<template>
  <button @click="fatter">變胖</button>
  <button @click="taller">變高</button>
  <p>BMI 變成:{{ (personData.weight / personData.height ** 2).toFixed(2) }}</p>
</template>
注意這邊~
我們不再定義我們要偵聽的參數,而是直接寫 callback,watchEffect 會直接偵聽我們在其中使用的響應式狀態。
watchEffect(() => {
  const bmi = personData.weight / personData.height ** 2;
  console.log(`當前 BMI: ${bmi.toFixed(2)}`);
});
其中當 personData 的任何一個屬性發生變化時,watchEffect 都會重新執行 callback,進而更新計算。
| 特性 | watch | watchEffect | 
|---|---|---|
| 依賴追蹤方式 | 明確指定偵聽參數 | 自動追蹤在 callback 中定義的所有響應式狀態 | 
| callback 觸發時機 | 只有在參數被改變時觸發 | 每次響應式狀態改變時都會觸發 | 
| 副作用中的響應式依賴 | 不自動追蹤 | 自動追蹤 | 
| 適用場景 | 需要明確監控特定的變化時 | 簡化語法、需要自動追蹤多個響應式狀態時 | 
當響應式狀態更新時,Vue 組件的更新和偵聽器的 callback 可能會同時被觸發。
正如我們在「生命週期」章節中提到的,當組件進入更新階段時,若有多個資料需要更新,Vue 只會進行一次更新,並在此期間同時更新所有相關資料。
而偵聽器的 callback 觸發時機是在:父組件更新完成後、子組件更新之前。
所以當我們在其中使用子組件的 DOM 時,該 DOM 仍處於更新前的狀態。
而我們可以利用一些語法,讓我們在適當時機操作 DOM。
指的是:可以於響應式狀態變化、所有 DOM 更新完成「之後」執行偵聽的 callback。
我們可以使用以下幾種語法。
flush: 'post'watch(要偵聽的參數, (參數變更前的值, 參數變更後的值) => {
  // 當參數變更時,執行的邏輯
  flush: 'post';
});
watchEffect(() => {
  // 當響應式狀態發生變化時執行的邏輯
  flush: 'post';
});  
也就是上述 watchEffect() 的改寫:
watchPostEffect(() => {
  /* 在 Vue 更新後執行 */
})
指的是:可以於響應式狀態變化、所有 DOM 更新完成「之前」執行偵聽的 callback。
我們可以使用以下幾種語法。
 flush: 'sync'watch(要偵聽的參數, (參數變更前的值, 參數變更後的值) => {
  // 當參數變更時,執行的邏輯
   flush: 'sync';
});
watchEffect(() => {
  // 當響應式狀態發生變化時執行的邏輯
   flush: 'sync';
});  
也就是上述 watchEffect() 的改寫:
watchSyncEffect(() => {
  /* 在 Vue 更新後執行 */
})
同步偵聽器和上述一般的偵聽行為不同,不會於更新階段處理多個偵聽操作,只會單次的觸發,因此官方建議請使用作為偵聽簡單的布林值即可,避免較複雜的使用。
稍早我們有提到,使用非同步語句定義的偵聽器會需要「手動」去停止它。
我們可以看看官方提供的例子:
<script setup>
import { watchEffect } from 'vue'
// 它會自動停止
watchEffect(() => {})
// ...這個則不會!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>
在 setTimeout 中使用的 watchEffect 不會自動停止。
而我們可以使用「watch / watchEffect retrun 的函式」停止它:
const unwatch = watchEffect(() => {})
// ...當該偵聽器不再需要時
unwatch()
非同步可以在邏輯中使用 if 條件判斷:
// 需要非同步請求得到的資料
const data = ref(null)
watchEffect(() => {
  if (data.value) {
  // 資料變動後執行的某些操作...
  }
})
得說一下⋯⋯偵聽器這三個字不知道哪裡來的氣場,看了就覺得超可怕,但~今日的還債之旅自己覺得走的還 ok,只是還不太能想像實際情況中怎麼使用 Q,好像還沒有真的遇到需要施作這種情境的東西(同場加映:欸你是要J案了沒?)
但沒有關係,之後遇到就再回來找回記憶就好。
我們~~~明~天見嚕。


(咻咻咻咻咻咻咻咻咻咻咻咻趕路中)
https://github.com/Jamixcs/2024iThome-jamixcs/tree/main/src/components/day25