Code前端首页关于Code前端联系我们

Vue3 watch 监听对象或嵌套对象没效果怎么办?有哪些好用的处理方法?

terry 1小时前 阅读数 23 #Vue
文章标签 Vue3 watch深度监听

作为前端开发,你肯定用过 Vue 的 watch 吧?到了 Vue3 时代,watch 虽然保留了核心功能,但 API 细节和对象监听的逻辑有了不小变化——很多刚开始接触 Vue3 的人,第一步踩坑就是监听普通对象或嵌套对象时,要么完全没反应,要么只有第一层数据变化能触发,深层改了个寂寞,今天就把我踩过的坑、整理过的实用方案全掏出来,结合场景给你讲明白,下次遇到直接套就行。

Vue3 watch 监听对象没反应的核心原因是什么?

要解决问题,得先搞懂底层逻辑,不然换多少写法都是碰运气,Vue3 放弃了 Vue2 的 Object.defineProperty,改用 Proxy 做响应式核心,这是对象监听逻辑变化的根本原因。

先回忆下 Proxy 和 defineProperty 的区别:defineProperty 是预先劫持对象的每个属性,哪怕你没用到的属性也要提前绑定getter/setter,Vue2 里直接给对象加新属性、删旧属性是没法触发响应式的,得用 Vue.set/Vue.delete,而 Proxy 是劫持整个对象的访问和修改操作,不管你原来有多少属性,新增、删除都能被捕获——哎不对啊,这不是更方便了吗?怎么还会出现没反应的情况?

问题出在「引用地址」和「监听粒度」上。

问题1:直接赋值了整个新对象,但 watch 没加 { immediate: true, deep: false } 以外的配置

举个最常见的例子:

// 场景:从接口拿到数据后,直接覆盖整个 data 里的 user 对象
import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 20 })
watch(user, (newVal, oldVal) => {
  console.log('user变了!', newVal, oldVal)
})
// 模拟接口返回
setTimeout(() => {
  user.value = { name: '李四', age: 21 } // 这里是新对象!引用地址变了
}, 1000)

这段代码会触发吗?按道理说引用地址变了,ref 监听的是 value 的引用,应该能触发啊?哦对,这段代码确实能触发,那很多人说的“直接赋值没反应”又是啥情况?别急,看第二种赋值方式:

// 另一种常见的错误场景:用 Object.assign 把新属性合并到旧对象,但对象本身的属性是基本类型时直接合并没问题,如果是复杂嵌套对象?
// 不对,先看简单的 Object.assign 替换引用吗?
// 错误写法:试图用 Object.assign 改变旧对象,但 watch 配置了 deep 以外的?不,先看基础的,哦不对,这个基础写法也能触发,再看真正的核心坑点:监听 reactive 对象的「本身」?
// 哦对!很多混淆 ref 和 reactive 的人会犯这个错:
import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
watch(user, (newVal, oldVal) => {
  console.log('user变了!', newVal, oldVal)
})
// 模拟接口返回
setTimeout(() => {
  // 错误!试图给 reactive 变量重新赋值整个对象——reactive 是不能直接替换引用的!
  // 替换后,原来的 Proxy 代理就断了,后面的修改既不会触发响应式更新视图,也不会触发 watch
  user = { name: '李四', age: 21 } 
}, 1000)

对,这个才是直接赋值没反应的重灾区!很多刚从 Vue2 转来的人,习惯了 data 里的对象随便重新赋值(因为 Vue2 会自动把重新赋值的对象也变成响应式),但 Vue3 的 reactive 不一样:你创建的 reactive({...}) 变量,本质是一个「绑定了原始对象引用的 Proxy 实例」,一旦给这个变量重新赋值,就相当于把变量指向了一个新的非 Proxy 对象,原来的监听、响应式全失效。

问题2:只改了嵌套对象的属性,但没加 { deep: true } 配置

这个应该是 Vue3 watch 监听对象最常见的坑了。

