Vue 3 的 computed 和 watch:算出一個值,還是做一件事
computed 從現有資料推導出新的值,watch 在值改變後去做有副作用的事。分不清楚時,先問自己是在「描述狀態」還是在「執行動作」。
目錄
- 一句話
- computed:算出一個值
- watch:值變了之後去做一件事
- 怎麼判斷要用哪一個
- 寫法
- 例子:購物車的免運門檻
- 我原本的誤解
- 練習
1. 一句話
要「得到一個值」用 computed,要「做一件事」用 watch。
| 差異 | computed | watch |
|---|---|---|
| 目的 | 從現有資料算出一個值 | 值變了之後去做一件事(副作用) |
| 有無回傳值 | 有,拿來在 template 或其他地方用 | 沒有,執行 callback |
| 依賴 | 自動追蹤 | 明確指定要看哪一個 |
| 舊值 | 通常不用(3.4 起 getter 可以拿到上一次的結果) | (newVal, oldVal) 都拿得到 |
| 適合 | 篩選清單、組字串、加總、判斷布林 | 打 API、寫 localStorage、改網址、送分析事件 |
2. computed:算出一個值
追蹤的是「實際讀到的」資料
Vue 不是去看函式裡寫了哪些變數,而是在執行時記下這次讀到了哪些響應式資料。有條件分支時,沒走到的分支裡的資料不會被追蹤:
const showDetail = ref(false)
const name = ref('Jason')
const detail = ref('...')
const text = computed(() => {
if (!showDetail.value) return name.value
return name.value + detail.value // showDetail 是 false 時,detail 沒被讀到
})
showDetail 是 false 時,改 detail 不會讓 text 重新計算。等 showDetail 變成 true、重算一次之後,detail 才會被加進追蹤。
資料一定要是響應式的(ref、reactive、props、其他 computed)。一般的 let x = 1 改了,Vue 不會知道。
懶計算和快取
依賴改了,computed 不會馬上重算,只會被標記成「過期」,等下次有人讀它才真的重算。依賴沒改,讀幾次都是拿快取。這是它和 method 的差別:method 每次呼叫都會重跑。
「值」跟型別無關
數字、布林、字串、陣列、物件都可以是 computed。重點在兩件事:有沒有回傳結果,以及有沒有改到別的東西。
const total = computed(() => cart.value.reduce((s, i) => s + i.price * i.qty, 0)) // 數字
const freeShipping = computed(() => total.value > 1000) // 布林
const summary = computed(() => `共 ${cart.value.length} 件,NT$ ${total.value}`) // 字串
const inStock = computed(() => products.value.filter(p => p.stock > 0)) // 陣列
常見的陷阱是 sort(),它會直接改原本的陣列,回傳的型別看起來沒問題,但已經是在 computed 裡「做事」了:
// ❌ 改到了 products
const sorted = computed(() => products.value.sort((a, b) => a.price - b.price))
// ✅ 回傳新陣列
const sorted = computed(() => products.value.toSorted((a, b) => a.price - b.price))
3. watch:值變了之後去做一件事
watch 處理的通常是 Vue 管不到的地方:API、localStorage、網址、計時器、分析事件、focus() 和捲動、第三方套件(地圖、圖表)。
const keyword = ref('')
watch(keyword, async (val) => {
result.value = await fetchSearch(val)
})
這裡沒有比較前後值,還是要用 watch,因為打 API 是副作用。
很多時候連 watch 都不用
如果值是使用者操作改的,在那個事件裡直接呼叫方法就好:
<select v-model="productId" @change="loadProduct">
watch 適合用在不知道是誰改的、或有很多地方會改的情況:
- 路由參數變了(
/product/1連到/product/2,Vue Router 會重用同一個元件,onMounted不會再跑) - props 從父元件傳進來
- Pinia store 的狀態被其他元件改
- 同一個值有好幾個地方會改,不想每個地方都記得呼叫
4. 怎麼判斷要用哪一個
判斷順序
- 是算出一個值嗎?→ computed
- 是做一件事,而且觸發點很清楚(點擊、輸入、送出)?→ 寫在事件處理函式
- 是做一件事,但值可能從各種地方被改?→ watch
- 要做的事需要知道「從什麼變成什麼」?→ 用 watch 的
(newVal, oldVal)
三個幫助判斷的問法
換成「是」或「去」的句子
- 「免運提示是:總金額大於 1000 時顯示」→ 描述狀態,computed
- 「總金額變了,去送分析事件」→ 動作,watch
拿掉畫面,這件事還要發生嗎?
- 免運提示:沒有畫面就沒意義,computed
- 分析事件:沒有畫面,伺服器還是要收到,watch
這件事碰到外部世界了嗎?
和畫面有關的幾乎都是 computed,碰到外部世界的才是 watch。
在 jQuery 或原生 JS 裡,「顯示」真的是一件事,要自己找元素、改 display。Vue 把這件事接手了,我們只描述狀態,畫面由 Vue 更新。所以在 Vue 裡,畫面上的東西幾乎都不算「做事」。
5. 寫法
以下是 Vue 3 的 <script setup>。
computed
import { computed } from 'vue'
const total = computed(() => price.value * qty.value)
console.log(total.value) // script 裡要加 .value,template 裡不用
可寫的 computed(常用在 v-model 綁定 props):
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val) => {
[firstName.value, lastName.value] = val.split(' ')
},
})
watch
import { watch } from 'vue'
watch(要看的東西, (新值, 舊值) => {
要做的事
}, { 選項 })
第一個參數最容易寫錯。傳「會變的那個東西」,不要傳「它現在的值」:
const count = ref(0)
const user = reactive({ name: 'Jason', age: 30 })
watch(count, ...) // ✅ ref 直接傳,不加 .value
watch(user, ...) // ✅ reactive 物件直接傳,自動 deep
watch(() => user.age, ...) // ✅ 物件裡的屬性要包成函式
watch([count, () => user.age], ...) // ✅ 看好幾個用陣列
watch(count.value, ...) // ❌ 傳進去的是數字 0
watch(user.age, ...) // ❌ 傳進去的是數字 30
user.age 一讀出來就是固定的數字,包成 () => user.age 才能讓 Vue 每次重讀。
選項:
| 選項 | 作用 |
|---|---|
immediate: true | 建立時先跑一次,這時舊值是 undefined |
deep: true | 看物件內部的變化。直接 watch reactive 物件預設就是 deep,watch 一個回傳物件的 getter,只有整個物件被換掉才會觸發 |
once: true | 只觸發一次(Vue 3.4 起) |
watchEffect
import { watchEffect } from 'vue'
watchEffect(() => {
localStorage.setItem('dark', String(dark.value))
})
像 computed 一樣自動追蹤讀到的資料,但用途像 watch,是做副作用的。建立時會先跑一次,拿不到舊值。
6. 例子:購物車的免運門檻
需求:顯示總金額,超過 1000 時顯示「已達免運門檻」,並送一次分析事件。
const total = computed(() =>
cart.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
const freeShipping = computed(() => total.value > 1000)
watch(freeShipping, (reached) => {
if (reached) sendEvent('free_shipping_reached')
})
<p v-if="freeShipping">已達免運門檻</p>
| 需求 | 用什麼 | 原因 |
|---|---|---|
| 總金額 | computed | 從購物車算出的值 |
| 顯示免運提示 | computed 加 v-if | 只是一個布林值,金額降回 1000 以下時會自動消失 |
| 送分析事件 | watch | 副作用 |
送事件要看的是跨過門檻的那一刻。如果直接 watch total 再判斷 val > 1000,超過之後每改一次數量就會再送一次。watch freeShipping 只有在 true 和 false 之間切換時才觸發,就沒有這個問題。
這也是 computed 和 watch 搭配的方式:computed 把狀態整理好,watch 只負責在狀態改變時做事。
7. 我原本的誤解
| 我原本以為 | 實際上 |
|---|---|
| computed 會在「列在函數裡的變數」改了之後重算 | 追蹤的是執行時實際讀到的響應式資料,沒走到的分支不算 |
| 顯示「已達免運門檻」是一件事,要用 watch | 它只是根據總金額算出的布林值,用 computed |
| 有沒有要比較前後值,是選 watch 的依據 | 依據是有沒有副作用。不比較前後值也可能要用 watch(打 API),3.4 起 computed 也拿得到上一次的值 |
| 變數改了要呼叫方法,就只能用 watch | 方法只是在算東西,放在 computed 裡沒問題。觸發點清楚的話,直接寫在事件裡 |
| 「算出一個值」是指數字、布林這類基本型別 | 跟型別無關,陣列、物件都可以。重點是有沒有改到別的東西 |
8. 練習
分類
- 輸入框為空時,送出按鈕變灰
- 使用者切換深色模式後,記住設定,下次打開還是深色
- 顯示「共 N 筆,已選 M 筆」
- 切換到另一個商品 ID 時,載入那個商品的資料
- 密碼長度不到 8 碼時,顯示紅色的提示
- 搜尋關鍵字改變時,網址跟著變成
?q=關鍵字
找錯:下面有 3 個錯誤
const form = reactive({ email: '', password: '' })
const keyword = ref('')
const isValid = computed(() => {
form.password.length >= 8
})
watch(form.email, (val) => {
console.log('email 改了', val)
})
watch(keyword.value, (val) => {
fetchSearch(val)
})
答案
分類
- computed。記得先
trim(),只輸入空白也算空:computed(() => text.value.trim().length > 0) - watch(寫 localStorage)。也可以寫在切換鈕的
@click,但設定可能從好幾個地方被改,用 watch 比較不會漏。另外,啟動時要把設定讀回來:ref(localStorage.getItem('dark') === 'true') - computed(組字串)
- 看是誰改的。下拉選單切換,用
@change。網址/product/:id切換,用watch(() => route.params.id, loadProduct, { immediate: true }) - computed(布林值,加上
v-if或 class) - watch(改網址)。實務上會加 debounce,並用
router.replace避免塞滿瀏覽紀錄
找錯
isValid的函式用了大括號卻沒有return,結果永遠是undefinedwatch(form.email, ...)傳進去的是字串,要改成() => form.emailwatch(keyword.value, ...)傳進去的是字串,要改成keyword