Vue Watch 監聽器

Watch(監聽器)讓你可以在資料變化時執行副作用(side effects),例如 API 呼叫、操作 DOM、執行非同步操作等。Vue 3 提供了 watchwatchEffect 兩種監聽方式。

watch

watch() 用於監聽一個或多個響應式資料,並在資料變化時執行回呼函式:

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

const count = ref(0)

// 監聽 ref
watch(count, (newValue, oldValue) => {
  console.log(`count 從 ${oldValue} 變成 ${newValue}`)
})
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

監聽來源的類型

watch 的第一個參數(監聽來源)可以是:

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

const count = ref(0)
const state = reactive({ name: 'Vue' })

// 1. ref
watch(count, (newVal) => {
  console.log('count changed:', newVal)
})

// 2. getter 函式
watch(
  () => state.name,
  (newVal) => {
    console.log('state.name changed:', newVal)
  }
)

// 3. 陣列(監聽多個來源)
watch(
  [count, () => state.name],
  ([newCount, newName], [oldCount, oldName]) => {
    console.log('count or name changed')
  }
)
</script>

監聽 reactive 物件

監聽整個 reactive 物件時,會自動開啟深層監聽:

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

const state = reactive({
  user: {
    name: 'John',
    age: 30
  }
})

// 監聽整個物件 - 自動深層監聽
watch(state, (newState) => {
  console.log('state changed')
})

// 深層屬性變化也會觸發
state.user.name = 'Jane'
</script>

如果只想監聽特定屬性,使用 getter 函式:

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

const state = reactive({ count: 0, name: 'Vue' })

// 只監聽 count
watch(
  () => state.count,
  (newCount) => {
    console.log('count changed:', newCount)
  }
)
</script>

深層監聽(deep)

當監聽的是一個 ref 包裝的物件時,預設不會深層監聽:

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

const user = ref({
  name: 'John',
  address: { city: 'Taipei' }
})

// 預設不會監聽深層變化
watch(user, (newUser) => {
  console.log('user changed')  // 不會被觸發
})

user.value.address.city = 'Kaohsiung'  // 不會觸發上面的 watch

// 使用 deep 選項啟用深層監聽
watch(
  user,
  (newUser) => {
    console.log('user changed (deep)')  // 會被觸發
  },
  { deep: true }
)
</script>
深層監聽需要遍歷物件的所有屬性,對於大型物件可能會有效能問題。

立即執行(immediate)

預設情況下,watch 只有在監聽的資料變化時才會執行。如果需要在建立時就執行一次,使用 immediate 選項:

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

const userId = ref(1)

watch(
  userId,
  async (newId) => {
    // 獲取使用者資料
    const response = await fetch(`/api/users/${newId}`)
    const user = await response.json()
    console.log(user)
  },
  { immediate: true }  // 建立時立即執行一次
)
</script>

一次性監聽(once)

Vue 3.4+ 新增了 once 選項,讓 watch 只觸發一次後就停止:

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

const count = ref(0)

watch(
  count,
  (newVal) => {
    console.log('只會觸發一次:', newVal)
  },
  { once: true }
)
</script>

回呼的觸發時機(flush)

預設情況下,watch 的回呼會在 Vue 元件更新之前被呼叫。如果需要在更新之後存取 DOM,使用 flush: 'post'

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

const items = ref([])

watch(
  items,
  () => {
    // DOM 已經更新,可以安全存取
    console.log('items 更新了,DOM 高度:', document.querySelector('.list')?.offsetHeight)
  },
  { flush: 'post' }
)
</script>
flush 值說明
'pre'(預設)在元件更新前呼叫
'post'在元件更新後呼叫
'sync'同步呼叫(謹慎使用)

watchEffect

watchEffect() 會立即執行傳入的函式,並自動追蹤其中使用的響應式依賴:

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

const count = ref(0)
const message = ref('Hello')

// 自動追蹤 count 和 message
watchEffect(() => {
  console.log(`count: ${count.value}, message: ${message.value}`)
})

// 建立時會立即執行一次
// 之後 count 或 message 變化時都會再次執行
</script>

watch vs watchEffect

特性watchwatchEffect
明確指定依賴✅ 是❌ 自動追蹤
取得舊值✅ 可以❌ 不行
預設立即執行❌ 否✅ 是
惰性執行✅ 是❌ 否
<script setup>
import { ref, watch, watchEffect } from 'vue'

const count = ref(0)

// watch:明確指定要監聽什麼,可以取得新舊值
watch(count, (newVal, oldVal) => {
  console.log(`${oldVal} -> ${newVal}`)
})

// watchEffect:自動追蹤,立即執行
watchEffect(() => {
  console.log('count is:', count.value)
})
</script>

watchEffect 的使用時機

  • 需要監聽多個依賴,且不需要舊值
  • 需要立即執行
  • 依賴關係複雜,不想手動列出
<script setup>
import { ref, watchEffect } from 'vue'

const searchQuery = ref('')
const sortBy = ref('date')
const filterCategory = ref('all')

