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

Vue3里watch嵌套对象属性的方法有哪些?踩坑点怎么避?

terry 59分钟前 阅读数 18 #Vue

刚上手Vue3的朋友,尤其是从Vue2转过来的,大概率会碰到这个问题:明明写了watch监听数据,改了深层的子属性怎么没反应?要么监听触发太频繁,要么完全没触发,调试半天找不到原因,今天咱们就从核心原理到实用方法,再到常见的5个避坑点,全给掰扯清楚。

先搞懂核心:Vue3的响应式为啥和嵌套属性“有隔阂”?

在讲方法之前,得先摸透响应式的底层——这能帮你从根源上理解为啥踩坑,以后再改嵌套数据也不会慌。

Vue2用的是Object.defineProperty(),它会递归遍历对象的所有层级,给每个属性都加上getter和setter,所以只要你改了子属性,Vue2能直接感知到,哪怕只是监听整个对象,默认也会触发。

但Vue3不一样,用的是Proxy+Reflect的组合,Proxy只代理传入的那个对象本身,不会自动递归代理深层的子对象或数组元素,举个简单的例子:你有个user对象,里面嵌套了address,Vue3只会给user套上Proxy,address还是普通对象,这时候你直接改user.address.city,只有city这个普通属性变了,Proxy感知不到,监听整个user的watch自然不会触发。

不过别担心,Vue3也不是完全不管嵌套属性,它有个懒代理的机制:当你第一次访问深层子属性的时候,Vue3才会给那个子属性也套上Proxy,比如先console.log(user.address),然后再改city,这时候Proxy就会捕获到变化了——但问题是,谁会没事先打印一遍子属性再监听呢?而且有些场景下你根本不会主动访问子属性,所以懒代理解决不了所有问题。

第一个实用方法:直接监听嵌套路径,简单粗暴但高效

如果你只需要监听嵌套对象里的某一个或几个明确的、路径清晰的子属性,直接写路径字符串是最方便的,没有多余的性能损耗。

举个具体的代码例子

<template>
  <div>
    <p>用户名:{{ user.name }}</p>
    <p>城市:{{ user.address.city }}</p>
    <p>邮编:{{ user.address.zip }}</p>
    <button @click="changeName">改名字</button>
    <button @click="changeCity">改城市</button>
    <button @click="changeZip">改邮编</button>
  </div>
</template>
<script setup>
import { ref, watch } from 'vue'
const user = ref({
  name: '张三',
  address: {
    city: '北京',
    zip: '100000'
  }
})
const changeName = () => user.value.name = '李四'
const changeCity = () => user.value.address.city = '上海'
const changeZip = () => user.value.address.zip = '200000'
// 直接监听嵌套路径的字符串,注意要加引号
watch(() => user.value.address.city, (newVal, oldVal) => {
  console.log('城市变了:', oldVal, '→', newVal)
})
// 也可以监听多个路径,放在数组里
watch(
  [() => user.value.name, () => user.value.address.zip],
  ([newName, newZip], [oldName, oldZip]) => {
    console.log('用户名或邮编变了')
    console.log('用户名:', oldName, '→', newName)
    console.log('邮编:', oldZip, '→', newZip)
  }
)
</script>

这里要注意,路径不能直接写user.value.address.city,必须用箭头函数返回路径值,或者如果你是用reactive定义的user,路径可以直接写成() => user.address.city,或者简化成'address.city'——对,reactive有个小优势,单路径监听的时候可以直接传字符串属性名或者嵌套路径字符串,不用箭头函数,但数组监听还是建议用箭头函数更稳妥,统一写法不容易出错。

第二个实用方法:开启deep选项,监听整个嵌套结构的变化

如果你的嵌套对象层级很深,或者子属性非常多,一个个写路径太麻烦,这时候可以用watch的deep: true选项,开启之后,不管你改嵌套对象里的哪个层级、哪个属性,watch都会触发。

开启deep的代码

<template>
  <div>
    <h3>个人资料</h3>
    <input v-model="user.name" placeholder="姓名">
    <input v-model="user.age" placeholder="年龄" type="number">
    <div>
      <h4>工作信息</h4>
      <input v-model="user.job.company" placeholder="公司">
      <input v-model="user.job.title" placeholder="职位">
      <div>
        <h5>同事列表</h5>
        <input v-for="(col, idx) in user.job.colleagues" :key="idx" v-model="user.job.colleagues[idx]">
        <button @click="addColleague">加同事</button>
      </div>
    </div>
    <p v-if="lastChange">最后修改的内容:{{ lastChange }}</p>
  </div>
