Skip to content

侦听器

侦听器允许我们在响应式数据变化时执行副作用。Vue 提供了两种主要的侦听器:watchwatchEffect

基本示例

watch

watch 函数用于侦听一个或多个响应式数据源,并在数据变化时执行回调函数。

vue
<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('Questions usually contain a question mark. ;-)')

// 可以直接侦听一个 ref
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.indexOf('?') > -1) {
    answer.value = 'Thinking...'
    try {
      const res = await fetch('https://yesno.wtf/api')
      answer.value = (await res.json()).answer
    } catch (error) {
      answer.value = 'Error! Could not reach the API. ' + error
    }
  }
})
</script>

<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" />
  </p>
  <p>{{ answer }}</p>
</template>

watchEffect

watchEffect 会立即执行一遍回调函数,如果这时函数产生了副作用,Vue 会自动追踪副作用的依赖关系,并在其依赖变更时重新运行该函数。

vue
<script setup>
import { ref, watchEffect } from 'vue'

const todoId = ref(1)
const data = ref(null)

watchEffect(async () => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
  )
  data.value = await response.json()
})
</script>

<template>
  <p>Todo id: {{ todoId }}</p>
  <button @click="todoId++">Fetch next todo</button>
  <p v-if="!data">Loading...</p>
  <pre v-else>{{ data }}</pre>
</template>

侦听数据源类型

侦听单个源

javascript
const x = ref(0)
const y = ref(0)

// 单个 ref
watch(x, (newX) => {
  console.log(`x is ${newX}`)
})