import { reactive, watch } from 'vue'
const user = reactive({ 
  name: '张三', 
  profile: { 
    address: { city: '北京', district: '朝阳区' },
    hobbies: ['编程', '看电影']
  }
})
watch(user, (newVal, oldVal) => {
  console.log('user变了!', newVal, oldVal)
})
// 模拟修改嵌套对象的属性
setTimeout(() => {
  user.profile.address.district = '海淀区' // 深层嵌套对象的基本类型属性变化
}, 1000)
setTimeout(() => {
  user.profile.hobbies.push('游泳') // 深层数组的push操作
}, 2000)

这两个 setTimeout 里的操作,都不会触发上面的 watch!为什么?因为 Vue3 的 watch 默认是「浅监听」:对于 ref 包裹的对象(或者说 watch 的源是一个响应式引用类型时),默认只监听这个引用的地址变化,不会去递归监听内部属性的变化。

那刚才说的 Proxy 不是能捕获所有操作吗?是能捕获,但 watch 默认不会去接收这些深层操作的通知——你得主动告诉它“我要深挖这个对象的每一层变化”,也就是加 { deep: true } 配置。

问题3:watch 监听的是一个「返回普通对象的函数」,但函数返回的对象没有依赖响应式数据?

哦对了,Vue3 的 watch 还支持「监听一个 getter 函数」,这个是 Vue2 也有的,但用法更灵活了,不过如果这个 getter 函数返回的是一个普通字面量对象,而且函数内部没有用到任何响应式数据的话,那不管什么时候都不会触发 watch,举个例子:

import { ref, watch } from 'vue'
const age = ref(20)
// 错误的 getter 写法:返回的是普通字面量,而且age的变化会不会影响返回值?看起来是影响,但为什么有时候不触发?哦不对,先看更极端的错误:
watch(
  () => ({ currentAge: 20 }), // 这里完全没用到age,不管你怎么改外面的age,这个函数的返回值都是同一个结构的普通对象,但每次返回的引用地址不一样啊?
  (newVal, oldVal) => {
    console.log('getter返回值变了!', newVal, oldVal)
  }
)
setTimeout(() => {
  age.value = 21
}, 1000)

哦对,这个极端例子里,age 变了,但 getter 函数根本没访问 age,Vue 不会追踪这个函数的依赖,自然不会触发 watch,哪怕你把函数改成访问 age,但返回的是普通字面量,Vue3 怎么判断要不要触发呢?

import { ref, watch } from 'vue'
const age = ref(20)
// 这个写法会触发吗?
watch(
  () => ({ currentAge: age.value }), // 访问了age,依赖有了
  (newVal, oldVal) => {
    console.log('getter返回值变了!', newVal, oldVal)
  }
)
setTimeout(() => {
  age.value = 21
}, 1000)

这个写法会触发,因为 getter 函数内部访问了响应式的 age.value,Vue 追踪到了依赖,age 一变就重新执行 getter,然后对比返回值——虽然每次返回的是新对象,但内容不一样,所以触发 watch。 但如果 getter 函数返回的是引用了响应式对象属性的对象,但属性本身没变化呢?

import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
// 这个写法会触发吗?
watch(
  () => ({ userObj: user }), // 这里的user是reactive对象,引用地址没变
  (newVal, oldVal) => {
    console.log('getter返回值变了!', newVal, oldVal)
  }
)
setTimeout(() => {
  user.age = 21
}, 1000)

这个写法不会触发!因为 getter 函数返回的对象 { userObj: user } 里的 userObj 引用的是同一个 reactive 对象,Vue 对比返回值的时候,发现对象的引用地址没变,就默认没变化,不管内部属性怎么改——除非你加 { deep: true } 配置,或者直接监听 user{ deep: true }

问题4:监听的是 reactive 对象的「解构属性」,但解构出来的是基本类型?

很多人为了代码简洁,喜欢把 reactive 对象的属性解构出来用,但要注意:解构 reactive 对象的基本类型属性时,会失去响应式!那对应的 watch 也会失效,举个例子:

