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

Vue3中怎么watch reactive对象的属性?常见坑和高效写法全解析

terry 2小时前 阅读数 31 #Vue

Vue3用了快两年,不管是做ToC的轻量H5还是ToB的复杂后台管理系统,监听数据变化都是绕不开的核心操作——毕竟很多业务逻辑都是“等某个数据变了再做某事”,最近后台收到不少新手甚至转过来的Vue2老用户的私信,说在watch reactive对象的属性时踩了各种坑:要么怎么都监听不到,要么监听了又触发太频繁,要么不知道该监听哪个层级的属性才对。

今天就把这些常见问题整理成一个完整的问答清单,从基础用法到高阶技巧,再到官方文档里没写得太细但实际开发超有用的细节,全给你讲明白,看完这篇,你不仅能解决当前遇到的监听问题,还能养成规范监听的习惯,避免以后踩更多性能和逻辑上的雷。


先别急着写代码!搞懂Vue3中watch监听reactive的底层逻辑很重要

很多新手上来就看代码示例复制粘贴,结果改个场景就没用了——根本原因是没搞懂Vue3的响应式原理和watch监听的对象机制。

为什么Vue2能直接watch data里的普通对象,Vue3的reactive要注意?

首先回忆一下Vue2的响应式:Vue2用的是Object.defineProperty,会递归遍历data里的所有对象属性,给每个属性都加上getter和setter,不管你是直接改对象的属性,还是给对象新增属性(虽然新增的得用Vue.set/$set才能触发响应式,但监听本身如果一开始就在data里定义了空属性的话没问题),只要触发了对应属性的setter,watch就能感知到。

但Vue3不一样,Vue3用的是Proxy代理整个对象,不是逐个代理属性,Proxy会拦截对这个代理对象的任何操作——包括读取、修改、删除属性,甚至是遍历、修改原型链等等,不过这里有个关键点:当你修改reactive对象里的基础类型属性(比如number、string、boolean)时,本质上是修改代理对象的属性,触发的是代理的setter;但当你修改嵌套对象或数组的属性时,比如reactiveUser.info.age = 28,这里的info也是一个Proxy(Vue3会自动把reactive对象里的嵌套对象/数组也变成响应式的),修改age触发的是info这个子Proxy的setter,不是reactiveUser的。

这就直接导致了watch reactive对象和watch ref的第一个核心区别——后面会详细讲。


Vue3 watch reactive属性的基础3种写法,各有什么适用场景?

先给你最常用的3种基础写法,根据你要监听的属性层级、业务需求选就行。

直接传reactive对象的属性访问器函数

这是最推荐、最精确的监听方式,不管你监听的是基础类型属性,还是嵌套几层的对象/数组属性/数组索引,只要写对函数,都能精准监听。

举个例子:假设我们有一个登录表单的reactive对象,里面有username、password,还有一个rememberMe的checkbox选中状态,另外可能还有一个嵌套的profile对象,存了用户的头像、昵称、手机号这些扩展信息。

import { reactive, watch } from 'vue'
const loginForm = reactive({
  username: '',
  password: '',
  rememberMe: false,
  profile: {
    avatar: '',
    nickname: '',
    phone: ''
  }
})
// 监听基础类型属性username
watch(
  () => loginForm.username,
  (newVal, oldVal) => {
    console.log('用户名变了:', oldVal, '→', newVal)
    // 这里可以做实时校验、输入防抖提示等
  }
)
// 监听嵌套基础类型属性profile.nickname
watch(
  () => loginForm.profile.nickname,
  (newVal, oldVal) => {
    console.log('昵称变了:', oldVal, '→', newVal)
  }
)

这种写法的适用场景非常广:只要你有明确的、单个或少量要监听的属性,不管层级多深,都用它,而且它的性能很好,因为Vue只会追踪你函数里用到的响应式数据,不会监听整个对象的其他无关变化。

直接传整个reactive对象

