侦听器
虽然计算属性在大多数情况下更合适,但有时也需要一个自定义的侦听器。这就是为什么 Vue 通过 watch
选项提供了一个更通用的方法,来响应数据的变化。当需要在数据变化时执行异步或开销较大的操作时,这个方式是最有用的。
基本示例
组合式 API
vue
<template>
<div class="watcher-demo">
<h2>侦听器演示</h2>
<div class="demo-section">
<h3>基本侦听</h3>
<div class="input-group">
<label>输入文本:</label>
<input
v-model="message"
placeholder="输入一些文本..."
class="text-input"
>
</div>
<div class="watch-info">
<p><strong>当前值:</strong> {{ message }}</p>
<p><strong>字符长度:</strong> {{ message.length }}</p>
<p><strong>变化次数:</strong> {{ changeCount }}</p>
<p><strong>最后变化时间:</strong> {{ lastChangeTime }}</p>
</div>
</div>
<div class="demo-section">
<h3>深度侦听</h3>
<div class="user-form">
<div class="input-group">
<label>姓名:</label>
<input v-model="user.name" class="text-input">
</div>
<div class="input-group">
<label>邮箱:</label>
<input v-model="user.email" type="email" class="text-input">
</div>
<div class="input-group">
<label>年龄:</label>
<input v-model.number="user.age" type="number" class="text-input">
</div>
<div class="input-group">
<label>城市:</label>
<select v-model="user.address.city" class="select-input">
<option value="">请选择城市</option>
<option value="北京">北京</option>
<option value="上海">上海</option>
<option value="广州">广州</option>
<option value="深圳">深圳</option>
</select>
</div>
<div class="input-group">
<label>详细地址:</label>
<input v-model="user.address.detail" class="text-input">
</div>
</div>
<div class="watch-info">
<p><strong>用户对象变化次数:</strong> {{ userChangeCount }}</p>
<p><strong>最后修改字段:</strong> {{ lastModifiedField }}</p>
<p><strong>表单验证状态:</strong>
<span :class="validationClass">{{ validationMessage }}</span>
</p>
</div>
</div>
<div class="demo-section">
<h3>异步操作</h3>
<div class="search-section">
<div class="input-group">
<label>搜索关键词:</label>
<input
v-model="searchQuery"
placeholder="输入搜索关键词..."
class="text-input"
>
</div>
<div class="search-results">
<div v-if="isSearching" class="loading">
<div class="spinner"></div>
<p>搜索中...</p>
</div>
<div v-else-if="searchResults.length > 0" class="results-list">
<h4>搜索结果 ({{ searchResults.length }} 条):</h4>
<div
v-for="result in searchResults"
:key="result.id"
class="result-item"
>
<h5>{{ result.title }}</h5>
<p>{{ result.description }}</p>
<span class="result-type">{{ result.type }}</span>
</div>
</div>
<div v-else-if="searchQuery && !isSearching" class="no-results">
<p>未找到相关结果</p>
</div>
<div v-else class="search-placeholder">
<p>请输入关键词开始搜索</p>
</div>
</div>
</div>
</div>
<div class="demo-section">
<h3>侦听器控制</h3>
<div class="controls">
<button @click="toggleWatcher" :class="watcherButtonClass">
{{ isWatcherActive ? '停止侦听' : '开始侦听' }}
</button>
<button @click="clearLogs" class="clear-btn">
清空日志
</button>
</div>
<div class="input-group">
<label>测试输入:</label>
<input
v-model="testValue"
placeholder="输入测试值..."
class="text-input"
>
</div>
<div class="logs-container">
<h4>侦听日志:</h4>
<div class="logs">
<div
v-for="(log, index) in watchLogs"
:key="index"
class="log-entry"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
reactive,
watch,
watchEffect,
computed,
nextTick
} from 'vue'
// 基本侦听
const message = ref('')
const changeCount = ref(0)
const lastChangeTime = ref('')
// 深度侦听
const user = reactive({
name: '',
email: '',
age: null,
address: {
city: '',
detail: ''
}
})
const userChangeCount = ref(0)
const lastModifiedField = ref('')
// 异步操作
const searchQuery = ref('')
const searchResults = ref([])
const isSearching = ref(false)
// 侦听器控制
const testValue = ref('')
const isWatcherActive = ref(true)
const watchLogs = ref([])
// 计算属性
const validationMessage = computed(() => {
if (!user.name) return '请输入姓名'
if (!user.email) return '请输入邮箱'
if (!user.age || user.age < 1) return '请输入有效年龄'
if (!user.address.city) return '请选择城市'
return '表单验证通过'
})
const validationClass = computed(() => {
return validationMessage.value === '表单验证通过' ? 'valid' : 'invalid'
})
const watcherButtonClass = computed(() => {
return isWatcherActive.value ? 'stop-btn' : 'start-btn'
})
// 模拟搜索 API
function simulateSearch(query) {
return new Promise((resolve) => {
setTimeout(() => {
const mockResults = [
{
id: 1,
title: `Vue.js ${query} 教程`,
description: `关于 ${query} 的详细教程和最佳实践`,
type: '教程'
},
{
id: 2,
title: `${query} 组件库`,
description: `基于 ${query} 的高质量组件库`,
type: '组件'
},
{
id: 3,
title: `${query} 实战项目`,
description: `使用 ${query} 构建的实际项目案例`,
type: '项目'
}
]
// 模拟随机结果
const randomResults = mockResults
.filter(() => Math.random() > 0.3)
.map(item => ({
...item,
title: item.title.replace(query, `<mark>${query}</mark>`)
}))
resolve(randomResults)
}, 800 + Math.random() * 1200) // 0.8-2秒随机延迟
})
}
function addLog(message) {
watchLogs.value.push({
time: new Date().toLocaleTimeString(),
message
})
// 限制日志数量
if (watchLogs.value.length > 50) {
watchLogs.value = watchLogs.value.slice(-50)
}
}
function toggleWatcher() {
isWatcherActive.value = !isWatcherActive.value
addLog(`侦听器已${isWatcherActive.value ? '启动' : '停止'}`)
}
function clearLogs() {
watchLogs.value = []
}
// 1. 基本侦听器
watch(message, (newValue, oldValue) => {
changeCount.value++
lastChangeTime.value = new Date().toLocaleTimeString()
console.log(`消息变化: "${oldValue}" -> "${newValue}"`)
})
// 2. 深度侦听器
watch(
user,
(newUser, oldUser) => {
userChangeCount.value++
// 检测具体变化的字段
const fields = ['name', 'email', 'age']
for (const field of fields) {
if (newUser[field] !== oldUser[field]) {
lastModifiedField.value = field
break
}
}
// 检测地址变化
if (newUser.address.city !== oldUser.address.city) {
lastModifiedField.value = 'address.city'
}
if (newUser.address.detail !== oldUser.address.detail) {
lastModifiedField.value = 'address.detail'
}
console.log('用户信息变化:', { newUser, oldUser })
},
{ deep: true }
)
// 3. 异步侦听器(防抖)
let searchTimer = null
watch(searchQuery, async (newQuery) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
// 如果查询为空,清空结果
if (!newQuery.trim()) {
searchResults.value = []
isSearching.value = false
return
}
// 设置防抖延迟
searchTimer = setTimeout(async () => {
isSearching.value = true
try {
const results = await simulateSearch(newQuery.trim())
searchResults.value = results
} catch (error) {
console.error('搜索失败:', error)
searchResults.value = []
} finally {
isSearching.value = false
}
}, 500) // 500ms 防抖延迟
})
// 4. 条件侦听器
let conditionalWatcher = null
watch(isWatcherActive, (isActive) => {
if (isActive) {
// 启动侦听器
conditionalWatcher = watch(testValue, (newValue, oldValue) => {
addLog(`测试值变化: "${oldValue}" -> "${newValue}"`)
})
} else {
// 停止侦听器
if (conditionalWatcher) {
conditionalWatcher() // 调用返回的停止函数
conditionalWatcher = null
}
}
}, { immediate: true })
// 5. watchEffect 示例
watchEffect(() => {
// 自动追踪依赖
if (user.name && user.email) {
console.log(`用户 ${user.name} (${user.email}) 的信息已更新`)
}
})
// 6. 侦听多个源
watch(
[message, () => user.name],
([newMessage, newName], [oldMessage, oldName]) => {
console.log('多源侦听:', {
message: { old: oldMessage, new: newMessage },
name: { old: oldName, new: newName }
})
}
)
</script>
<style scoped>
.watcher-demo {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.demo-section {
margin-bottom: 25px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.demo-section h3 {
margin: 0 0 15px 0;
color: #333;
}
.input-group {
margin-bottom: 15px;
}
.input-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
}
.text-input,
.select-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.3s;
}
.text-input:focus,
.select-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.watch-info {
margin-top: 15px;
padding: 15px;
background-color: white;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.watch-info p {
margin: 8px 0;
font-size: 14px;
}
.valid {
color: #28a745;
font-weight: bold;
}
.invalid {
color: #dc3545;
font-weight: bold;
}
.user-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.search-section {
margin-bottom: 15px;
}
.search-results {
margin-top: 15px;
min-height: 200px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid #f3f3f3;
border-top: 3px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.results-list h4 {
margin: 0 0 15px 0;
color: #333;
}
.result-item {
padding: 15px;
margin-bottom: 10px;
background-color: white;
border-radius: 6px;
border: 1px solid #dee2e6;
transition: all 0.3s;
}
.result-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.result-item h5 {
margin: 0 0 8px 0;
color: #007bff;
}
.result-item p {
margin: 0 0 8px 0;
color: #666;
font-size: 14px;
}
.result-type {
display: inline-block;
padding: 2px 8px;
background-color: #e9ecef;
color: #495057;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.no-results,
.search-placeholder {
text-align: center;
padding: 40px;
color: #6c757d;
}
.controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.start-btn,
.stop-btn,
.clear-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: all 0.3s;
}
.start-btn {
background-color: #28a745;
color: white;
}
.start-btn:hover {
background-color: #1e7e34;
}
.stop-btn {
background-color: #dc3545;
color: white;
}
.stop-btn:hover {
background-color: #c82333;
}
.clear-btn {
background-color: #6c757d;
color: white;
}
.clear-btn:hover {
background-color: #545b62;
}
.logs-container {
margin-top: 15px;
}
.logs-container h4 {
margin: 0 0 10px 0;
color: #333;
}
.logs {
max-height: 200px;
overflow-y: auto;
background-color: white;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 10px;
}
.log-entry {
display: flex;
padding: 5px 0;
border-bottom: 1px solid #f8f9fa;
font-family: monospace;
font-size: 13px;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: #6c757d;
margin-right: 15px;
min-width: 80px;
}
.log-message {
color: #333;
flex: 1;
}
@media (max-width: 768px) {
.user-form {
grid-template-columns: 1fr;
}
.controls {
flex-direction: column;
}
.log-entry {
flex-direction: column;
gap: 5px;
}
.log-time {
min-width: auto;
}
}
</style>
侦听器类型
1. watch() - 基本侦听器
javascript
import { ref, watch } from 'vue'
const count = ref(0)
// 侦听单个 ref
watch(count, (newCount, oldCount) => {
console.log(`count 从 ${oldCount} 变为 ${newCount}`)
})
// 侦听 getter 函数
watch(
() => count.value + 1,
(newValue) => {
console.log(`count + 1 = ${newValue}`)
}
)
2. 侦听多个源
javascript
const firstName = ref('')
const lastName = ref('')
watch(
[firstName, lastName],
([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`姓名从 "${oldFirst} ${oldLast}" 变为 "${newFirst} ${newLast}"`)
}
)
3. 深度侦听
javascript
const user = reactive({
name: 'John',
address: {
city: 'Beijing'
}
})
// 深度侦听整个对象
watch(
user,
(newUser, oldUser) => {
console.log('用户信息发生变化')
},
{ deep: true }
)
// 侦听嵌套属性
watch(
() => user.address.city,
(newCity) => {
console.log(`城市变为: ${newCity}`)
}
)
4. 立即执行
javascript
watch(
source,
(newValue, oldValue) => {
// 侦听器回调
},
{ immediate: true } // 立即执行一次
)
5. watchEffect() - 自动依赖追踪
javascript
import { ref, watchEffect } from 'vue'
const count = ref(0)
const name = ref('Vue')
watchEffect(() => {
// 自动追踪 count 和 name 的变化
console.log(`${name.value}: ${count.value}`)
})
实际应用场景
1. 表单验证
vue
<template>
<form class="validation-form">
<div class="form-group">
<label>用户名:</label>
<input
v-model="form.username"
:class="{ error: errors.username }"
placeholder="请输入用户名"
>
<span v-if="errors.username" class="error-message">
{{ errors.username }}
</span>
</div>
<div class="form-group">
<label>密码:</label>
<input
v-model="form.password"
type="password"
:class="{ error: errors.password }"
placeholder="请输入密码"
>
<span v-if="errors.password" class="error-message">
{{ errors.password }}
</span>
</div>
<div class="form-group">
<label>确认密码:</label>
<input
v-model="form.confirmPassword"
type="password"
:class="{ error: errors.confirmPassword }"
placeholder="请确认密码"
>
<span v-if="errors.confirmPassword" class="error-message">
{{ errors.confirmPassword }}
</span>
</div>
<button
type="submit"
:disabled="!isFormValid"
class="submit-btn"
>
提交
</button>
</form>
</template>
<script setup>
import { reactive, ref, computed, watch } from 'vue'
const form = reactive({
username: '',
password: '',
confirmPassword: ''
})
const errors = reactive({
username: '',
password: '',
confirmPassword: ''
})
const isFormValid = computed(() => {
return !errors.username &&
!errors.password &&
!errors.confirmPassword &&
form.username &&
form.password &&
form.confirmPassword
})
// 用户名验证
watch(
() => form.username,
(username) => {
if (!username) {
errors.username = '用户名不能为空'
} else if (username.length < 3) {
errors.username = '用户名至少3个字符'
} else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
errors.username = '用户名只能包含字母、数字和下划线'
} else {
errors.username = ''
}
}
)
// 密码验证
watch(
() => form.password,
(password) => {
if (!password) {
errors.password = '密码不能为空'
} else if (password.length < 6) {
errors.password = '密码至少6个字符'
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) {
errors.password = '密码必须包含大小写字母和数字'
} else {
errors.password = ''
}
// 重新验证确认密码
if (form.confirmPassword) {
validateConfirmPassword()
}
}
)
// 确认密码验证
function validateConfirmPassword() {
if (!form.confirmPassword) {
errors.confirmPassword = '请确认密码'
} else if (form.password !== form.confirmPassword) {
errors.confirmPassword = '两次密码输入不一致'
} else {
errors.confirmPassword = ''
}
}
watch(
() => form.confirmPassword,
validateConfirmPassword
)
</script>
2. 数据持久化
javascript
import { ref, watch } from 'vue'
const userPreferences = ref({
theme: 'light',
language: 'zh-CN',
fontSize: 14
})
// 从 localStorage 加载设置
const loadPreferences = () => {
const saved = localStorage.getItem('userPreferences')
if (saved) {
userPreferences.value = JSON.parse(saved)
}
}
// 保存设置到 localStorage
watch(
userPreferences,
(newPreferences) => {
localStorage.setItem('userPreferences', JSON.stringify(newPreferences))
console.log('用户设置已保存')
},
{ deep: true }
)
// 初始化时加载设置
loadPreferences()
3. API 调用防抖
javascript
import { ref, watch } from 'vue'
const searchQuery = ref('')
const searchResults = ref([])
const isLoading = ref(false)
let searchTimer = null
watch(searchQuery, async (newQuery) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
// 如果查询为空,清空结果
if (!newQuery.trim()) {
searchResults.value = []
return
}
// 设置防抖延迟
searchTimer = setTimeout(async () => {
isLoading.value = true
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
const data = await response.json()
searchResults.value = data.results
} catch (error) {
console.error('搜索失败:', error)
searchResults.value = []
} finally {
isLoading.value = false
}
}, 300) // 300ms 防抖延迟
})
侦听器的最佳实践
1. 选择合适的侦听器类型
javascript
// ✅ 计算属性更适合同步计算
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
// ✅ 侦听器适合异步操作或副作用
watch(userId, async (newUserId) => {
const userData = await fetchUser(newUserId)
user.value = userData
})
2. 避免在侦听器中修改被侦听的数据
javascript
// ❌ 可能导致无限循环
watch(count, () => {
count.value++ // 危险!
})
// ✅ 修改其他相关数据
watch(count, () => {
doubleCount.value = count.value * 2
})
3. 清理侦听器
javascript
// 手动停止侦听器
const stopWatcher = watch(source, callback)
// 在适当时机停止
stopWatcher()
// 在组件卸载时自动清理
onBeforeUnmount(() => {
stopWatcher()
})
4. 使用 flush 选项控制执行时机
javascript
watch(
source,
callback,
{
flush: 'post' // 在 DOM 更新后执行
}
)
调试侦听器
javascript
watch(
source,
(newValue, oldValue) => {
console.log('侦听器触发:', { newValue, oldValue })
},
{
onTrack(e) {
console.log('追踪:', e)
},
onTrigger(e) {
console.log('触发:', e)
}
}
)