import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
const { name, age } = user // 解构基本类型,name和age变成普通变量了!
// 监听age,这里的age是普通变量,肯定不会触发
watch(age, (newVal, oldVal) => {
  console.log('age变了!', newVal, oldVal)
})
// 哦不对,watch的源如果是普通变量,Vue会直接报错吗?还是不报错也不触发?
// 实际测试:Vue3 不会报错,但不会建立任何依赖追踪,所以不管怎么改user.age,这里的watch都不会触发
setTimeout(() => {
  user.age = 21
}, 1000)

如果要解构 reactive 对象的基本类型属性并保持响应式,得用 toRefs,这个后面讲技巧的时候会详细说。

解决 Vue3 watch 监听对象问题的5个实用技巧

搞懂了原因,现在给你几个经过验证的、覆盖大部分场景的处理方法,按使用频率排序。

技巧1:正确区分 ref 和 reactive,不要给 reactive 变量重新赋值

这个是最基础的,但也是最重要的,什么时候用 ref?什么时候用 reactive?这里给你个简单的判断标准:

  • 优先用 ref:不管是基本类型、数组、对象、函数,都能用 ref 包裹,ref 包裹的对象会自动通过 reactive 处理内部属性(Vue3.2+ 是这样,3.0-3.1 好像是浅响应式?不过现在大家都用3.2+了),ref 可以直接重新赋值 value,不会断代理,调试起来也方便(控制台直接打印 xxx.value 就能看到内容,reactive 打印出来是 Proxy 实例,有时候要点好几层)。
  • reactive 的场景:当你有一组逻辑上紧密相关的属性,比如用户信息、表单数据,用 reactive 可以把它们放在一个对象里,不用每次都写 .value,代码更简洁,但千万记住:不要给 reactive 变量重新赋值整个对象!如果要完全替换 reactive 对象的内容,可以用 Object.assign 合并,或者把 reactive 对象放在 ref 里(也就是 ref(reactive({...}))),不过后者有点多此一举,直接用 ref 包裹对象就行。

那正确的「完全替换 reactive 对象内容」的写法是什么?用 Object.assign 或者展开运算符(注意展开运算符也是合并到旧对象,不是替换引用):

import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
watch(user, (newVal, oldVal) => {
  console.log('user变了!', newVal, oldVal)
  // 注意:这里的oldVal和newVal是同一个对象引用!因为是合并到旧对象,不是替换
  // 想要拿到变化前的旧值,得用watchEffect或者getter函数配合深拷贝
}, { deep: true })
// 模拟接口返回新对象
setTimeout(() => {
  const newUser = { name: '李四', age: 21, gender: '男' }
  // 正确写法1:Object.assign合并到旧reactive对象
  Object.assign(user, newUser)
  // 正确写法2:展开运算符合并(本质和Object.assign一样,都是浅合并)
  // user = {...user, ...newUser} 错误!别这么写!
  // 正确写法3:循环赋值,适合只更新部分属性的情况
  // for (const key in newUser) {
  //   user[key] = newUser[key]
  // }
}, 1000)

技巧2:监听嵌套对象/数组时,必须加 { deep: true } 配置

这个是解决“深层修改没反应”最直接的方法,不管你监听的是 ref 包裹的对象、reactive 对象,还是返回引用类型的 getter 函数,只要想捕获内部属性的变化,就加 { deep: true }。 不过这里有个注意点:deep 监听会有性能损耗,因为 Vue 要递归遍历整个对象的每一层属性,建立依赖追踪,如果你的对象非常大(比如有几百上千个属性,或者嵌套了十几层),deep 监听可能会影响页面的渲染速度,所以要慎用——能用其他更精准的监听方式就别用 deep。

技巧3:精准监听对象的某个属性或路径,避免不必要的 deep 监听

如果只需要监听对象的某个具体属性,或者某个具体的嵌套路径,那就别监听整个对象加 deep,直接精准监听就行,性能更好。 怎么精准监听?分两种情况:

情况3.1:监听 ref 包裹对象的某个属性