这种写法能监听整个reactive对象的所有属性变化——不管是改顶层属性、嵌套属性、还是新增/删除属性,甚至是修改数组的元素、长度、push/splice/pop等操作,都会触发watch回调。

但注意!这里有两个新手必踩的小坑: 第一个坑是,直接传整个reactive对象的话,watch的oldVal和newVal是同一个对象!因为Proxy代理的是同一个内存地址,修改对象属性不会改变对象本身的引用,所以newVal === oldVal永远是true,这时候你拿不到修改前的旧值了。 第二个坑是性能问题——如果你只需要监听其中一两个属性,监听整个对象会导致很多不必要的回调触发,比如修改rememberMe时,你本来只是为了监听username做校验,结果也会触发回调,浪费资源。

所以直接传整个reactive对象的适用场景非常少:只有当你需要监听这个对象的所有操作,并且完全不需要旧值的对比逻辑时,才用它,比如做一个表单的实时草稿保存功能——不管用户改了表单的哪个字段,都要把整个表单存到localStorage里。

// 监听整个loginForm对象
watch(
  loginForm,
  (newVal, oldVal) => {
    console.log('整个表单有变化了')
    console.log(newVal === oldVal) // 永远输出true!
    localStorage.setItem('loginFormDraft', JSON.stringify(newVal))
  }
)

直接传整个reactive对象+deep配置

有些新手可能会问:“写法二监听不到嵌套属性的旧值,那加上deep配置会不会有用?” 答案是:deep配置只能让Vue深入遍历对象的所有嵌套属性,强制触发回调,但仍然解决不了oldVal和newVal是同一个引用的问题。 那deep配置什么时候用呢?刚才写法二里说的“监听整个对象的所有操作”,其实已经包含了嵌套属性的操作,因为Proxy的拦截本身就是深层的——不过有一个例外:如果你用的是shallowReactive(浅层响应式对象),直接传整个对象+不加deep,只能监听到顶层属性的变化,嵌套属性修改了不会触发,这时候才需要加deep。

举个shallowReactive的例子:

import { shallowReactive, watch } from 'vue'
const shallowLoginForm = shallowReactive({
  username: '',
  profile: {
    nickname: ''
  }
})
// 不加deep的话,修改nickname不会触发回调
watch(
  shallowLoginForm,
  () => {
    console.log('表单变化了')
  }
)
// 加上deep之后,修改nickname就能触发了,但oldVal和newVal还是同一个
watch(
  shallowLoginForm,
  () => {
    console.log('deep监听:表单变化了')
  },
  { deep: true }
)
// 如果你监听的是普通reactive的嵌套对象本身,比如要监听整个profile对象的替换或内部变化
// 这时候也可以用写法一+deep
watch(
  () => loginForm.profile,
  (newVal, oldVal) => {
    console.log('profile对象变化了')
    // 这里newVal和oldVal只有当profile被整个替换时才不一样,内部属性变化还是同一个引用
  },
  { deep: true }
)

所以写法三的适用场景主要是两个:

  1. 监听shallowReactive对象的嵌套属性变化;
  2. 用写法一监听某个嵌套的对象/数组本身(不是单个属性),并且需要监听它的内部变化,而不仅仅是替换整个对象/数组。

新手高频踩坑top5,看看你中过几个?

刚才讲底层逻辑和基础写法的时候已经提到了一些坑,现在整理出实际开发中新手最容易遇到的5个,帮你避避坑。

坑1:监听基础类型属性时,直接传属性值而不是访问器函数

这是新手最多犯的错误!比如刚才的loginForm.username,有些新手会直接写:

// ❌ 错误写法!永远不会触发回调
watch(
  loginForm.username,
  () => {
    console.log('用户名变了?')
  }
)

为什么?因为当你直接传loginForm.username的时候,传的是当前username的基础类型值(比如空字符串''),而不是响应式引用,基础类型值是没有getter/setter的,Vue根本没法追踪它的变化,所以回调永远不会触发。 只有当你监听的是ref的.value的时候,才可以直接传ref本身——因为ref是一个带.value属性的响应式对象,Vue会自动帮你解包追踪.value的变化,但reactive的属性不是ref,所以必须用访问器函数。