</template>
<script setup>
import { reactive, watch } from 'vue'
const user = reactive({
  name: '王五',
  age: 28,
  job: {
    company: '字节跳动', '前端开发工程师',
    colleagues: ['赵六', '孙七']
  }
})
const lastChange = ref('')
const addColleague = () => user.job.colleagues.push('周八')
// 开启deep: true监听整个user对象
watch(user, (newUser) => {
  // 这里newUser和oldUser是同一个引用!!!等下避坑点会讲
  console.log('user的某个地方变了')
  // 这里我们可以用JSON.stringify比较一下,看看大概改了啥
  // 不过更精准的比较建议用lodash的isEqual,但不要滥用,会影响性能
  const lastModifiedKey = findLastModifiedKey(user, initialUser) // 这里的initialUser要自己存一份深拷贝的初始值,避坑点也会提
  lastChange.value = lastModifiedKey
}, { deep: true })
// 这里的findLastModifiedKey只是个示例函数,实际开发根据需求写
const initialUser = JSON.parse(JSON.stringify(user))
function findLastModifiedKey(newObj, oldObj, path = '') {
  for (const key in newObj) {
    const currentPath = path ? `${path}.${key}` : key
    if (typeof newObj[key] === 'object' && newObj[key] !== null) {
      const subPath = findLastModifiedKey(newObj[key], oldObj[key], currentPath)
      if (subPath) return subPath
    } else if (newObj[key] !== oldObj[key]) {
      return currentPath
    }
  }
  return null
}
</script>

这个方法覆盖范围最广,但缺点也很明显——性能开销大,因为开启deep之后,Vue3会递归遍历整个嵌套结构的Proxy,每次有变化都会重新触发监听回调,哪怕你只改了一个无关紧要的子属性,所以如果你的嵌套对象非常大(比如超过100个属性,或者层级超过5层),尽量不要随便开deep,还是优先用路径监听。

第三个实用方法:拆分小的响应式对象,细粒度控制监听

这个方法其实是结合了前两个的优点,既避免了一个个写长路径的麻烦,又不会像deep那样有太大的性能损耗。

拆分的思路

把原来的大嵌套对象,拆成几个独立的、逻辑上紧密相关的小响应式对象,然后分别监听,比如刚才的个人资料,可以拆成userBase(姓名、年龄)、userJob(公司、职位)、userColleagues(同事数组)三个reactive对象。

拆分后的代码示例

<template>
  <div>
    <h3>个人资料</h3>
    <input v-model="userBase.name" placeholder="姓名">
    <input v-model="userBase.age" placeholder="年龄" type="number">
    <div>
      <h4>工作信息</h4>
      <input v-model="userJob.company" placeholder="公司">
      <input v-model="userJob.title" placeholder="职位">
      <div>
        <h5>同事列表</h5>
        <input v-for="(col, idx) in userColleagues" :key="idx" v-model="userColleagues[idx]">
        <button @click="addColleague">加同事</button>
      </div>
    </div>
  </div>
</template>
<script setup>
import { reactive, watch } from 'vue'
// 拆分后的小响应式对象
const userBase = reactive({ name: '王五', age: 28 })
const userJob = reactive({ company: '字节跳动', title: '前端开发工程师' })
const userColleagues = reactive(['赵六', '孙七'])
const addColleague = () => userColleagues.push('周八')
// 只监听userJob的变化,不用开deep
watch(userJob, (newJob) => {
  console.log('工作信息变了:', newJob.company, newJob.title)
})
// 监听数组的变化,默认只能监听到数组的方法(push、pop、shift、unshift、splice、sort、reverse)和长度变化,
// 如果要监听数组元素的直接修改(比如userColleagues[0] = '吴九'),可以开deep,但数组元素如果是基本类型的话,开deep也不会有太大性能问题
watch(userColleagues, (newCols) => {
  console.log('同事列表变了:', newCols)
}, { deep: true })
</script>

这个拆分的思路非常推荐,尤其是在做大型项目的时候,既能提高代码的可读性和可维护性,又能精准控制监听的范围,避免不必要的性能浪费。

第四个补充方法:用watchEffect自动追踪依赖

watchEffect和watch不一样,它不需要你手动指定监听的目标,只要你在回调函数里用到了某个响应式数据(包括嵌套属性),它就会自动追踪,一旦这些数据变化就会触发回调。

watchEffect的适用场景

比如你需要根据多个嵌套属性做一些复杂的计算或者DOM操作,但不想一个个写路径,也不想开deep怕影响性能——这时候watchEffect就派上用场了。

watchEffect的代码示例

