在 Vue3 的响应式系统中,watch 和 watchEffect 是两个非常重要的 API,它们可以帮助我们监听数据变化并执行相应的副作用操作。很多刚接触 Vue3 的开发者可能会对这两个 API 的使用场景和区别感到困惑,今天我就结合自己多年的实战经验,带大家彻底搞懂它们的用法和最佳实践。
watch 的基本用法就像是一个精确的狙击手,它需要你明确指定要监听的目标。比如我们要监听一个 ref 值的变化:
javascript复制import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`计数从 ${oldValue} 变成了 ${newValue}`)
})
这里有几个关键点需要注意:
watchEffect 的用法则更像是一个雷达,它会自动追踪回调函数中使用的所有响应式依赖:
javascript复制import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
console.log(`当前计数是: ${count.value}`)
})
watchEffect 的特点在于:
在实际项目中,我经常看到开发者纠结该用 watch 还是 watchEffect。简单来说,当你明确知道要监听哪些数据时用 watch;当你需要自动追踪依赖或者需要立即执行副作用时用 watchEffect。
当我们需要监听一个基本类型数据(如数字、字符串等)时,可以直接使用 watch 监听 ref 值:
javascript复制const message = ref('Hello')
watch(message, (newVal, oldVal) => {
console.log(`消息从 "${oldVal}" 变成了 "${newVal}"`)
})
这里有个小技巧:如果你只需要新值而不关心旧值,可以简化回调函数的参数:
javascript复制watch(message, (newVal) => {
console.log(`新消息: "${newVal}"`)
})
监听对象或数组时,情况会稍微复杂一些。默认情况下,watch 是浅层监听的:
javascript复制const user = ref({ name: '张三', age: 20 })
watch(user, (newVal, oldVal) => {
// 只有当整个 user.value 被替换时才会触发
console.log('用户信息变化了')
})
如果我们需要监听对象内部属性的变化,就需要开启深度监听:
javascript复制watch(user, (newVal, oldVal) => {
console.log('用户信息变化了')
}, { deep: true })
但要注意,开启 deep 后,newVal 和 oldVal 会指向同一个对象,因为 Vue 使用的是响应式代理。这是我踩过的一个坑,后来发现官方文档中有明确说明。
对于 reactive 创建的对象,watch 的行为有些特殊:
javascript复制const state = reactive({
count: 0,
user: { name: '李四' }
})
watch(() => state.count, (newVal) => {
console.log(`计数变为: ${newVal}`)
})
这里有几个关键点:
有时候我们只需要监听对象中的某个特定属性,这时候可以用 getter 函数:
javascript复制const product = reactive({
id: 1,
details: {
name: '手机',
price: 2999
}
})
watch(
() => product.details.price,
(newPrice) => {
console.log(`价格更新为: ${newPrice}`)
}
)
这种写法在管理复杂状态时特别有用,可以避免不必要的监听开销。
watch 还支持同时监听多个数据源,这在需要协调多个状态时非常方便:
javascript复制const x = ref(0)
const y = ref(0)
watch([x, y], ([newX, newY], [oldX, oldY]) => {
console.log(`x从${oldX}变为${newX}, y从${oldY}变为${newY}`)
})
在实际项目中,我经常用这种方式来处理表单多个字段的联动校验。
flush 选项控制着回调函数的执行时机,它有三个可选值:
javascript复制watch(source, callback, {
flush: 'post' // 适用于需要访问更新后的DOM
})
这个配置项在需要操作DOM时特别重要。我曾经遇到过一个bug,在回调中尝试获取元素高度,但因为没设置flush: 'post',获取到的是更新前的高度。
immediate 选项可以让 watch 在创建时就立即执行一次回调:
javascript复制watch(user, (newVal) => {
console.log('用户数据:', newVal)
}, { immediate: true })
而 once 选项(Vue 3.4+)则让回调只执行一次:
javascript复制watch(notification, (newVal) => {
console.log('收到通知:', newVal)
}, { once: true })
这两个选项在一些特殊场景下非常有用,比如需要在初始化时加载数据,或者只需要响应第一次变化的情况。
深度监听(deep: true)虽然方便,但会带来额外的性能开销:
javascript复制watch(bigObject, (newVal) => {
// 处理变化
}, { deep: true })
对于大型对象,深度监听可能会导致性能问题。我的经验是:
我曾经优化过一个项目,把几个深度监听改为特定属性监听后,性能提升了约30%。
watchEffect 最大的特点就是自动依赖追踪,这使得代码更加简洁:
javascript复制const count = ref(0)
const double = computed(() => count.value * 2)
watchEffect(() => {
console.log(`计数: ${count.value}, 双倍: ${double.value}`)
})
这个例子中,watchEffect 会自动追踪 count 和 double 的变化,无需显式声明依赖。
watchEffect 提供了副作用清理的机制,这对于取消请求、清除定时器等操作非常有用:
javascript复制watchEffect((onCleanup) => {
const timer = setTimeout(() => {
console.log('定时器触发')
}, 1000)
onCleanup(() => {
clearTimeout(timer)
})
})
这个特性在组件卸载时特别重要,可以避免内存泄漏。我建议所有涉及副作用的操作都应该使用这个机制。
虽然 watchEffect 用起来很方便,但在某些情况下 watch 性能更好:
在我的性能测试中,对于简单场景两者差异不大,但在复杂组件中,合理使用 watch 可以获得更好的性能。
在使用侦听器时,最容易犯的错误就是创建无限循环:
javascript复制const count = ref(0)
// 错误示例:会导致无限循环
watch(count, (newVal) => {
count.value = newVal + 1
})
解决方法:
当需要监听大型列表时,直接监听整个数组可能会导致性能问题:
javascript复制const items = ref([...大量数据...])
// 不推荐的写法
watch(items, (newVal) => {
// 处理变化
}, { deep: true })
更好的做法:
忘记停止侦听器是内存泄漏的常见原因:
javascript复制import { watchEffect, onUnmounted } from 'vue'
export default {
setup() {
const stop = watchEffect(() => {
// 副作用代码
})
onUnmounted(() => {
stop() // 重要!组件卸载时停止侦听
})
}
}
这个习惯非常重要,特别是在SPA应用中。我曾经参与修复过一个内存泄漏问题,就是因为没有正确停止侦听器导致的。
Vue提供了侦听器的调试钩子,这在复杂场景下非常有用:
javascript复制watch(
source,
() => {
// 回调逻辑
},
{
onTrack(e) {
debugger // 依赖被追踪时触发
},
onTrigger(e) {
debugger // 依赖变化时触发
}
}
)
这些钩子可以帮助我们理解侦听器的工作机制,特别是在处理复杂依赖关系时。