坑2:直接修改reactive对象的整个引用,导致watch失效

比如有些新手会在提交表单失败后,重置表单的时候写:

import { reactive, watch } from 'vue'
let loginForm = reactive({
  username: '',
  password: ''
})
// 监听username
watch(
  () => loginForm.username,
  () => console.log('用户名变了')
)
// ❌ 错误的重置方式!
loginForm = {
  username: '重置了',
  password: '重置了'
}

为什么失效?因为原来的loginForm是一个Proxy对象,你现在把它赋值成了一个普通的JavaScript对象,失去了响应式,而且watch之前绑定的是旧的Proxy对象,新的普通对象没有任何监听。 正确的重置方式有两种:

  1. 用Object.assign把新值赋给旧的Proxy对象:
    // ✅ 正确重置方式1
    Object.assign(loginForm, {
    username: '',
    password: ''
    })
  2. 把reactive对象的属性都放在一个嵌套的state里,这样重置state的属性就行,不用改loginForm本身的引用:
    // ✅ 正确重置方式2(推荐,更规范)
    const loginForm = reactive({
    state: {
     username: '',
     password: ''
    }
    })

// 重置 loginForm.state = { username: '', password: '' }

哦对了,刚才提的“如果监听的是嵌套的state对象本身,加上deep能监听内部变化,不加deep只能监听state被整个替换”,这种重置方式就可以用不加deep的写法来监听整个表单的重置操作。
### 坑3:监听数组的索引或length时,以为写法一不行,非要用deep
其实写法一完全可以监听数组的索引和length,
```javascript
import { reactive, watch } from 'vue'
const todoList = reactive([
  { id: 1, text: '学Vue3', done: false },
  { id: 2, text: '写文章', done: true }
])
// 监听数组的length变化(比如push/pop/unshift/shift/splice时length会变)
watch(
  () => todoList.length,
  (newVal, oldVal) => {
    console.log('待办事项数量变了:', oldVal, '→', newVal)
  }
)
// 监听数组某个索引下的done属性
watch(
  () => todoList[0].done,
  (newVal) => {
    console.log('第一个待办事项的完成状态变成了:', newVal)
  }
)

这种写法比直接监听整个数组+deep性能好太多,因为你只监听了你关心的索引或属性。

坑4:用watchEffect替代watch,导致不需要的回调触发

有些新手觉得watchEffect比watch好用,因为不用写访问器函数,不用指定监听的属性——但实际上watch和watchEffect的适用场景完全不同。

watchEffect是立即执行一次,然后自动追踪函数里用到的所有响应式数据,只要有一个变化就触发回调;而watch是默认不立即执行(除非加immediate: true),只监听你指定的一个或多个数据源,只有这些数据源变化了才触发。

举个例子:假设你有一个todoList,你需要在todoList的done属性变化时,统计已完成的数量,如果你用watchEffect:

import { reactive, watchEffect, computed } from 'vue'
const todoList = reactive([
  { id: 1, text: '学Vue3', done: false },
  { id: 2, text: '写文章', done: true }
])
// ❌ 不太好的写法(但也能用)
watchEffect(() => {
  const doneCount = todoList.filter(todo => todo.done).length
  console.log('已完成的待办事项数量:', doneCount)
})

这里watchEffect会立即执行一次,然后只要todoList的任何一个属性变化——比如新增了一个待办、修改了某个待办的text、修改了某个待办的done——都会触发回调,但如果你只需要在doneCount变化时触发,其实用computed会更好(哦对了,computed也是监听内部用到的响应式数据,不过它是返回一个值,适合做数据转换,不适合做副作用),或者用watch监听整个todoList的filter后的结果?不,filter后的结果是新数组,每次变化都是新引用,其实用watchEffect或者computed+watch都可以,但如果你明确知道只有done变化时才需要统计,其实可以用另一种方式——不过这里的核心问题是,不要滥用watchEffect,否则会导致很多不必要的副作用触发,影响性能。

坑5:在watch回调里修改正在监听的属性,导致无限循环

这个坑不管是Vue2还是Vue3都有,但新手还是容易犯,比如你想让username的长度不超过10个字符,有些新手会写:

// ❌ 错误写法!会无限循环
watch(
  () => loginForm.username,
  (newVal) => {
    if (newVal.length > 10) {
      loginForm.username = newVal.slice(0, 10)
    }
  }
)

为什么?因为当你修改loginForm.username超过10个字符时,触发watch回调,然后你在回调里又修改了loginForm.username,又触发watch回调,无限循环下去,直到浏览器卡死。 正确的写法有两种:

  1. 在修改属性前,先判断新值和原来的修改后的值是否一样,如果一样就不修改:
    // ✅ 正确写法1
    watch(
    () => loginForm.username,
    (newVal) => {
     const trimmedVal = newVal.slice(0, 10)
     if (trimmedVal !== newVal) {
       loginForm.username = trimmedVal
     }
    }
    )
  2. 用input事件的处理函数直接限制,或者用computed返回限制后的username,然后绑定到输入框的v-model上:
    // ✅ 正确写法2(推荐用computed+v-model)
    import { reactive, computed } from 'vue'

const loginForm = reactive({ rawUsername: '', password: '' })

const username = computed({ get() { return loginForm.rawUsername.slice(0, 10) }, set(val) { loginForm.rawUsername = val } })

然后在模板里用`<input v-model="username" />`,这样输入超过10个字符时,会自动截断,而且不会触发无限循环。
---
## Vue3 watch reactive属性的高阶技巧,提升开发效率和性能
刚才讲了基础写法和避坑,现在讲几个实际开发中非常有用的高阶技巧,官方文档里可能有提,但没讲得这么细,也没结合具体的业务场景。
### 技巧一:监听多个reactive属性,返回不同的newVal和oldVal
有时候你需要同时监听多个reactive属性,比如登录表单的username和password,只要其中一个变了,就更新“是否可以提交表单”的状态,这时候可以把多个访问器函数放在一个数组里传给watch,回调函数的第一个参数是所有新值组成的数组,第二个参数是所有旧值组成的数组。
```javascript
import { reactive, watch, ref } from 'vue'
const loginForm = reactive({
  username: '',
  password: ''
})
const canSubmit = ref(false)
// 同时监听username和password
watch(
  [() => loginForm.username, () => loginForm.password],
  ([newUsername, newPassword], [oldUsername, oldPassword]) => {
    console.log('用户名从', oldUsername, '变到', newUsername)
    console.log('密码从', oldPassword, '变到', newPassword)
    // 只要用户名和密码都不为空,就可以提交
    canSubmit.value = newUsername.trim() !== '' && newPassword.trim() !== ''
  }
)

