技術筆記

Vue 3 的 computed 和 watch:算出一個值,還是做一件事

computed 從現有資料推導出新的值,watch 在值改變後去做有副作用的事。分不清楚時,先問自己是在「描述狀態」還是在「執行動作」。

#Vue #面試準備

目錄

  1. 一句話
  2. computed:算出一個值
  3. watch:值變了之後去做一件事
  4. 怎麼判斷要用哪一個
  5. 寫法
  6. 例子:購物車的免運門檻
  7. 我原本的誤解
  8. 練習

1. 一句話

要「得到一個值」用 computed,要「做一件事」用 watch。

差異computedwatch
目的從現有資料算出一個值值變了之後去做一件事(副作用)
有無回傳值有,拿來在 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. 怎麼判斷要用哪一個

判斷順序

  1. 是算出一個值嗎?→ computed
  2. 是做一件事,而且觸發點很清楚(點擊、輸入、送出)?→ 寫在事件處理函式
  3. 是做一件事,但值可能從各種地方被改?→ watch
  4. 要做的事需要知道「從什麼變成什麼」?→ 用 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. 練習

分類

  1. 輸入框為空時,送出按鈕變灰
  2. 使用者切換深色模式後,記住設定,下次打開還是深色
  3. 顯示「共 N 筆,已選 M 筆」
  4. 切換到另一個商品 ID 時,載入那個商品的資料
  5. 密碼長度不到 8 碼時,顯示紅色的提示
  6. 搜尋關鍵字改變時,網址跟著變成 ?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)
})

答案

分類

  1. computed。記得先 trim(),只輸入空白也算空:computed(() => text.value.trim().length > 0)
  2. watch(寫 localStorage)。也可以寫在切換鈕的 @click,但設定可能從好幾個地方被改,用 watch 比較不會漏。另外,啟動時要把設定讀回來:ref(localStorage.getItem('dark') === 'true')
  3. computed(組字串)
  4. 看是誰改的。下拉選單切換,用 @change。網址 /product/:id 切換,用 watch(() => route.params.id, loadProduct, { immediate: true })
  5. computed(布林值,加上 v-if 或 class)
  6. watch(改網址)。實務上會加 debounce,並用 router.replace 避免塞滿瀏覽紀錄

找錯

  1. isValid 的函式用了大括號卻沒有 return,結果永遠是 undefined
  2. watch(form.email, ...) 傳進去的是字串,要改成 () => form.email
  3. watch(keyword.value, ...) 傳進去的是字串,要改成 keyword