直接把属性的 getter 函数传给 watch:

import { ref, watch } from 'vue'
const user = ref({ 
  name: '张三', 
  profile: { 
    address: { city: '北京', district: '朝阳区' },
    hobbies: ['编程', '看电影']
  }
})
// 精准监听 name 属性
watch(
  () => user.value.name,
  (newVal, oldVal) => {
    console.log('name变了!', newVal, oldVal)
  }
)
// 精准监听 district 属性(嵌套路径)
watch(
  () => user.value.profile.address.district,
  (newVal, oldVal) => {
    console.log('district变了!', newVal, oldVal)
  }
)
// 精准监听 hobbies 数组(不需要加 deep 吗?如果是监听数组的 length 或者数组元素的替换,不需要;如果是监听数组元素的 push/pop/splice 等变异方法,或者数组元素是对象时的内部属性变化,需要加 deep)
watch(
  () => user.value.profile.hobbies,
  (newVal, oldVal) => {
    console.log('hobbies变了!', newVal, oldVal)
  },
  { deep: true } // 这里如果要监听push或者内部元素变化,必须加deep
)

情况3.2:监听 reactive 对象的某个属性

可以直接传属性的引用吗?不行,因为 reactive 对象的基本类型属性解构出来会失去响应式,所以也得用 getter 函数:

import { reactive, watch } from 'vue'
const user = reactive({ 
  name: '张三', 
  profile: { 
    address: { city: '北京', district: '朝阳区' },
    hobbies: ['编程', '看电影']
  }
})
// 精准监听 name 属性
watch(
  () => user.name,
  (newVal, oldVal) => {
    console.log('name变了!', newVal, oldVal)
  }
)
// 精准监听 district 属性,和ref一样
watch(
  () => user.profile.address.district,
  (newVal, oldVal) => {
    console.log('district变了!', newVal, oldVal)
  }
)

哦对了,还有一种方法:用 toRef 把 reactive 对象的某个属性转成 ref,然后直接监听这个 ref:

