Skip to content

侦听器

虽然计算属性在大多数情况下更合适,但有时也需要一个自定义的侦听器。这就是为什么 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)
    }
  }
)

下一步

基于 Vue.js 官方文档构建的学习宝典