用flush配置调整watch回调的触发时机

默认情况下,Vue3的watch回调是在DOM更新前触发的(也就是flush: 'pre')——这和Vue2是一样的,但有时候你需要在DOM更新后触发回调,比如你修改了某个数据后,需要获取更新后的DOM元素的尺寸或位置,这时候就需要把flush配置成'post'。

还有一种情况是,你需要在数据变化后同步触发回调,也就是不管什么时机,只要数据变了就立刻执行,这时候可以把flush配置成'sync'——不过这个配置要慎用,因为它可能会破坏Vue的响应式更新队列,导致性能问题,甚至出现一些奇怪的bug。

举个flush: 'post'的例子:

import { reactive, watch, ref, nextTick } from 'vue'
const loginForm = reactive({
  showPassword: false, // 控制密码是否可见
  password: ''
})
const passwordInput = ref(null)
// 监听showPassword的变化
watch(
  () => loginForm.showPassword,
  (newVal) => {
    // ❌ 默认flush: 'pre',这时候DOM还没更新,passwordInput.value可能是undefined
    // passwordInput.value.focus()
    // ✅ 方法一:用nextTick包裹
    // nextTick(() => {
    //   passwordInput.value.focus()
    // })
    // ✅ 方法二:把flush配置成'post',这样回调会在DOM更新后自动触发
    passwordInput.value.focus()
  },
  { flush: 'post' }
)