import { reactive, toRef, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
const ageRef = toRef(user, 'age') // 把user.age转成ref,保持响应式
// 直接监听ageRef,不需要getter函数
watch(ageRef, (newVal, oldVal) => {
  console.log('age变了!', newVal, oldVal)
})
setTimeout(() => {
  user.age = 21 // 不管是改user.age还是改ageRef.value,都会触发
  // ageRef.value = 21 也是一样的
}, 1000)

toRef 适合你需要在多个地方用到这个属性,或者需要传递这个属性给子组件并保持响应式的情况。

技巧4:用 toRefs 解构 reactive 对象,避免失去响应式

刚才说过,直接解构 reactive 对象的基本类型属性会失去响应式,那如果想既保持代码简洁,又保持响应式,就用 toRefs

import { reactive, toRefs, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
const { name: nameRef, age: ageRef } = toRefs(user) // 这里的nameRef和ageRef都是ref,保持响应式
// 直接监听ageRef
watch(ageRef, (newVal, oldVal) => {
  console.log('age变了!', newVal, oldVal)
})
// 模板里也可以直接用{{ nameRef }},不需要.value,Vue3的模板会自动解包ref

注意:toRefs 只会处理对象自身的可枚举属性,不会处理原型链上的属性,也不会处理嵌套对象的属性——嵌套对象的属性如果需要解构并保持响应式,得再用 toRef 或者 toRefs 处理。

技巧5:用 watchEffect 替代 watch,不需要手动指定依赖

如果你的监听逻辑比较复杂,依赖多个响应式数据,而且不需要对比旧值,那可以用 watchEffectwatchEffect自动追踪函数内部用到的所有响应式数据,一旦这些数据变化,就会重新执行函数,不需要加 deep 也不需要写 getter 函数。 举个例子:

import { reactive, watchEffect } from 'vue'
const user = reactive({ 
  name: '张三', 
  profile: { 
    address: { city: '北京', district: '朝阳区' },
    hobbies: ['编程', '看电影']
  }
})
// watchEffect 自动追踪所有用到的响应式数据
watchEffect(() => {
  console.log('用到的响应式数据变了!')
  console.log('name:', user.name)
  console.log('district:', user.profile.address.district)
  console.log('hobbies长度:', user.profile.hobbies.length)
  // 注意:这里如果访问了user.profile.hobbies[0],那hobbies数组的第一个元素变化也会触发;如果只访问length,那只有length变化才会触发(比如push/pop/splice改变长度,或者直接给length赋值)
})
setTimeout(() => {
  user.name = '李四' // 触发
}, 1000)
setTimeout(() => {
  user.profile.address.district = '海淀区' // 触发
}, 2000)
setTimeout(() => {
  user.profile.hobbies.push('游泳') // 触发,因为length变了
}, 3000)

watchEffect 有几个特点:

  1. 立即执行一次:不像 watch 只有在数据变化时才执行(除非加 { immediate: true }),watchEffect 会在组件初始化的时候立即执行一次,收集依赖。
  2. 不需要指定源:自动追踪内部依赖。
  3. 没有旧值:回调函数里只有新值的上下文,没有 oldVal 参数。
  4. 可以手动停止watchEffect 会返回一个停止函数,调用这个函数可以停止监听。

什么时候用 watchEffect?什么时候用 watch?这里也给你个判断标准:

  • watch:当你需要对比旧值、需要精准指定监听的源(避免不必要的执行)、或者需要延迟执行(不加 immediate)的时候。
  • watchEffect:当你不需要对比旧值、依赖的数据比较多且零散、或者需要立即执行的时候。

补充:如何拿到 watch 监听对象变化前的完整旧值?

刚才在讲 Object.assign 合并 reactive 对象的时候提到过:如果是合并到旧对象,或者加 deep 监听内部属性变化,watch 的回调函数里的 oldValnewVal同一个对象引用,因为 Vue 是直接修改原对象的,不会创建副本,那如果需要拿到变化前的完整旧值怎么办?

答案是:用 getter 函数配合深拷贝,举个例子:

import { ref, watch } from 'vue'
import { cloneDeep } from 'lodash-es' // 建议用成熟的深拷贝库,比如lodash-es,不要自己写深拷贝,容易有坑(比如循环引用、Date/RegExp等特殊类型的处理)
const user = ref({ name: '张三', age: 20 })
watch(
  () => cloneDeep(user.value), // 每次依赖变化时,先深拷贝一份当前值作为newVal的对比源?不对,看回调函数的参数
  (newVal, oldVal) => {
    console.log('newVal:', newVal) // 这次变化后的深拷贝
    console.log('oldVal:', oldVal) // 上次变化后的深拷贝,也就是这次变化前的旧值
  },
  { deep: true } // 这里的deep还是要加的,因为getter函数返回的是深拷贝后的普通对象,Vue需要追踪原user.value的依赖
)
setTimeout(() => {
  user.value.age = 21
}, 1000)

注意:深拷贝会有性能损耗,尤其是对象非常大的时候,所以也要慎用——只有在确实需要旧值的情况下才用。

总结一下

今天我们讲了 Vue3 watch 监听对象没反应的4个核心原因,以及5个实用的解决技巧,最后还补充了如何拿到完整旧值的方法,再给你梳理一遍核心要点:

  1. 不要给 reactive 变量重新赋值整个对象,要用 Object.assign 或展开运算符合并。
  2. 监听嵌套对象/数组时,必须加 { deep: true },但要注意性能损耗。
  3. 优先用精准监听(getter 函数或 toRef),避免不必要的 deep 监听。
  4. 解构 reactive 对象时,用 toRefs 保持响应式。
  5. 不需要对比旧值、依赖零散时,用 watchEffect 更方便。
  6. 需要拿到完整旧值时,用 getter 函数配合深拷贝。

掌握了这些,以后再遇到 Vue3 watch 监听对象的问题,应该就能很快解决了,如果还有其他问题,欢迎在评论区留言交流!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门