Vue3 watch 侦听器详解

68 阅读11分钟

Vue3 watch 侦听器详解

核心概念理解

什么是 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调试用,依赖变化时触发-

使用建议

  1. 性能考虑

    • 大对象使用 deep: true 时要谨慎
    • 频繁变化的数据使用防抖
    • 不需要时及时停止侦听器
  2. 最佳实践

    • 侦听多个相关数据时使用数组形式
    • 异步操作要正确处理加载状态
    • 合理使用 immediatedeep 选项
  3. 何时使用 watch

    • 需要执行异步操作
    • 需要开销较大的计算
    • 需要精确控制响应时机
    • 需要监听多个数据源

记忆口诀

  • watch:数据变化我响应
  • immediate:立即执行不等待
  • deep:深层变化我也知
  • 多个数据:数组形式一起听
  • 性能优化:防抖节流手动停

这样就能很好地掌握 Vue3 的 watch 侦听器了!