// getter 函数
watch(
  () => x.value + y.value,
  (sum) => {
    console.log(`sum of x + y is: ${sum}`)
  }
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x is ${newX} and y is ${newY}`)
})

侦听响应式对象

javascript
const obj = reactive({ count: 0 })

// 这不能正常工作,因为 watch() 得到的参数是一个 number
watch(obj.count, (count) => {
  console.log(`count is: ${count}`)
})

// 提供一个 getter 函数
watch(
  () => obj.count,
  (count) => {
    console.log(`count is: ${count}`)
  }
)

深层侦听器

直接给 watch() 传入一个响应式对象,会隐式地创建一个深层侦听器——该回调函数在所有嵌套的变更时都会被触发:

javascript
const obj = reactive({ count: 0, nested: { id: 1 } })

watch(obj, (newValue, oldValue) => {
  // 在嵌套的属性变更时触发
  // 注意:`newValue` 此处和 `oldValue` 是相等的
  // 因为它们是同一个对象!
})

obj.count++
obj.nested.id++

相比之下,一个返回响应式对象的 getter 函数,只有在返回不同的对象时,才会触发回调:

javascript
watch(
  () => state.someObject,
  () => {
    // 仅当 state.someObject 被替换时触发
  }
)

你也可以给上面这个例子显式地加上 deep 选项,强制转成深层侦听器:

javascript
watch(
  () => state.someObject,
  (newValue, oldValue) => {
    // 注意:`newValue` 此处和 `oldValue` 是相等的
    // *除非* state.someObject 被整个替换了
  },
  { deep: true }
)

即时回调的侦听器

watch 默认是懒执行的:仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时,立即执行一遍回调。

我们可以通过传入 immediate: true 选项来强制侦听器的回调立即执行:

javascript
watch(source, (newValue, oldValue) => {
  // 立即执行,且当 `source` 改变时再次执行
}, { immediate: true })

回调的触发时机

当你更改了响应式状态,它可能会同时触发 Vue 组件更新和侦听器回调。

默认情况下,用户创建的侦听器回调,都会在 Vue 组件更新之前被调用。这意味着你在侦听器回调中访问的 DOM 将是被 Vue 更新之前的状态。

如果想在侦听器回调中能访问被 Vue 更新之后的 DOM,你需要指明 flush: 'post' 选项:

javascript
watch(source, callback, {
  flush: 'post'
})

watchEffect(callback, {
  flush: 'post'
})

后置刷新的 watchEffect() 有个更方便的别名 watchPostEffect()

javascript
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})

停止侦听器

setup()<script setup> 中用同步语句创建的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,你无需关心怎么停止一个侦听器。

一个关键点是,侦听器必须用同步语句创建:如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏。

vue
<script setup>
import { watchEffect } from 'vue'

// 它会自动停止
watchEffect(() => {})

// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

要手动停止一个侦听器,请调用 watchwatchEffect 返回的函数:

javascript
const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()

注意,需要异步创建侦听器的情况很少,请尽可能选择同步创建。如果需要等待一些异步数据,你可以使用条件式的侦听逻辑:

javascript
// 需要异步请求得到的数据
const data = ref(null)

watchEffect(() => {
  if (data.value) {
    // 数据加载后执行某些操作...
  }
})

选项式 API

watch 选项

javascript
export default {
  data() {
    return {
      question: '',
      answer: 'Questions usually contain a question mark. ;-)'
    }
  },
  watch: {
    // 每当 question 改变时,这个函数就会执行
    question(newQuestion, oldQuestion) {
      if (newQuestion.indexOf('?') > -1) {
        this.getAnswer()
      }
    }
  },
  methods: {
    async getAnswer() {
      this.answer = 'Thinking...'
      try {
        const res = await fetch('https://yesno.wtf/api')
        this.answer = (await res.json()).answer
      } catch (error) {
        this.answer = 'Error! Could not reach the API. ' + error
      }
    }
  }
}

深层侦听器

javascript
export default {
  watch: {
    someObject: {
      handler(newValue, oldValue) {
        // 注意:在嵌套的变更中,
        // 只要没有替换对象本身,
        // 那么这里的 `newValue` 和 `oldValue` 相同
      },
      deep: true
    }
  }
}

即时回调的侦听器

javascript
export default {
  watch: {
    question: {
      handler(newQuestion, oldQuestion) {
        // ...
      },
      // 强制立即执行回调
      immediate: true
    }
  }
}

回调数组

javascript
export default {
  watch: {
    // 字符串方法名
    a: 'someMethod',
    // 该回调会在任何被侦听的对象的 property 改变时被调用,不论其被嵌套多深
    c: {
      handler: 'someMethod',
      deep: true
    },
    // 侦听单个嵌套属性:
    'c.d': function (val, oldVal) {
      // do something
    },
    // 该回调将会在侦听开始之后被立即调用
    e: {
      handler(val, oldVal) {
        // do something
      },
      immediate: true
    },
    // 你可以传入回调数组,它们会被逐一调用
    f: [
      'handle1',
      function handle2(val, oldVal) {
        // do something
      },
      {
        handler: function handle3(val, oldVal) {
          // do something
        }
      }
    ]
  },
  methods: {
    someMethod() {
      // do something
    },
    handle1() {
      // do something
    }
  }
}

this.$watch()

我们也可以使用组件实例的 $watch() 方法来命令式地创建一个侦听器:

javascript
export default {
  created() {
    this.$watch('question', (newQuestion) => {
      // ...
    })
  }
}

如果要在特定条件下设置一个侦听器,或者只侦听响应用户交互的内容,这方法很有用。它还允许你提前停止该侦听器。

实际应用示例

表单验证

vue
<script setup>
import { ref, watch, computed } from 'vue'

const email = ref('')
const password = ref('')
const confirmPassword = ref('')

const emailError = ref('')
const passwordError = ref('')
const confirmPasswordError = ref('')

// 邮箱验证
watch(email, (newEmail) => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (newEmail && !emailRegex.test(newEmail)) {
    emailError.value = '请输入有效的邮箱地址'
  } else {
    emailError.value = ''
  }
})

// 密码验证
watch(password, (newPassword) => {
  if (newPassword && newPassword.length < 6) {
    passwordError.value = '密码长度至少为6位'
  } else {
    passwordError.value = ''
  }
})

// 确认密码验证
watch([password, confirmPassword], ([newPassword, newConfirmPassword]) => {
  if (newConfirmPassword && newPassword !== newConfirmPassword) {
    confirmPasswordError.value = '两次输入的密码不一致'
  } else {
    confirmPasswordError.value = ''
  }
})

const isFormValid = computed(() => {
  return email.value && 
         password.value && 
         confirmPassword.value &&
         !emailError.value && 
         !passwordError.value && 
         !confirmPasswordError.value
})
</script>

<template>
  <form>
    <div>
      <input v-model="email" placeholder="邮箱" />
      <span v-if="emailError" class="error">{{ emailError }}</span>
    </div>
    
    <div>
      <input v-model="password" type="password" placeholder="密码" />
      <span v-if="passwordError" class="error">{{ passwordError }}</span>
    </div>
    
    <div>
      <input v-model="confirmPassword" type="password" placeholder="确认密码" />
      <span v-if="confirmPasswordError" class="error">{{ confirmPasswordError }}</span>
    </div>
    
    <button :disabled="!isFormValid" type="submit">注册</button>
  </form>
</template>

数据持久化

vue
<script setup>
import { ref, watch } from 'vue'

const preferences = ref({
  theme: 'light',
  language: 'zh-CN',
  notifications: true
})

// 从 localStorage 加载设置
const loadPreferences = () => {
  const saved = localStorage.getItem('userPreferences')
  if (saved) {
    preferences.value = { ...preferences.value, ...JSON.parse(saved) }
  }
}

// 保存设置到 localStorage
watch(
  preferences,
  (newPreferences) => {
    localStorage.setItem('userPreferences', JSON.stringify(newPreferences))
  },
  { deep: true }
)

// 组件挂载时加载设置
loadPreferences()
</script>

<template>
  <div>
    <h3>用户设置</h3>
    
    <label>
      主题:
      <select v-model="preferences.theme">
        <option value="light">浅色</option>
        <option value="dark">深色</option>
      </select>
    </label>
    
    <label>
      语言:
      <select v-model="preferences.language">
        <option value="zh-CN">中文</option>
        <option value="en-US">English</option>
      </select>
    </label>
    
    <label>
      <input 
        type="checkbox" 
        v-model="preferences.notifications"
      />
      启用通知
    </label>
  </div>
</template>

搜索防抖

vue
<script setup>
import { ref, watch } from 'vue'

const searchQuery = ref('')
const searchResults = ref([])
const isLoading = ref(false)

let searchTimeout = null

const performSearch = async (query) => {
  if (!query.trim()) {
    searchResults.value = []
    return
  }
  
  isLoading.value = true
  
  try {
    // 模拟 API 调用
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
    const results = await response.json()
    searchResults.value = results
  } catch (error) {
    console.error('搜索失败:', error)
    searchResults.value = []
  } finally {
    isLoading.value = false
  }
}

// 防抖搜索
watch(searchQuery, (newQuery) => {
  // 清除之前的定时器
  if (searchTimeout) {
    clearTimeout(searchTimeout)
  }
  
  // 设置新的定时器
  searchTimeout = setTimeout(() => {
    performSearch(newQuery)
  }, 300)
})
</script>

<template>
  <div>
    <input 
      v-model="searchQuery" 
      placeholder="搜索..." 
      class="search-input"
    />
    
    <div v-if="isLoading" class="loading">
      搜索中...
    </div>
    
    <ul v-else-if="searchResults.length" class="results">
      <li v-for="result in searchResults" :key="result.id">
        {{ result.title }}
      </li>
    </ul>
    
    <div v-else-if="searchQuery.trim()" class="no-results">
      没有找到相关结果
    </div>
  </div>
</template>

性能考虑

避免在侦听器中创建大量对象

javascript
// 不好
watch(someRef, () => {
  // 每次都创建新对象
  const expensiveObject = createExpensiveObject()
  doSomething(expensiveObject)
})

// 好
const expensiveObject = createExpensiveObject()
watch(someRef, () => {
  // 重用对象
  doSomething(expensiveObject)
})

使用 shallowRef 避免深层响应式

javascript
import { shallowRef, watch } from 'vue'

// 对于大型对象,使用 shallowRef 可以提高性能
const largeObject = shallowRef({
  // 大量数据...
})

watch(largeObject, (newValue) => {
  // 只有当整个对象被替换时才会触发
}, { deep: false })

条件性侦听

javascript
const shouldWatch = ref(true)
const data = ref('')

// 只在需要时进行侦听
watchEffect(() => {
  if (shouldWatch.value) {
    // 执行侦听逻辑
    console.log('Data changed:', data.value)
  }
})

调试侦听器

使用 onTrack 和 onTrigger

javascript
watchEffect(
  () => {
    // 副作用函数
    console.log(count.value)
  },
  {
    onTrack(e) {
      // 当 count.value 被追踪为依赖时触发
      debugger
    },
    onTrigger(e) {
      // 当 count.value 被修改时触发
      debugger
    }
  }
)

下一步

vue study guide - 专业的 Vue.js 学习平台