这里方法二比方法一更简洁,对吧?

用watch的返回值停止监听

有时候你需要在某个条件满足后,停止对某个属性的监听,避免不必要的回调触发,比如你有一个倒计时的功能,倒计时结束后,就不需要再监听倒计时的变化了,这时候可以用watch的返回值——一个停止函数,调用它就能停止监听。

import { reactive, watch, onMounted, onUnmounted } from 'vue'
const state = reactive({
  countdown: 10,
  countdownEnded: false
})
let stopWatchCountdown
let countdownTimer
onMounted(() => {
  // 启动倒计时
  countdownTimer = setInterval(() => {
    if (state.countdown > 0) {
      state.countdown--
    } else {
      state.countdownEnded = true
      clearInterval(countdownTimer)
    }
  }, 1000)
  // 监听倒计时,每秒更新UI提示
  stopWatchCountdown = watch(
    () => state.countdown,
    (newVal) => {
      console.log(`还剩${newVal}秒`)
      // 如果倒计时结束,停止监听
      if (newVal === 0) {
        stopWatchCountdown()
        console.log('倒计时监听已停止')
      }
    }
  )
})
// 组件卸载时,记得清理定时器和停止监听,避免内存泄漏
onUnmounted(() => {
  clearInterval(countdownTimer)
  // 防止组件卸载前倒计时已经结束,stopWatchCountdown已经被调用过,这里加个判断
  if (stopWatchCountdown) {
    stopWatchCountdown()
  }
})

用immediate配置让watch回调立即执行一次

默认情况下,watch回调是只有在监听的数据源变化时才触发的,但有时候你需要在组件挂载后,立刻执行一次回调,获取初始值的信息,比如刚才的登录表单的canSubmit状态,你希望组件一挂载就判断初始的username和password是否为空,这时候就可以加immediate: true配置。

watch(
  [() => loginForm.username, () => loginForm.password],
  ([newUsername, newPassword]) => {
    canSubmit.value = newUsername.trim() !== '' && newPassword.trim() !== ''
  },
  { immediate: true } // 组件挂载后立刻执行一次回调
)

Vue3 watch reactive属性的最佳实践

给你整理一下Vue3 watch reactive属性的最佳实践,帮你快速做出正确的选择:

  1. 优先使用访问器函数写法:不管监听的是单个基础类型属性,还是嵌套几层的属性/数组索引,只要你有明确的、单个或少量要监听的数据源,就用访问器函数,性能最好,最精确,也不会出现oldVal和newVal是同一个引用的问题(除非你监听的是嵌套对象/数组本身)。
  2. 尽量避免直接传整个reactive对象:只有当你需要监听整个对象的所有操作,并且完全不需要旧值的对比逻辑时,才用它。
  3. deep配置要慎用:只有监听shallowReactive的嵌套属性,或者用访问器函数监听某个嵌套对象/数组本身并且需要监听内部变化时,才加deep。
  4. 不要滥用watchEffect:watchEffect适合自动追踪多个响应式数据的副作用,但如果你有明确的、单个或少量要监听的数据源,或者需要旧值的对比逻辑,或者不需要立即执行回调,就用watch。
  5. 在watch回调里修改正在监听的属性时,一定要加判断:避免无限循环,或者直接用computed+input事件处理函数来替代。
  6. 组件卸载时,记得清理定时器和停止监听:避免内存泄漏。

好了,这篇文章就讲到这里,相信你看完之后,对Vue3 watch reactive对象的属性已经有了非常全面的了解,如果还有其他问题,欢迎在评论区留言讨论。

版权声明

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

热门