// 適合用 watchEffect:多個依賴,且需要立即執行
watchEffect(async () => {
  // 自動追蹤 searchQuery、sortBy、filterCategory
  const response = await fetch(
    `/api/search?q=${searchQuery.value}&sort=${sortBy.value}&category=${filterCategory.value}`
  )
  // ...
})
</script>

watchPostEffect 和 watchSyncEffect

這是 watchEffect 加上 flush 選項的簡寫:

import { watchEffect, watchPostEffect, watchSyncEffect } from 'vue'

// 以下兩個等價
watchEffect(callback, { flush: 'post' })
watchPostEffect(callback)

// 以下兩個等價
watchEffect(callback, { flush: 'sync' })
watchSyncEffect(callback)

停止監聽

watchwatchEffect 都會返回一個停止函式:

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

const count = ref(0)

// 取得停止函式
const stop = watchEffect(() => {
  console.log('count:', count.value)
})

// 稍後停止監聽
function stopWatching() {
  stop()
}
</script>

<script setup> 中建立的 watch 會在元件卸載時自動停止,通常不需要手動處理。

但如果是在非同步回呼中建立的 watch,需要手動停止:

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

const count = ref(0)

// ❌ 這個 watch 不會自動停止
setTimeout(() => {
  watchEffect(() => {
    console.log(count.value)
  })
}, 1000)

// ✅ 手動停止
let stop
setTimeout(() => {
  stop = watchEffect(() => {
    console.log(count.value)
  })
}, 1000)

// 在適當時機呼叫 stop()
</script>

清理副作用(onCleanup)

如果 watch 的回呼會產生需要清理的副作用(如計時器、訂閱),可以使用 onCleanup

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

const id = ref(1)

watch(id, async (newId, oldId, onCleanup) => {
  let cancelled = false

  // 當 id 再次變化時,取消上一次的請求
  onCleanup(() => {
    cancelled = true
  })

  const response = await fetch(`/api/data/${newId}`)
  if (!cancelled) {
    // 處理回應...
  }
})
</script>

對於 watchEffect

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

const id = ref(1)

watchEffect((onCleanup) => {
  const controller = new AbortController()

  fetch(`/api/data/${id.value}`, { signal: controller.signal })
    .then(response => response.json())
    .then(data => {
      // 處理資料
    })

  // 清理:取消 fetch 請求
  onCleanup(() => {
    controller.abort()
  })
})
</script>

實際範例

搜尋防抖(Debounce)

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

const searchQuery = ref('')
const results = ref([])
const isLoading = ref(false)

let timeout

watch(searchQuery, (newQuery) => {
  // 清除前一個計時器
  clearTimeout(timeout)

  if (!newQuery.trim()) {
    results.value = []
    return
  }

  // 延遲 300ms 後執行搜尋
  timeout = setTimeout(async () => {
    isLoading.value = true
    try {
      const response = await fetch(`/api/search?q=${newQuery}`)
      results.value = await response.json()
    } finally {
      isLoading.value = false
    }
  }, 300)
})
</script>

<template>
  <input v-model="searchQuery" placeholder="搜尋...">
  <div v-if="isLoading">搜尋中...</div>
  <ul>
    <li v-for="item in results" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

儲存至 LocalStorage

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

// 從 localStorage 讀取初始值
const theme = ref(localStorage.getItem('theme') || 'light')

// 監聽變化,儲存到 localStorage
watch(theme, (newTheme) => {
  localStorage.setItem('theme', newTheme)
  document.documentElement.setAttribute('data-theme', newTheme)
}, { immediate: true })
</script>

<template>
  <select v-model="theme">
    <option value="light">淺色</option>
    <option value="dark">深色</option>
  </select>
</template>

路由參數變化

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

const route = useRoute()
const user = ref(null)
const isLoading = ref(false)

// 監聽路由參數變化
watch(
  () => route.params.id,
  async (newId) => {
    if (!newId) return

    isLoading.value = true
    try {
      const response = await fetch(`/api/users/${newId}`)
      user.value = await response.json()
    } finally {
      isLoading.value = false
    }
  },
  { immediate: true }
)
</script>

表單自動儲存

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

const form = reactive({
  title: '',
  content: '',
  lastSaved: null
})

// 監聽表單變化,自動儲存
watch(
  () => ({ title: form.title, content: form.content }),
  async (newForm) => {
    // 防抖處理
    await new Promise(resolve => setTimeout(resolve, 1000))

    // 儲存到後端
    await fetch('/api/drafts', {
      method: 'POST',
      body: JSON.stringify(newForm)
    })

    form.lastSaved = new Date()
  },
  { deep: true }
)
</script>

<template>
  <form>
    <input v-model="form.title" placeholder="標題">
    <textarea v-model="form.content" placeholder="內容"></textarea>
    <p v-if="form.lastSaved">
      最後儲存於:{{ form.lastSaved.toLocaleTimeString() }}
    </p>
  </form>
</template>