Skip to content

computed 與 watch

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

前面幾節我們已經能顯示資料、處理事件、渲染列表。接著要看兩個很常用、也很容易混淆的工具:computedwatch

簡單來說,computed 用來產生「由既有資料推導出的結果」;watch 用來在資料變動時執行副作用,例如呼叫 API、寫入 localStorage 或記錄分析事件。

computed 表示衍生資料

假設我們有一份待辦清單,畫面上要顯示未完成項目數量。這個數字不應該另外手動維護,因為它可以從 todos 推導出來:

vue
<script setup>
import { computed, ref } from 'vue'

const todos = ref([
  { id: 1, text: '建立 Vite 專案', done: true },
  { id: 2, text: '認識響應式資料', done: true },
  { id: 3, text: '練習 computed', done: false }
])

const remainingCount = computed(() =>
  todos.value.filter(todo => !todo.done).length
)
</script>

<template>
  <main class="app">
    <p>還有 {{ remainingCount }} 件事尚未完成。</p>

    <ul>
      <li v-for="todo in todos" :key="todo.id">
        {{ todo.text }}
      </li>
    </ul>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>

remainingCount 會依賴 todos。只要 todos 改變,Vue 就知道這個 computed 結果需要重新計算。

computed vs methods

同樣的結果,也可以寫成函式:

js
function getRemainingCount() {
  return todos.value.filter(todo => !todo.done).length
}

差異在於:computed 會根據依賴快取結果。只要 todos 沒變,重複讀取 remainingCount 不會重新計算;methods 則是每次呼叫都會執行一次。

實務上可以這樣判斷:

情境建議
要把既有資料推導成另一個值,並在模板中重複使用computed
使用者操作時要執行一段動作用 function
要因資料變化而做副作用watch

computed getter 要保持純粹

computed 的 getter 應該只負責計算並回傳結果,不要在裡面修改其他狀態、呼叫 API 或操作 DOM。

常見錯誤:在 computed 裡做副作用或忘記 return

下面這兩種寫法都容易出問題:

js
const total = computed(() => {
  console.log('重新計算') // 不建議:computed 應避免副作用
  todos.value.length      // 錯誤:沒有 return,結果會是 undefined
})

沒有 return 的問題很直接:getter 回傳 undefinedtotal 的值就永遠是 undefined

副作用的問題則出在執行時機:computed 有快取,而且採惰性計算。依賴改變時,Vue 只會先把快取標記為需要更新;要等到下一次有人讀取這個 computed 的值,getter 才會重新執行。所以 getter 並不是「資料一變就會觸發的 callback」。

這代表放在 getter 裡的 console.log、API 呼叫等動作,會變成「有時發生、有時不發生」,出問題時很難追查;如果是在 getter 裡修改其他響應式資料,還可能牽動其他 computed 跟著重算,讓資料流變得難以預測。

比較好的寫法是讓 computed 專心回傳結果:

js
const total = computed(() => todos.value.length)

如果真的需要在資料變動時執行副作用,請改用 watch

watch 觀察資料變化

watch 適合用在「資料變了,所以要做某件事」的場景。下面範例會在關鍵字改變時更新提示文字:

vue
<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')
const message = ref('請輸入關鍵字')

watch(keyword, (newKeyword, oldKeyword) => {
  if (newKeyword === '') {
    message.value = '請輸入關鍵字'
    return
  }

  message.value = `從「${oldKeyword}」改成「${newKeyword}」`
})
</script>

<template>
  <main class="app">
    <input v-model="keyword" type="search" placeholder="搜尋">
    <p>{{ message }}</p>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>

watch(source, callback) 的第一個參數是要觀察的資料來源,第二個參數會在資料變動時執行。callback 可以取得新值與舊值。

什麼時候不要用 watch?

如果只是要從既有資料算出另一個結果,通常不需要 watch。例如下面這種寫法,就是用 watch 手動維護衍生資料:

js
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = ref('')

watch([firstName, lastName], ([newFirstName, newLastName]) => {
  fullName.value = `${newFirstName} ${newLastName}`
})

這種情境改成 computed 會更清楚:

js
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

常見錯誤:該用 computed 卻用 watch

watch 很強大,但不要把它當成「資料加工工具」。只要結果可以由現有資料同步推導出來,先考慮 computed

watch 留給副作用,程式會比較容易追蹤。

立即執行與深層觀察

watch 預設會等資料第一次變動後才執行。如果建立 watcher 時就想先跑一次,可以加上 immediate: true

js
watch(keyword, (newKeyword) => {
  message.value = newKeyword === ''
    ? '請輸入關鍵字'
    : `搜尋:${newKeyword}`
}, { immediate: true })

如果觀察的是物件或陣列中的深層資料,則可能會需要 deep。深層觀察會遍歷巢狀屬性,因此資料很大時要謹慎使用。

watch deep 數字深度 (Vue 3.5 新增)

在 Vue 3.5 之後,deep 不只可以是 true,也可以是數字,用來限制最多往下觀察幾層。以下面範例的 profile 為例,{ deep: 2 } 的觀察範圍只到第二層:

vue
<script setup>
import { ref, watch } from 'vue'

const profile = ref({
  name: 'Ada',
  settings: {
    theme: {
      color: 'blue'
    }
  }
})

const triggeredCount = ref(0)

watch(
  profile,
  () => {
    triggeredCount.value++
  },
  { deep: 2 }
)

function replaceTheme() {
  // theme 是第二層的屬性,仍在 deep: 2 的觀察範圍內
  profile.value.settings.theme = { color: 'green' }
}

function changeColor() {
  // color 位在第三層,超出 deep: 2 的範圍
  profile.value.settings.theme.color = 'red'
}
</script>

<template>
  <main class="app">
    <p>目前顏色:{{ profile.settings.theme.color }}</p>
    <p>watch 觸發次數:{{ triggeredCount }}</p>
    <button type="button" @click="replaceTheme">
      換掉整組 theme
    </button>
    <button type="button" @click="changeColor">
      只改最深層的 color
    </button>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>

{ deep: 2 } 的意思是最多往下遍歷兩層。實際按按鈕觀察:按「換掉整組 theme」時,改動發生在第二層,watch 觸發次數會加一;按「只改最深層的 color」時,改動發生在第三層,watch 不會觸發,但畫面上的顏色照樣更新。

從這裡也可以看出,deep 只限制 watch 的觀察範圍,不影響響應式系統本身。數字深度比無限制的深層觀察更可控,但仍然要避免拿來觀察過大的資料結構。

小提醒:在 watch 裡發非同步請求要小心

watch 裡面發出非同步請求時,還會遇到「舊請求比新請求晚回來」的問題。Vue 3.5 的 onWatcherCleanup() 可以處理這類清理需求;這屬於 composables 與副作用管理的主題,CH5 會有更完整的說明。