Vue3 watch 侦听器详解
核心概念理解
- 参考 侦听器 | Vue.js
什么是 watch?
watch 是 Vue 的侦听器,用来监听响应式数据的变化,当数据发生变化时执行相应的回调函数。
为什么需要 watch?
- 需要在数据变化时执行异步操作
- 需要执行开销较大的操作
- 需要监听多个数据的变化
- 需要精确控制何时以及如何响应数据变化
基础用法
1. 侦听基本数据类型
<template>
<div class="watch-demo">
<h2>基础 watch 用法</h2>
<!-- 侦听基本数据类型 -->
<div class="watch-group">
<h3>侦听计数器</h3>
<div class="counter-controls">
<button @click="count++">增加</button>
<button @click="count--">减少</button>
<span class="count-display">计数: {{ count }}</span>
</div>
<div class="log-section">
<h4>变化日志:</h4>
<ul class="log-list">
<li v-for="(log, index) in countLogs" :key="index">
{{ log }}
</li>
</ul>
</div>
</div>
<!-- 侦听输入框 -->
<div class="watch-group">
<h3>侦听搜索输入</h3>
<input
v-model="searchText"
placeholder="输入搜索内容"
class="search-input"
>
<p>当前搜索词: "{{ searchText }}"</p>
<p>搜索状态: {{ searchStatus }}</p>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
const searchText = ref('')
const searchStatus = ref('空闲')
const countLogs = ref([])
// 侦听基本数据类型
watch(count, (newValue, oldValue) => {
console.log(`计数从 ${oldValue} 变为 ${newValue}`)
countLogs.value.push(`从 ${oldValue} → ${newValue} (${new Date().toLocaleTimeString()})`)
})
// 侦听搜索输入
watch(searchText, (newText, oldText) => {
console.log(`搜索词从 "${oldText}" 变为 "${newText}"`)
searchStatus.value = '搜索中...'
// 模拟搜索延迟
setTimeout(() => {
searchStatus.value = newText ? '搜索完成' : '空闲'
}, 500)
})
</script>
<style>
.watch-demo {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.watch-group {
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.watch-group h3 {
margin-top: 0;
color: #495057;
}
.counter-controls {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
}
.count-display {
font-size: 18px;
font-weight: bold;
color: #007bff;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #0056b3;
}
.search-input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin-bottom: 10px;
}
.search-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.log-section {
margin-top: 20px;
}
.log-list {
max-height: 200px;
overflow-y: auto;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
margin: 0;
}
.log-list li {
padding: 5px 0;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.log-list li:last-child {
border-bottom: none;
}
</style>
侦听复杂数据类型
1. 侦听对象和数组
<template>
<div class="complex-watch-demo">
<h2>侦听复杂数据类型</h2>
<!-- 侦听对象 -->
<div class="watch-group">
<h3>侦听用户信息</h3>
<div class="user-form">
<input
v-model="user.name"
placeholder="姓名"
class="form-input"
>
<input
v-model="user.email"
placeholder="邮箱"
class="form-input"
>
<input
v-model.number="user.age"
type="number"
placeholder="年龄"
class="form-input"
>
</div>
<div class="user-display">
<h4>用户信息变化日志:</h4>
<ul class="log-list">
<li v-for="(log, index) in userLogs" :key="index">
{{ log }}
</li>
</ul>
</div>
</div>
<!-- 侦听数组 -->
<div class="watch-group">
<h3>侦听购物车</h3>
<div class="cart-controls">
<input
v-model="newItem"
placeholder="输入商品名称"
class="form-input"
@keyup.enter="addItem"
>
<button @click="addItem">添加商品</button>
</div>
<div class="cart-display">
<h4>购物车商品:</h4>
<ul class="cart-list">
<li v-for="(item, index) in cart" :key="item.id">
{{ item.name }}
<button @click="removeItem(index)" class="remove-btn">×</button>
</li>
</ul>
<p>商品总数: {{ cart.length }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
// 用户对象
const user = reactive({
name: '',
email: '',
age: 0
})
// 购物车数组
const cart = ref([])
const newItem = ref('')
const userLogs = ref([])
// 侦听整个对象(需要 deep: true)
watch(user, (newUser, oldUser) => {
console.log('用户信息变化:', newUser, oldUser)
userLogs.value.push(`用户信息更新: ${JSON.stringify(newUser)} (${new Date().toLocaleTimeString()})`)
}, { deep: true })
// 侦听数组
watch(cart, (newCart, oldCart) => {
console.log('购物车变化:', newCart.length, oldCart.length)
}, { deep: true })
// 添加商品
const addItem = () => {
if (newItem.value.trim()) {
cart.value.push({
id: Date.now(),
name: newItem.value.trim()
})
newItem.value = ''
}
}
// 删除商品
const removeItem = (index) => {
cart.value.splice(index, 1)
}
</script>
<style>
.complex-watch-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.watch-group {
margin-bottom: 30px;
padding: 20px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.watch-group h3 {
margin-top: 0;
color: #495057;
}
.user-form {
display: grid;
gap: 10px;
margin-bottom: 20px;
}
.form-input {
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.user-display, .cart-display {
margin-top: 20px;
}
.user-display h4, .cart-display h4 {
margin-top: 0;
color: #495057;
}
.cart-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.cart-controls .form-input {
flex: 1;
}
.cart-list {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
margin: 10px 0;
min-height: 50px;
}
.cart-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid #eee;
}
.cart-list li:last-child {
border-bottom: none;
}
.remove-btn {
background-color: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn:hover {
background-color: #c82333;
}
.log-list {
max-height: 200px;
overflow-y: auto;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
margin: 0;
}
.log-list li {
padding: 5px 0;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.log-list li:last-child {
border-bottom: none;
}
</style>
watch 的配置选项
1. immediate 和 deep 选项
<template>
<div class="options-demo">
<h2>watch 配置选项</h2>
<!-- immediate 选项 -->
<div class="option-group">
<h3>immediate 选项 - 立即执行</h3>
<div class="input-group">
<input
v-model="message"
placeholder="输入消息"
class="form-input"
>
<p>消息: {{ message }}</p>
<p>immediate 回调执行次数: {{ immediateCount }}</p>
</div>
</div>
<!-- deep 选项 -->
<div class="option-group">
<h3>deep 选项 - 深度侦听</h3>
<div class="nested-form">
<input
v-model="nestedData.user.name"
placeholder="姓名"
class="form-input"
>
<input
v-model="nestedData.user.profile.email"
placeholder="邮箱"
class="form-input"
>
<div class="settings-section">
<label>
<input
v-model="nestedData.settings.theme"
type="checkbox"
>
深色主题
</label>
<label>
<input
v-model="nestedData.settings.notifications"
type="checkbox"
>
开启通知
</label>
</div>
</div>
<p>deep 回调执行次数: {{ deepCount }}</p>
</div>
<!-- flush 选项 -->
<div class="option-group">
<h3>flush 选项 - 执行时机</h3>
<div class="flush-demo">
<button @click="updateFlushData">更新数据</button>
<p>数据值: {{ flushData }}</p>
<div class="flush-logs">
<p>pre 回调 (更新前): {{ preValue }}</p>
<p>post 回调 (更新后): {{ postValue }}</p>
<p>sync 回调 (同步): {{ syncValue }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
const message = ref('Hello Vue3!')
const immediateCount = ref(0)
const nestedData = reactive({
user: {
name: '',
profile: {
email: '',
age: 0
}
},
settings: {
theme: false,
notifications: true
}
})
const deepCount = ref(0)
const flushData = ref(0)
const preValue = ref(0)
const postValue = ref(0)
const syncValue = ref(0)
// immediate: true - 立即执行一次
watch(message, (newVal, oldVal) => {
console.log(`消息变化: ${oldVal} → ${newVal}`)
immediateCount.value++
}, { immediate: true })
// deep: true - 深度侦听对象内部变化
watch(nestedData, (newVal, oldVal) => {
console.log('嵌套数据变化:', newVal)
deepCount.value++
}, { deep: true })
// flush 选项
watch(flushData, (newVal) => {
preValue.value = `pre: ${newVal}`
}, { flush: 'pre' })
watch(flushData, (newVal) => {
postValue.value = `post: ${newVal}`
}, { flush: 'post' })
watch(flushData, (newVal) => {
syncValue.value = `sync: ${newVal}`
}, { flush: 'sync' })
const updateFlushData = () => {
flushData.value++
}
</script>
<style>
.options-demo {
max-width: 700px;
margin: 0 auto;
padding: 20px;
}
.option-group {
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.option-group h3 {
margin-top: 0;
color: #495057;
}
.input-group {
margin-bottom: 15px;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin-bottom: 10px;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.nested-form {
display: grid;
gap: 10px;
margin-bottom: 15px;
}
.settings-section {
display: flex;
gap: 20px;
margin-top: 10px;
}
.settings-section label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.flush-demo {
text-align: center;
}
.flush-demo button {
padding: 10px 20px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 16px;
margin-bottom: 15px;
}
.flush-demo button:hover {
background-color: #0056b3;
}
.flush-logs {
text-align: left;
background-color: #fff;
padding: 15px;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.flush-logs p {
margin: 8px 0;
padding: 5px;
background-color: #e9ecef;
border-radius: 3px;
}
</style>
侦听多个数据源
1. 同时侦听多个值
<template>
<div class="multi-watch-demo">
<h2>侦听多个数据源</h2>
<!-- 侦听多个独立的响应式数据 -->
<div class="watch-group">
<h3>侦听用户名和密码</h3>
<div class="form-group">
<input
v-model="username"
placeholder="用户名"
class="form-input"
>
<input
v-model="password"
type="password"
placeholder="密码"
class="form-input"
>
<div class="login-status">
<p>用户名: {{ username || '未输入' }}</p>
<p>密码: {{ password ? '●'.repeat(password.length) : '未输入' }}</p>
<p>登录状态: {{ loginStatus }}</p>
</div>
</div>
</div>
<!-- 侦听数组中的多个值 -->
<div class="watch-group">
<h3>侦听表单验证</h3>
<div class="validation-form">
<input
v-model="formData.email"
type="email"
placeholder="邮箱"
class="form-input"
>
<input
v-model="formData.phone"
type="tel"
placeholder="手机号"
class="form-input"
>
<textarea
v-model="formData.message"
placeholder="留言内容"
rows="3"
class="form-input"
></textarea>
</div>
<div class="validation-status">
<p>邮箱验证: {{ validationResults.email }}</p>
<p>手机验证: {{ validationResults.phone }}</p>
<p>留言验证: {{ validationResults.message }}</p>
<p>整体验证: {{ overallValidation }}</p>
</div>
</div>
<!-- 侦听计算属性 -->
<div class="watch-group">
<h3>侦听计算属性</h3>
<div class="calculation-demo">
<input
v-model.number="num1"
type="number"
placeholder="第一个数"
class="form-input small"
>
<select v-model="operator" class="form-select">
<option value="+">+</option>
<option value="-">-</option>
<option value="*">×</option>
<option value="/">÷</option>
</select>
<input
v-model.number="num2"
type="number"
placeholder="第二个数"
class="form-input small"
>
<p>计算结果: {{ calculationResult }}</p>
<p>历史记录数: {{ calculationHistory.length }}</p>
</div>
<div class="history-section" v-if="calculationHistory.length">
<h4>计算历史:</h4>
<ul class="history-list">
<li v-for="(record, index) in calculationHistory" :key="index">
{{ record }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
// 多个独立数据
const username = ref('')
const password = ref('')
const loginStatus = ref('请输入用户名和密码')
// 表单数据
const formData = reactive({
email: '',
phone: '',
message: ''
})
const validationResults = reactive({
email: '未验证',
phone: '未验证',
message: '未验证'
})
// 计算属性
const num1 = ref(0)
const num2 = ref(0)
const operator = ref('+')
const calculationResult = computed(() => {
switch (operator.value) {
case '+': return num1.value + num2.value
case '-': return num1.value - num2.value
case '*': return num1.value * num2.value
case '/': return num2.value !== 0 ? num1.value / num2.value : '错误'
default: return 0
}
})
// 历史记录
const calculationHistory = ref([])
// 侦听多个独立的响应式数据
watch([username, password], ([newUsername, newPassword], [oldUsername, oldPassword]) => {
console.log(`用户名: ${oldUsername} → ${newUsername}`)
console.log(`密码: ${'*'.repeat(oldPassword?.length || 0)} → ${'*'.repeat(newPassword?.length || 0)}`)
if (newUsername && newPassword) {
loginStatus.value = '可以登录'
} else {
loginStatus.value = '请输入用户名和密码'
}
})
// 侦听响应式对象的多个属性
watch(
[() => formData.email, () => formData.phone, () => formData.message],
([newEmail, newPhone, newMessage]) => {
// 验证邮箱
validationResults.email = newEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)
? '✅ 有效'
: newEmail ? '❌ 无效' : '未输入'
// 验证手机
validationResults.phone = newPhone && /^1[3-9]\d{9}$/.test(newPhone)
? '✅ 有效'
: newPhone ? '❌ 无效' : '未输入'
// 验证留言
validationResults.message = newMessage && newMessage.length >= 10
? '✅ 长度合适'
: newMessage ? '❌ 太短' : '未输入'
}
)
// 计算整体验证状态
const overallValidation = computed(() => {
const results = Object.values(validationResults)
if (results.every(result => result.includes('✅'))) {
return '✅ 全部通过'
} else if (results.some(result => result.includes('❌'))) {
return '❌ 存在错误'
} else {
return '📝 等待输入'
}
})
// 侦听计算属性
watch(calculationResult, (newResult, oldResult) => {
if (typeof newResult === 'number' || newResult === '错误') {
calculationHistory.value.push(
`${num1.value} ${operator.value} ${num2.value} = ${newResult} (${new Date().toLocaleTimeString()})`
)
}
})
</script>
<style>
.multi-watch-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.watch-group {
margin-bottom: 30px;
padding: 20px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.watch-group h3 {
margin-top: 0;
color: #495057;
}
.form-group, .validation-form, .calculation-demo {
margin-bottom: 20px;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin-bottom: 10px;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-input.small {
display: inline-block;
width: 120px;
margin: 0 5px;
}
.form-select {
display: inline-block;
width: 60px;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin: 0 5px;
}
.login-status, .validation-status {
background-color: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-top: 15px;
}
.login-status p, .validation-status p {
margin: 5px 0;
}
.validation-status p::before {
margin-right: 5px;
}
.history-section {
margin-top: 20px;
}
.history-section h4 {
margin-top: 0;
color: #495057;
}
.history-list {
max-height: 150px;
overflow-y: auto;
background-color: #e9ecef;
border-radius: 4px;
padding: 10px;
margin: 0;
font-size: 14px;
}
.history-list li {
padding: 3px 0;
border-bottom: 1px solid #ddd;
}
.history-list li:last-child {
border-bottom: none;
}
</style>
实际应用示例
1. 搜索功能实现
<template>
<div class="search-demo">
<h2>搜索功能实现</h2>
<!-- 搜索框 -->
<div class="search-section">
<div class="search-controls">
<input
v-model="searchQuery"
placeholder="搜索用户..."
class="search-input"
>
<select v-model="searchCategory" class="category-select">
<option value="all">所有分类</option>
<option value="name">姓名</option>
<option value="email">邮箱</option>
<option value="city">城市</option>
</select>
</div>
<div class="search-info">
<p>搜索词: "{{ searchQuery }}"</p>
<p>搜索分类: {{ searchCategory }}</p>
<p>搜索状态: {{ searchStatus }}</p>
<p>找到 {{ filteredUsers.length }} 个结果</p>
</div>
</div>
<!-- 搜索结果 -->
<div class="results-section">
<h3>用户列表</h3>
<div class="user-grid">
<div
v-for="user in filteredUsers"
:key="user.id"
class="user-card"
>
<div class="user-avatar">{{ user.name.charAt(0) }}</div>
<div class="user-info">
<h4>{{ user.name }}</h4>
<p>{{ user.email }}</p>
<p class="user-city">{{ user.city }}</p>
</div>
</div>
</div>
</div>
<!-- 搜索历史 -->
<div class="history-section" v-if="searchHistory.length">
<h3>搜索历史</h3>
<div class="history-tags">
<span
v-for="(history, index) in searchHistory"
:key="index"
@click="searchQuery = history.query"
class="history-tag"
>
{{ history.query }} ({{ history.category }})
</span>
<button @click="clearHistory" class="clear-btn">清空历史</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const searchQuery = ref('')
const searchCategory = ref('all')
const searchStatus = ref('空闲')
const searchHistory = ref([])
// 模拟用户数据
const users = ref([
{ id: 1, name: '张三', email: 'zhangsan@example.com', city: '北京' },
{ id: 2, name: '李四', email: 'lisi@example.com', city: '上海' },
{ id: 3, name: '王五', email: 'wangwu@example.com', city: '广州' },
{ id: 4, name: '赵六', email: 'zhaoliu@example.com', city: '深圳' },
{ id: 5, name: '钱七', email: 'qianqi@example.com', city: '杭州' },
{ id: 6, name: '孙八', email: 'sunba@example.com', city: '成都' }
])
// 计算过滤后的用户
const filteredUsers = computed(() => {
if (!searchQuery.value.trim()) {
return users.value
}
const query = searchQuery.value.toLowerCase()
return users.value.filter(user => {
switch (searchCategory.value) {
case 'name':
return user.name.toLowerCase().includes(query)
case 'email':
return user.email.toLowerCase().includes(query)
case 'city':
return user.city.toLowerCase().includes(query)
default:
return (
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.city.toLowerCase().includes(query)
)
}
})
})
// 侦听搜索变化
watch([searchQuery, searchCategory], ([newQuery, newCategory], [oldQuery, oldCategory]) => {
// 记录搜索历史
if (newQuery.trim() && (newQuery !== oldQuery || newCategory !== oldCategory)) {
searchHistory.value.push({
query: newQuery.trim(),
category: newCategory,
timestamp: Date.now()
})
// 限制历史记录数量
if (searchHistory.value.length > 10) {
searchHistory.value.shift()
}
}
// 模拟搜索延迟
searchStatus.value = '搜索中...'
setTimeout(() => {
searchStatus.value = newQuery.trim() ? '搜索完成' : '空闲'
}, 300)
})
// 清空搜索历史
const clearHistory = () => {
searchHistory.value = []
}
</script>
<style>
.search-demo {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.search-section {
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.search-controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
}
.search-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.category-select {
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
background-color: white;
cursor: pointer;
}
.category-select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.search-info {
background-color: #fff;
padding: 15px;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.search-info p {
margin: 5px 0;
color: #495057;
}
.results-section {
margin-bottom: 30px;
}
.results-section h3 {
color: #495057;
margin-bottom: 20px;
}
.user-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.user-card {
display: flex;
align-items: center;
padding: 15px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
transition: all 0.2s ease;
}
.user-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.user-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #007bff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
margin-right: 15px;
}
.user-info h4 {
margin: 0 0 5px 0;
color: #333;
}
.user-info p {
margin: 3px 0;
color: #6c757d;
font-size: 14px;
}
.user-city {
font-weight: bold;
color: #007bff !important;
}
.history-section {
padding: 20px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.history-section h3 {
color: #495057;
margin-top: 0;
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.history-tag {
padding: 6px 12px;
background-color: #e9ecef;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.history-tag:hover {
background-color: #007bff;
color: white;
}
.clear-btn {
padding: 6px 12px;
border: none;
border-radius: 20px;
background-color: #dc3545;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
}
.clear-btn:hover {
background-color: #c82333;
}
</style>
2. 表单验证和自动保存
<template>
<div class="form-validation-demo">
<h2>表单验证和自动保存</h2>
<form @submit.prevent="submitForm" class="validation-form">
<!-- 基本信息 -->
<div class="form-section">
<h3>基本信息</h3>
<div class="form-grid">
<div class="form-group">
<label>姓名 *</label>
<input
v-model="form.name"
type="text"
placeholder="请输入姓名"
:class="{ error: errors.name }"
>
<span v-if="errors.name" class="error-message">{{ errors.name }}</span>
</div>
<div class="form-group">
<label>邮箱 *</label>
<input
v-model="form.email"
type="email"
placeholder="请输入邮箱"
:class="{ error: errors.email }"
>
<span v-if="errors.email" class="error-message">{{ errors.email }}</span>
</div>
<div class="form-group">
<label>电话</label>
<input
v-model="form.phone"
type="tel"
placeholder="请输入电话号码"
:class="{ error: errors.phone }"
>
<span v-if="errors.phone" class="error-message">{{ errors.phone }}</span>
</div>
<div class="form-group">
<label>年龄</label>
<input
v-model.number="form.age"
type="number"
placeholder="请输入年龄"
min="0"
max="150"
>
</div>
</div>
</div>
<!-- 详细信息 -->
<div class="form-section">
<h3>详细信息</h3>
<div class="form-group">
<label>地址</label>
<input
v-model="form.address"
type="text"
placeholder="请输入详细地址"
class="full-width"
>
</div>
<div class="form-group">
<label>个人简介</label>
<textarea
v-model="form.bio"
placeholder="请输入个人简介"
rows="4"
class="full-width"
></textarea>
<div class="char-count">{{ form.bio.length }}/200</div>
</div>
</div>
<!-- 状态显示 -->
<div class="status-section">
<div class="status-indicators">
<div class="status-item">
<span class="status-label">表单状态:</span>
<span :class="['status-value', formStatus.class]">{{ formStatus.text }}</span>
</div>
<div class="status-item">
<span class="status-label">验证状态:</span>
<span :class="['status-value', validationStatus.class]">{{ validationStatus.text }}</span>
</div>
<div class="status-item">
<span class="status-label">自动保存:</span>
<span :class="['status-value', autoSaveStatus.class]">{{ autoSaveStatus.text }}</span>
</div>
</div>
<div class="form-actions">
<button
type="submit"
:disabled="!isFormValid"
class="submit-btn"
>
提交表单
</button>
<button
@click="saveDraft"
type="button"
class="save-btn"
>
保存草稿
</button>
<button
@click="resetForm"
type="button"
class="reset-btn"
>
重置表单
</button>
</div>
</div>
</form>
<!-- 提交结果 -->
<div v-if="submittedData" class="result-section">
<h3>提交成功!</h3>
<pre>{{ JSON.stringify(submittedData, null, 2) }}</pre>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
const form = reactive({
name: '',
email: '',
phone: '',
age: null,
address: '',
bio: ''
})
const errors = reactive({
name: '',
email: '',
phone: ''
})
const formStatus = ref({ text: '编辑中', class: 'editing' })
const validationStatus = ref({ text: '未验证', class: 'pending' })
const autoSaveStatus = ref({ text: '未保存', class: 'idle' })
const submittedData = ref(null)
// 验证规则
const validateField = (field, value) => {
switch (field) {
case 'name':
if (!value.trim()) {
return '姓名不能为空'
} else if (value.length < 2) {
return '姓名至少2个字符'
}
return ''
case 'email':
if (!value.trim()) {
return '邮箱不能为空'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return '请输入有效的邮箱地址'
}
return ''
case 'phone':
if (value && !/^1[3-9]\d{9}$/.test(value)) {
return '请输入有效的手机号码'
}
return ''
default:
return ''
}
}
// 侦听表单字段变化并验证
watch(
() => form.name,
(newName) => {
errors.name = validateField('name', newName)
updateValidationStatus()
}
)
watch(
() => form.email,
(newEmail) => {
errors.email = validateField('email', newEmail)
updateValidationStatus()
}
)
watch(
() => form.phone,
(newPhone) => {
errors.phone = validateField('phone', newPhone)
updateValidationStatus()
}
)
// 更新验证状态
const updateValidationStatus = () => {
const hasErrors = Object.values(errors).some(error => error)
const hasEmptyRequired = !form.name.trim() || !form.email.trim()
if (hasErrors) {
validationStatus.value = { text: '存在错误', class: 'error' }
} else if (hasEmptyRequired) {
validationStatus.value = { text: '待完善', class: 'pending' }
} else {
validationStatus.value = { text: '验证通过', class: 'success' }
}
}
// 计算表单是否有效
const isFormValid = computed(() => {
return (
form.name.trim() &&
form.email.trim() &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) &&
!errors.name &&
!errors.email &&
!errors.phone
)
})
// 侦听表单变化并自动保存
watch(form, () => {
// 防抖自动保存
clearTimeout(window.autoSaveTimer)
autoSaveStatus.value = { text: '准备保存...', class: 'saving' }
window.autoSaveTimer = setTimeout(() => {
saveToLocalStorage()
autoSaveStatus.value = { text: '已保存', class: 'saved' }
// 2秒后恢复状态
setTimeout(() => {
autoSaveStatus.value = { text: '自动保存', class: 'idle' }
}, 2000)
}, 1000)
}, { deep: true })
// 保存到本地存储
const saveToLocalStorage = () => {
try {
localStorage.setItem('formDraft', JSON.stringify(form))
console.log('表单草稿已保存')
} catch (error) {
console.error('保存失败:', error)
}
}
// 从本地存储加载
const loadFromLocalStorage = () => {
try {
const saved = localStorage.getItem('formDraft')
if (saved) {
const parsed = JSON.parse(saved)
Object.assign(form, parsed)
formStatus.value = { text: '已恢复草稿', class: 'restored' }
}
} catch (error) {
console.error('加载失败:', error)
}
}
// 保存草稿
const saveDraft = () => {
saveToLocalStorage()
autoSaveStatus.value = { text: '草稿已保存', class: 'saved' }
}
// 重置表单
const resetForm = () => {
Object.assign(form, {
name: '',
email: '',
phone: '',
age: null,
address: '',
bio: ''
})
Object.keys(errors).forEach(key => {
errors[key] = ''
})
formStatus.value = { text: '已重置', class: 'reset' }
validationStatus.value = { text: '未验证', class: 'pending' }
autoSaveStatus.value = { text: '未保存', class: 'idle' }
// 清除本地存储
localStorage.removeItem('formDraft')
// 2秒后恢复状态
setTimeout(() => {
formStatus.value = { text: '编辑中', class: 'editing' }
}, 2000)
}
// 提交表单
const submitForm = () => {
if (isFormValid.value) {
submittedData.value = { ...form }
formStatus.value = { text: '提交成功', class: 'success' }
// 清除草稿
localStorage.removeItem('formDraft')
console.log('表单提交:', form)
} else {
formStatus.value = { text: '请检查表单', class: 'error' }
}
}
// 组件挂载时加载草稿
import { onMounted } from 'vue'
onMounted(() => {
loadFromLocalStorage()
})
</script>
<style>
.form-validation-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.validation-form {
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
}
.form-section {
margin-bottom: 30px;
}
.form-section h3 {
color: #495057;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #e9ecef;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #495057;
}
.form-group input, .form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.2s ease;
}
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-group input.error {
border-color: #dc3545;
}
.full-width {
width: 100%;
}
.char-count {
text-align: right;
font-size: 14px;
color: #6c757d;
margin-top: 5px;
}
.error-message {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
display: block;
}
.status-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.status-indicators {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-label {
font-weight: bold;
color: #495057;
}
.status-value {
padding: 4px 10px;
border-radius: 12px;
font-size: 14px;
font-weight: bold;
}
.status-value.editing {
background-color: #d1ecf1;
color: #0c5460;
}
.status-value.restored {
background-color: #d4edda;
color: #155724;
}
.status-value.reset {
background-color: #f8d7da;
color: #721c24;
}
.status-value.success {
background-color: #d4edda;
color: #155724;
}
.status-value.error {
background-color: #f8d7da;
color: #721c24;
}
.status-value.pending {
background-color: #fff3cd;
color: #856404;
}
.status-value.idle {
background-color: #e2e3e5;
color: #383d41;
}
.status-value.saving {
background-color: #cce7ff;
color: #004085;
}
.status-value.saved {
background-color: #d4edda;
color: #155724;
}
.form-actions {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.submit-btn, .save-btn, .reset-btn {
padding: 12px 20px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.submit-btn {
background-color: #28a745;
color: white;
flex: 1;
}
.submit-btn:hover:not(:disabled) {
background-color: #218838;
}
.submit-btn:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.save-btn {
background-color: #007bff;
color: white;
}
.save-btn:hover {
background-color: #0056b3;
}
.reset-btn {
background-color: #6c757d;
color: white;
}
.reset-btn:hover {
background-color: #5a6268;
}
.result-section {
padding: 20px;
background-color: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 8px;
}
.result-section h3 {
color: #155724;
margin-top: 0;
}
.result-section pre {
background-color: #fff;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
margin: 0;
font-size: 14px;
}
</style>
注意事项和最佳实践
1. 性能优化和内存管理
<template>
<div class="performance-demo">
<h2>性能优化和内存管理</h2>
<!-- 停止侦听器 -->
<div class="demo-section">
<h3>手动停止侦听器</h3>
<div class="control-group">
<button @click="startWatcher">开始侦听</button>
<button @click="stopWatcher" :disabled="!stopWatcherFn">停止侦听</button>
<input
v-model="watchedValue"
placeholder="输入值测试侦听"
class="form-input"
>
<p>侦听状态: {{ watcherStatus }}</p>
</div>
</div>
<!-- 防抖处理 -->
<div class="demo-section">
<h3>防抖优化</h3>
<div class="control-group">
<input
v-model="debouncedInput"
placeholder="快速输入测试防抖"
class="form-input"
>
<p>原始输入: {{ debouncedInput }}</p>
<p>防抖结果: {{ debouncedResult }}</p>
<p>更新次数: {{ debounceCount }}</p>
</div>
</div>
<!-- 条件性侦听 -->
<div class="demo-section">
<h3>条件性侦听</h3>
<div class="control-group">
<label class="checkbox-label">
<input
v-model="enableWatch"
type="checkbox"
>
启用侦听
</label>
<input
v-model="conditionalValue"
placeholder="条件性侦听测试"
class="form-input"
>
<p>条件状态: {{ enableWatch ? '已启用' : '已禁用' }}</p>
<p>侦听结果: {{ conditionalResult }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
// 手动停止侦听
const watchedValue = ref('')
const watcherStatus = ref('未开始')
const stopWatcherFn = ref(null)
const startWatcher = () => {
if (stopWatcherFn.value) {
stopWatcherFn.value()
}
stopWatcherFn.value = watch(watchedValue, (newVal) => {
console.log('值变化:', newVal)
watcherStatus.value = `侦听中: ${newVal}`
})
watcherStatus.value = '侦听已开始'
}
const stopWatcher = () => {
if (stopWatcherFn.value) {
stopWatcherFn.value()
stopWatcherFn.value = null
watcherStatus.value = '侦听已停止'
}
}
// 防抖处理
const debouncedInput = ref('')
const debouncedResult = ref('')
const debounceCount = ref(0)
let debounceTimer = null
watch(debouncedInput, (newVal) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debouncedResult.value = newVal
debounceCount.value++
}, 500)
})
// 条件性侦听
const enableWatch = ref(false)
const conditionalValue = ref('')
const conditionalResult = ref('')
watch([enableWatch, conditionalValue], ([isEnabled, newVal]) => {
if (isEnabled) {
conditionalResult.value = `已启用: ${newVal}`
} else {
conditionalResult.value = '侦听已禁用'
}
})
</script>
<style>
.performance-demo {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.demo-section {
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.demo-section h3 {
margin-top: 0;
color: #495057;
}
.control-group {
margin-top: 15px;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin: 10px 0;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
button {
padding: 8px 16px;
margin: 5px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
}
button:hover:not(:disabled) {
background-color: #0056b3;
}
button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0;
cursor: pointer;
user-select: none;
}
.checkbox-label input {
width: 18px;
height: 18px;
cursor: pointer;
}
</style>
2. 常见陷阱和解决方案
<template>
<div class="pitfalls-demo">
<h2>常见陷阱和解决方案</h2>
<!-- 陷阱1: 对象引用问题 -->
<div class="demo-section">
<h3>陷阱1: 对象引用问题</h3>
<div class="example-group">
<button @click="updateUserWrong">错误方式更新对象</button>
<button @click="updateUserCorrect">正确方式更新对象</button>
<p>用户信息: {{ JSON.stringify(userObject) }}</p>
</div>
</div>
<!-- 陷阱2: 数组变更检测 -->
<div class="demo-section">
<h3>陷阱2: 数组变更检测</h3>
<div class="example-group">
<div class="array-controls">
<input
v-model="newItem"
placeholder="新项目"
class="form-input small"
>
<button @click="addItemWrong">错误添加</button>
<button @click="addItemCorrect">正确添加</button>
</div>
<ul class="item-list">
<li v-for="(item, index) in itemArray" :key="index">
{{ item }}
<button @click="removeItem(index)" class="remove-btn">×</button>
</li>
</ul>
<p>数组长度: {{ itemArray.length }}</p>
</div>
</div>
<!-- 陷阱3: 异步操作处理 -->
<div class="demo-section">
<h3>陷阱3: 异步操作处理</h3>
<div class="example-group">
<button @click="fetchDataWrong">错误方式获取数据</button>
<button @click="fetchDataCorrect">正确方式获取数据</button>
<p>加载状态: {{ loadingStatus }}</p>
<p>数据: {{ apiData }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
// 陷阱1: 对象引用问题
const userObject = reactive({
name: 'John',
age: 30
})
// 错误方式:直接替换对象(会失去响应性)
const updateUserWrong = () => {
// userObject = { name: 'Jane', age: 25 } // 这样做是错误的!
console.log('这种方式是错误的')
}
// 正确方式:修改对象属性
const updateUserCorrect = () => {
userObject.name = 'Jane'
userObject.age = 25
}
// 陷阱2: 数组变更检测
const itemArray = ref(['苹果', '香蕉'])
const newItem = ref('')
// 错误方式:直接通过索引设置
const addItemWrong = () => {
// itemArray.value.length = 3 // 这样做不会触发更新
// itemArray.value[2] = newItem.value // 这样做也不会触发更新
console.log('这种方式是错误的')
newItem.value = ''
}
// 正确方式:使用数组方法
const addItemCorrect = () => {
if (newItem.value.trim()) {
itemArray.value.push(newItem.value.trim())
newItem.value = ''
}
}
const removeItem = (index) => {
itemArray.value.splice(index, 1)
}
// 陷阱3: 异步操作处理
const loadingStatus = ref('空闲')
const apiData = ref('')
// 错误方式:不处理异步状态
const fetchDataWrong = () => {
loadingStatus.value = '加载中...'
// 模拟 API 调用
setTimeout(() => {
apiData.value = '获取到的数据'
// 忘记更新加载状态
}, 1000)
}
// 正确方式:完整处理异步状态
const fetchDataCorrect = () => {
loadingStatus.value = '加载中...'
apiData.value = ''
// 模拟 API 调用
setTimeout(() => {
apiData.value = '获取到的数据'
loadingStatus.value = '加载完成'
// 3秒后重置状态
setTimeout(() => {
loadingStatus.value = '空闲'
}, 3000)
}, 1000)
}
</script>
<style>
.pitfalls-demo {
max-width: 700px;
margin: 0 auto;
padding: 20px;
}
.demo-section {
margin-bottom: 30px;
padding: 20px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.demo-section h3 {
margin-top: 0;
color: #495057;
}
.example-group {
margin-top: 15px;
}
button {
padding: 8px 16px;
margin: 5px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #0056b3;
}
.form-input {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
margin: 5px;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-input.small {
width: 120px;
}
.array-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.item-list {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
margin: 10px 0;
min-height: 50px;
}
.item-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid #eee;
}
.item-list li:last-child {
border-bottom: none;
}
.remove-btn {
background-color: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn:hover {
background-color: #c82333;
}
</style>
总结
watch 配置选项
| 选项 | 说明 | 默认值 |
|---|---|---|
immediate | 是否立即执行回调 | false |
deep | 是否深度侦听对象 | false |
flush | 回调执行时机 | 'pre' |
onTrack | 调试用,访问依赖时触发 | - |
onTrigger | 调试用,依赖变化时触发 | - |
使用建议
-
性能考虑:
- 大对象使用
deep: true时要谨慎 - 频繁变化的数据使用防抖
- 不需要时及时停止侦听器
- 大对象使用
-
最佳实践:
- 侦听多个相关数据时使用数组形式
- 异步操作要正确处理加载状态
- 合理使用
immediate和deep选项
-
何时使用 watch:
- 需要执行异步操作
- 需要开销较大的计算
- 需要精确控制响应时机
- 需要监听多个数据源
记忆口诀:
- watch:数据变化我响应
- immediate:立即执行不等待
- deep:深层变化我也知
- 多个数据:数组形式一起听
- 性能优化:防抖节流手动停
这样就能很好地掌握 Vue3 的 watch 侦听器了!