<template>
  <div>
    <input v-model="product.price" placeholder="价格" type="number">
    <input v-model="product.discount" placeholder="折扣(0-1)" type="number">
    <input v-model="product.shipping.fee" placeholder="运费" type="number">
    <p>最终价格:{{ finalPrice }}</p>
  </div>
</template>
<script setup>
import { reactive, ref, watchEffect } from 'vue'
const product = reactive({
  price: 100,
  discount: 0.8,
  shipping: {
    fee: 10
  }
})
const finalPrice = ref(0)
// watchEffect自动追踪product.price、product.discount、product.shipping.fee这三个属性
watchEffect(() => {
  finalPrice.value = product.price * product.discount + product.shipping.fee
  console.log('自动计算最终价格:', finalPrice.value)
})
</script>

watchEffect的优点是自动追踪,代码简洁;缺点是没有oldValue,而且初始化的时候会自动执行一次(当然你可以用flush选项或者配置来改变执行时机,但默认是立即执行的),所以如果你的场景需要用到oldValue,或者不想初始化的时候就触发,还是用watch更合适。

最容易踩的5个坑,90%的人都中过

刚才讲方法的时候也提到了一些避坑点,现在我们集中整理一下,免得以后再踩。

坑1:直接监听ref定义的嵌套对象的.value

虽然有时候直接监听user.value(ref定义的)也能触发,但这其实是依赖于懒代理的——如果你没有先访问过深层子属性,直接修改子属性就不会触发,正确的做法是要么用箭头函数返回整个ref对象的value(reactive可以直接传对象),要么开启deep,要么用路径监听。

坑2:开启deep后,oldValue和newValue是同一个引用

这个是从Vue2就有的问题,Vue3也没改,因为Proxy返回的是同一个对象的引用,所以不管你开不开deep,只要监听的是整个对象,oldValue和newValue都是一样的,如果你需要比较修改前后的差异,必须自己提前存一份深拷贝的初始值,或者在回调函数里对newValue做深拷贝,然后和上次存的深拷贝值比较。

深拷贝的话,简单场景可以用JSON.parse(JSON.stringify()),但这个方法有局限性:不能处理函数、Symbol、循环引用、Date对象(会变成字符串)、RegExp对象(会变成空对象)等,如果是复杂场景,建议用lodash的cloneDeep,或者自己写一个深拷贝函数。

坑3:监听数组元素的直接修改没反应

默认情况下,Vue3的watch只能监听到数组的变异方法(push、pop、shift、unshift、splice、sort、reverse)和长度变化,如果直接修改数组的某个元素(比如arr[0] = 123),或者修改数组的某个索引(比如arr.length = 5,不过这个长度变化默认是可以监听到的),默认不会触发。

解决方法有三个:

  1. 用变异方法代替直接修改,比如arr.splice(0, 1, 123)代替arr[0] = 123
  2. 开启deep选项;
  3. 直接监听数组的某个索引路径(比如() => arr[0])。

坑4:路径监听的时候写错了属性名或者路径

这个是低级错误,但很多人都会犯,比如把user.address.city写成了user.addr.city,或者少写了一层路径,因为Vue3的路径监听如果找不到属性,会返回undefined,这时候回调函数只会在初始化的时候触发一次(如果配置了immediate: true的话),以后都不会触发,所以写路径的时候一定要仔细检查,或者用TypeScript的类型提示来避免这个问题。

坑5:滥用deep选项导致性能问题

刚才讲过,开启deep之后,Vue3会递归遍历整个嵌套结构的Proxy,每次有变化都会重新触发监听回调,如果你的嵌套对象非常大,比如后台返回的一个有1000个数据项的数组,每个数据项又有10个属性,这时候开deep会导致页面卡顿,甚至崩溃。

所以一定要记住:优先用路径监听,其次用拆分小响应式对象的方法,最后迫不得已再开deep,开deep的时候,尽量把监听的范围缩小到最小的嵌套结构,不要监听整个大对象。

选哪个方法?

根据你的具体场景来选:

  1. 只监听1-3个明确的、路径清晰的子属性:直接写路径字符串(箭头函数或者reactive的简化路径);
  2. 嵌套对象层级不深、属性不多、需要监听所有变化:开启deep选项,但要注意oldValue的问题;
  3. 大型项目、嵌套对象逻辑清晰可拆分:拆分小的响应式对象,分别监听;
  4. 需要根据多个嵌套属性做自动计算或操作、不需要oldValue:用watchEffect。

好了,今天关于Vue3 watch嵌套属性的内容就讲完了,如果还有什么疑问,可以在评论区留言,我们一起讨论。

版权声明

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

热门