Vue3 watch到底怎么用才不踩坑?新手到进阶的全要点都在这了
写Vue3项目时,不管是响应式数据、计算属性还是路由参数,只要需要触发自定义副作用逻辑,大家第一反应可能就是用watch,但真上手后,总会遇到奇怪的问题:比如明明数据改了,回调却没执行;回调里拿到的旧值和新值一模一样;有时候watch反而拖慢了页面性能……
其实这些问题,都是因为没搞懂Vue3 watch的核心机制和细节规则,今天咱们就从新手入门到性能优化,一步步聊透watch的正确打开方式。
先搞懂基础:watch能监听什么?不能监听什么?
很多刚从Vue2转过来的朋友,可能会直接照搬之前的写法——但Vue3的响应式系统改成了Proxy,监听规则也有了细微但关键的变化,咱们先把监听对象的范围理清楚。
可直接监听的3种核心类型
在Vue3的<script setup>里,watch可以直接接收3种参数作为“监听源”,这是最基础也最不容易出错的用法:
-
单个ref值:不管是基本类型还是对象类型的ref,只要ref的
.value本身发生变化(比如基本类型直接赋值、对象类型整个替换成新对象),watch都会触发,举个简单的例子:import { ref, watch } from 'vue' const count = ref(0) const user = ref({ name: '张三' }) // 监听基本类型ref watch(count, (newVal, oldVal) => { console.log(`count从${oldVal}变成了${newVal}`) }) // 监听对象类型ref——但这里默认只监听整个对象的替换 watch(user, (newVal, oldVal) => { console.log(`user整个对象换了`) }) // 测试一下 count.value++ // 触发第一个回调 user.value.name = '李四' // 不会触发第二个回调!这里很多人会踩坑 user.value = { name: '李四' } // 替换整个对象,触发第二个回调 -
单个reactive值:这里要注意,reactive对象本身就是深层响应式的,默认监听所有属性的变化,不管嵌套多少层,不过有个前提——得直接传reactive对象本身,不能传它的某个属性值的表达式:
import { reactive, watch } from 'vue' const user = reactive({ name: '张三', address: { city: '北京', street: '长安街' } }) // 直接传reactive对象,深层监听所有属性 watch(user, (newVal, oldVal) => { console.log(`user的某个属性变了`) }) // 错误写法!传某个属性的表达式,比如user.name,因为是基本类型, // 虽然可以监听,但需要把表达式包成箭头函数(马上讲第三种类型) // watch(user.name, ...) 这样写会直接报错 // 测试深层监听 user.address.street = '王府井' // 直接触发! -
箭头函数返回的监听源:这是最灵活的用法——不管是想监听reactive对象的单个/多个嵌套属性、多个ref/reactive的组合值、还是计算属性的结果,只要能在箭头函数里返回一个明确的可追踪的值或依赖集合,都可以实现监听,这种写法可以解决前两种的所有局限:
import { ref, reactive, computed, watch } from 'vue' const count1 = ref(0) const count2 = ref(10) const user = reactive({ name: '张三', age: 18 }) // 1. 监听reactive的单个属性 watch(() => user.age, (newVal, oldVal) => { console.log(`user的年龄从${oldVal}变成了${newVal}`) }) // 2. 监听多个值的组合(可以是ref、reactive属性、甚至表达式) watch([count1, count2, () => user.name], (newVals, oldVals) => { console.log(`组合值变化:新值是${newVals},旧值是${oldVals}`) }) // 3. 监听计算属性的结果 const totalCount = computed(() => count1.value + count2.value) watch(totalCount, (newVal, oldVal) => { console.log(`总和从${oldVal}变成了${newVal}`) }) // 测试 user.age++ // 触发第一个回调 user.name = '李四' // 触发第二个回调 count1.value += 5 // 同时触发第二个和第三个回调
绝对不能直接监听的类型
除了上面3种,其他的写法基本都会出错或者达不到预期,
- 直接传基本类型字面量:比如
watch(0, ...),0是静态值,根本没有响应式依赖,watch会直接忽略。 - 直接传reactive的某个解构出来的基本类型属性:比如解构
const { age } = user,解构出来的age已经失去了reactive的深层关联,变成了一个普通的基本类型变量,直接传watch(age, ...)没用,必须包成箭头函数() => age才行。 - 直接传普通对象/数组:没有被ref/reactive包裹的普通数据,根本不是响应式的,watch监听了也白搭。
新手最容易踩的3个坑,现在就能避开
刚才在基础部分其实已经提到过一些坑点,咱们把它们单独拎出来,配详细的避坑方案,以后写代码就不会再犯了。
坑1:监听reactive对象时,拿到的新值和旧值一模一样
刚才在写reactive默认监听的例子时,有没有注意到回调里的newVal和oldVal我没打印具体值?因为打印出来你会发现——它们是同一个对象的引用!这是Proxy机制导致的:reactive对象的修改是在原对象上进行的,没有生成新的对象副本,所以Vue3没办法拿到修改前的那个“旧对象的独立引用”。
避坑方案
解决这个问题有两种常用的方法,根据你的需求选就行:
- 如果只需要监听单个/少数几个嵌套属性:直接用箭头函数返回具体的属性值,这样拿到的新值和旧值就是独立的(如果属性是基本类型的话,本来就是独立的;如果是嵌套的小对象,后面讲immediate和deep的时候会提到另一种处理),比如刚才的user.address.city:
watch(() => user.address.city, (newVal, oldVal) => { console.log(`城市从${oldVal}变成了${newVal}`) // 这里的新旧值肯定不一样! }) - 如果确实需要拿到整个对象修改前后的对比:可以用
JSON.parse(JSON.stringify())先把对象转成字符串再转回来,生成一个独立的副本作为箭头函数的返回值——不过这种方法有局限性:不能处理Date、正则、循环引用、函数等特殊类型的数据,而且如果对象很大,性能会很差,还有一种更优雅的第三方库方案,但咱们先讲原生的,后面性能优化部分再提,原生的例子:watch( () => JSON.parse(JSON.stringify(user)), // 每次user变化都会生成新副本,所以watch能追踪到 (newVal, oldVal) => { console.log('新旧对象不一样了!', newVal, oldVal) } )
坑2:监听对象/数组的内部属性时,watch没触发
刚才在监听对象类型ref的例子里,修改user.value.name没触发回调,这就是因为默认情况下,不管是ref还是箭头函数返回的对象/数组,watch都是“浅层监听”的——只会监听引用的变化,不会监听内部属性/元素的变化,那什么时候是深层监听呢?刚才提过的——直接传整个reactive对象的时候,默认是深层监听,这一点要记牢。
避坑方案
不管是ref对象还是箭头函数返回的对象/数组,只要加上deep: true这个配置项,就能开启深层监听了:
import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 18 })
const hobbies = ref(['篮球', '游泳'])
// 开启ref对象的深层监听
watch(
user,
(newVal, oldVal) => {
console.log('user的某个属性变了')
},
{ deep: true } // 加上这个配置
)
// 开启ref数组的深层监听
watch(
hobbies,
(newVal, oldVal) => {
console.log('hobbies的某个元素变了')
},
{ deep: true }
)
// 测试
user.value.name = '李四' // 触发第一个回调
hobbies.value.push('爬山') // 触发第二个回调
hobbies.value[0] = '足球' // 也触发第二个回调
不过要注意:开启深层监听会有一定的性能损耗——因为Vue3需要递归遍历整个对象/数组的所有嵌套属性,给它们都加上追踪器,如果对象/数组非常大(比如成百上千条数据的表格),这种损耗可能会影响页面的渲染速度,所以尽量不要随便开deep,能用箭头函数监听单个/多个关键属性的话,优先用箭头函数。
坑3:页面刚加载的时候,想要watch自动执行一次,但没反应
比如我们想监听路由参数的变化,并且在页面刚加载时,就根据初始的路由参数请求数据,这时候如果直接写普通的watch,只有当路由参数后续变化时才会触发,第一次加载时不会执行——这就是因为默认情况下,watch是“惰性执行”的。
避坑方案
加上immediate: true这个配置项,就能让watch在初始化的时候自动执行一次回调了,这时候有个小细节要注意:第一次自动执行时,oldVal会是undefined,因为还没有“之前的值”,咱们用路由参数的例子演示一下:
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const userData = ref(null)
// 监听路由参数id,并且初始化时自动执行
watch(
() => route.params.id,
async (newId, oldId) => {
console.log('旧id:', oldId) // 第一次执行时是undefined
console.log('新id:', newId)
// 这里可以写请求数据的逻辑
if (newId) {
const res = await fetch(`/api/user/${newId}`)
userData.value = await res.json()
}
},
{ immediate: true } // 加上这个配置
)
这个配置项非常常用,几乎所有需要和初始化数据请求结合的watch场景都会用到。
进阶优化:让你的watch既高效又好用
搞懂了基础和避坑,咱们再来聊点进阶的内容——怎么优化watch的性能,怎么处理watch里的异步逻辑,怎么和watchEffect做对比选择。
优化1:谨慎使用deep,尽量用箭头函数精确监听
刚才在避坑的时候已经提过,deep会有性能损耗,那具体什么时候必须用deep,什么时候可以用箭头函数代替呢?
-
必须用deep的场景:比如监听一个嵌套很深但结构不确定的JSON配置对象,你不知道用户会修改哪个属性;或者监听一个动态增删的数组,并且需要监听所有元素的所有属性变化。
-
优先用箭头函数的场景:只要能明确知道需要监听的关键属性/元素,不管有多少个,都可以用数组形式的箭头函数监听,比如监听表格数据的总条数、当前页码、每页条数这三个决定请求参数的属性:
import { reactive, watch } from 'vue' const tableParams = reactive({ page: 1, pageSize: 10, total: 0, // 可能还有很多其他不需要监听的属性,比如搜索表单的临时值 searchTemp: { name: '', age: '' } }) // 只监听page、pageSize、total这三个关键属性 watch( [() => tableParams.page, () => tableParams.pageSize, () => tableParams.total], (newVals, oldVals) => { console.log('请求参数变化,需要重新请求表格数据') } )这样写的话,不管搜索表单的临时值怎么改,watch都不会触发,性能会好很多。
优化2:用watchPostEffect/watchSyncEffect代替watch的特殊场景
Vue3除了watch,还有watchEffect、watchPostEffect、watchSyncEffect这三个API——它们都是用来处理响应式副作用的,但触发时机和监听方式不一样,很多人会混淆它们的用法,咱们先简单对比一下,再讲什么时候用哪个: | API | 监听方式 | 触发时机 | 是否惰性执行 | 是否支持新值旧值 | 是否支持配置项(deep/immediate/flush) | |---------------------|------------------------------|------------------------------|--------------|------------------|-----------------------------------------| | watch | 手动指定监听源 | 监听源变化时 | 是 | 是 | 是(flush有三个选项:pre/post/sync) | | watchEffect | 自动追踪回调里的所有响应式依赖 | 响应式依赖变化时;初始化时自动执行 | 否 | 否 | 是(只有flush选项) | | watchPostEffect | 自动追踪回调里的所有响应式依赖 | DOM更新之后执行;初始化时自动执行 | 否 | 否 | 否(内部固定flush: 'post') | | watchSyncEffect | 自动追踪回调里的所有响应式依赖 | 响应式依赖变化后立即同步执行;初始化时自动执行 | 否 | 否 | 否(内部固定flush: 'sync') |
这里的flush选项简单解释一下:
- pre(默认):在DOM更新之前执行回调,适合用来修改DOM更新前的一些数据。
- post:在DOM更新之后执行回调,适合用来操作DOM元素(比如获取DOM的高度、宽度)。
- sync:响应式依赖变化后立即同步执行,不等待批量更新,尽量少用,会影响性能。
那什么时候用watch,什么时候用这三个Effect呢?
- 用watch的场景:
- 需要拿到变化前后的新值和旧值。
- 不需要立即执行,只需要监听特定的几个响应式源变化时执行。
- 需要精确控制监听的范围(不想追踪回调里的所有依赖)。
- 用watchEffect的场景:
- 不需要拿到新值旧值。
- 回调里的所有响应式依赖都需要被监听,初始化时也需要自动执行。
- 不需要手动指定监听源,代码更简洁。
- 用watchPostEffect的场景:
- 满足watchEffect的所有场景,但必须在DOM更新之后执行(比如获取ref绑定的DOM元素的尺寸)。
- 不想手动写watchEffect的flush: 'post'配置项。
- 用watchSyncEffect的场景:
- 满足watchEffect的所有场景,但必须立即同步执行(非常少见,比如处理一些跨组件的紧急状态同步)。
举个watchPostEffect的例子,获取DOM元素的高度:
import { ref, watchPostEffect } from 'vue'
const containerRef = ref(null)
const containerHeight = ref(0)
// watchPostEffect会在DOM更新之后执行,所以containerRef.value肯定已经挂载了
watchPostEffect(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight
console.log('容器高度:', containerHeight.value)
}
})
如果用watchEffect默认的pre模式,第一次初始化时containerRef.value可能还是null,获取不到高度;如果用watch的话,需要手动指定监听源(比如容器里的内容变化时触发),而且还要加immediate: true,还要注意flush: 'post',代码会更复杂。
优化3:处理watch里的异步逻辑,避免竞态条件
很多时候,watch的回调里会写异步请求数据的逻辑——比如刚才的路由参数例子,监听id的变化,请求对应的用户数据,但这里有个常见的问题:竞态条件——如果用户快速点击切换id,第一次请求的数据还没回来,第二次请求已经发出去了,结果第二次请求先回来,第一次请求后回来,就会导致页面显示的是旧数据。
避坑+优化方案
解决竞态条件的方法有很多,比如给请求加loading状态,在发起新请求之前取消上一个请求(如果用axios的话,可以用CancelToken或者AbortController;如果用fetch的话,直接用AbortController),咱们用fetch+AbortController的例子演示一下,这是原生的解决方案,不需要依赖第三方库:
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const userData = ref(null)
const loading = ref(false)
const error = ref(null)
// 定义一个变量来保存上一个请求的AbortController
let lastAbortController = null
watch(
() => route.params.id,
async (newId) => {
// 1. 如果上一个请求还没完成,就取消它
if (lastAbortController) {
lastAbortController.abort()
}
// 2. 创建新的AbortController
const abortController = new AbortController()
lastAbortController = abortController
// 3. 重置状态
loading.value = true
error.value = null
userData.value = null
try {
// 4. 发起请求,把signal传进去
const res = await fetch(`/api/user/${newId}`, {
signal: abortController.signal
})
if (!res.ok) {
throw new Error('请求失败')
}
const data = await res.json()
userData.value = data
} catch (err) {
// 5. 如果是主动取消的请求,就不显示错误
if (err.name !== 'AbortError') {
error.value = err.message
}
} finally {
// 6. 不管请求成功还是失败,都关闭loading(除非是主动取消的?其实不用,主动取消的也可以关闭,因为新请求已经发起了)
loading.value = false
}
},
{ immediate: true }
)
这样写的话,不管用户切换id有多快,页面只会显示最后一次请求的结果,不会出现竞态条件的问题。
总结一下Vue3 watch的核心要点
最后咱们把今天讲的内容总结成几个核心要点,方便大家记忆:
- 监听对象范围:单个ref、单个reactive、箭头函数返回的监听源(单个/多个属性、组合值、计算属性)。
- 默认规则:直接传reactive是深层监听、惰性执行(不自动初始化)、旧值新值引用相同(如果是reactive或ref对象);其他情况是浅层监听、惰性执行、旧值新值独立(如果是基本类型)。
- 常用配置项:
deep: true:开启深层监听(谨慎使用)。immediate: true:初始化时自动执行一次。flush: 'pre'/'post'/'sync':控制回调的执行时机(pre默认,post操作DOM,sync少用)。
- 避坑技巧:
- 旧值新值引用相同:用箭头函数监听具体属性,或者用JSON序列化生成副本(注意局限性)。
- 内部属性变化没触发:加deep,或者用箭头函数监听具体属性。
- 初始化没执行:加immediate。
- 进阶优化:
- 谨慎用deep,尽量用箭头函数精确监听。
- 根据场景选择watch/watchEffect/watchPostEffect/watchSyncEffect。
- 处理异步逻辑时用AbortController取消上一个请求,避免竞态条件。
好了,今天关于Vue3 watch的内容就聊到这里了,如果你还有其他Vue3的问题,欢迎在评论区留言讨论!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


