Vue Watch 監聽器
Watch(監聽器)讓你可以在資料變化時執行副作用(side effects),例如 API 呼叫、操作 DOM、執行非同步操作等。Vue 3 提供了 watch 和 watchEffect 兩種監聽方式。
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
| 特性 | watch | watchEffect |
|---|---|---|
| 明確指定依賴 | ✅ 是 | ❌ 自動追蹤 |
| 取得舊值 | ✅ 可以 | ❌ 不行 |
| 預設立即執行 | ❌ 否 | ✅ 是 |
| 惰性執行 | ✅ 是 | ❌ 否 |
<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)
停止監聽
watch 和 watchEffect 都會返回一個停止函式:
<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>