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

Vue3中监听v-model绑定值变化,watch到底怎么写才靠谱?

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

咱们平时写Vue3项目,不管是表单输入、组件嵌套还是状态联动,v-model肯定是天天碰的玩意儿,但一旦要监听它的变化,很多人要么就是把旧写法Vue2那套直接搬过来报错,要么就是能跑但不知道原理踩坑(比如深层对象/数组只变属性没触发、初始值要不要处理、嵌套组件v-model双向绑不对传参监听),今天就把这事儿掰碎了说,从基础写法到进阶避坑再到最佳实践,一步一步来。

为什么Vue2的watch写法搬Vue3会出问题?

先回忆下Vue2的操作:要监听data里的model值,直接watch: { userName(newVal, oldVal) { // do something } },深层对象加个deep: true,初始加载触发加immediate: true,那为啥到Vue3里,用setup的watch或者watchEffect照搬data里的变量就报错?甚至把ref/reactive的东西直接传进去也不生效?

根本原因是Vue3的响应式系统彻底换了底层逻辑:Vue2是Object.defineProperty,直接拦截data对象的属性;Vue3是Proxy,整个ref的.value包裹、reactive的代理对象才是真正的响应式载体,举个最常见的反例:如果你在setup里写const userName = '张三'; watch(userName, () => { alert('变了') }),那这个userName是个普通字符串,Proxy根本没包裹它,watch自然抓不到变化。

不同场景下,watch监听v-model值的正确打开方式

既然底层逻辑变了,咱们得先理清楚v-model在Vue3里到底绑定的是什么——绑定的一定是ref的.value、reactive的属性、或者经过computed处理的响应式值,那分三种高频场景说:

场景1:监听单个ref绑定的原生表单v-model

原生表单是最基础的,比如input、select、textarea,v-model默认绑定的就是ref.value,这时候的写法最简单,举个例子:

import { ref, watch } from 'vue'
export default {
  setup() {
    const userPhone = ref('')
    // 写法1:直接传ref变量本身,别带.value!
    watch(userPhone, (newVal, oldVal) => {
      console.log('手机号从', oldVal, '变成了', newVal)
      // 这里可以做手机号校验、防抖请求接口啥的
    })
    return { userPhone }
  }
}
<template>
  <input type="tel" v-model="userPhone" placeholder="请输入手机号" />
</template>

这里一定要注意:watch的第一个参数如果是ref,直接写变量名,不能写userPhone.value!因为userPhone.value是个普通值(或者是深层对象的话也是普通引用路径的最后一步),传进去的那一刻就固定了,后续Proxy的变化根本传不进去watch的回调。

那如果我非要带.value行不行?也不是不行,但得包个箭头函数当 getter:watch(() => userPhone.value, ...),不过这种写法对单个ref来说完全没必要,直接传变量更简洁,性能也没差多少(getter函数的开销很小,但单个ref是内部优化过的监听路径)。

场景2:监听reactive对象/数组绑定的原生/自定义表单v-model

自定义表单或者复杂表单组,通常会用reactive包裹整个对象或者数组,这时候v-model要么绑定formData.phone这种reactive的属性,要么绑定formData.addresses[0].city这种深层嵌套的属性,写法得分两种情况:

情况A:只监听reactive对象的单个属性

不管属性深不深,都必须用箭头函数getter的写法,和单个ref带.value的包法一样,但原理不同——因为reactive对象本身的属性访问是经过Proxy拦截的,但单个属性如果是基本类型(string/number/boolean等),单独取出来就是普通值;如果是引用类型(子对象/子数组),单独取出来如果没被单独Proxy处理(reactive深层属性会自动被嵌套代理,这个是优化点),其实也能监听,但用getter更稳妥,是官方推荐的写法。

比如监听单个基本类型属性:

import { reactive, watch } from 'vue'
export default {
  setup() {
    const formData = reactive({
      userName: '',
      userAge: 0,
      addresses: [{ city: '北京', district: '朝阳区' }]
    })
    // 监听单个基本类型
    watch(() => formData.userName, (newVal) => {
      console.log('用户名变了:', newVal)
    })
    // 监听单个深层引用类型的属性
    watch(() => formData.addresses[0].district, (newVal) => {
      console.log('朝阳区还是海淀区?变了:', newVal)
    })
    return { formData }
  }
}
<template>
  <div>
    <input v-model="formData.userName" placeholder="用户名" />
    <input type="number" v-model.number="formData.userAge" placeholder="年龄" />
    <div v-for="(addr, index) in formData.addresses" :key="index">
      <input v-model="addr.city" placeholder="城市" />
      <input v-model="addr.district" placeholder="区县" />
    </div>
  </div>
</template>

这里如果不包getter,直接写formData.userName,肯定不生效;直接写formData,则会监听整个对象的任何属性变化,包括子对象的子属性,开销会很大,除非你真的需要监听整个表单的所有变动。

情况B:监听整个reactive对象/数组的所有变动

这时候可以直接传reactive对象本身,但一定要加deep: true吗?等下,官方文档里说过:如果直接传reactive对象给watch,默认就是开启deep: true的!不信你试试把deep: true去掉,修改子对象的子属性,回调照样触发,这是Vue3对reactive的特殊优化,因为Proxy本身就能拦截深层属性的访问和修改,所以不需要手动加deep,但开销还是存在的——如果对象非常大(比如几百上千条数据的数组),每次有属性变动都触发回调,可能会影响页面性能。

什么时候才需要手动加deep?如果你的getter返回的是reactive对象的子对象/子数组,那这时候默认是不开启deep的,

// 监听地址数组的变化,只监听数组长度、元素增删,不监听元素内部的属性
watch(() => formData.addresses, (newAddrs, oldAddrs) => {
  console.log('地址数组的结构变了')
})
// 监听地址数组的**任何**变化,包括元素内部的属性
watch(() => formData.addresses, (newAddrs, oldAddrs) => {
  console.log('地址数组有变动,不管是结构还是内部属性')
}, { deep: true })

这个区别很重要,很多新手分不清,导致要么监听不到想要的,要么监听的太泛浪费性能。

场景3:监听嵌套组件双向绑定的v-model值

Vue3里嵌套组件的v-model写法也变了——不再是默认的value prop和input event,而是默认的modelValue prop和update:modelValue event,而且支持同时绑定多个v-model(比如<Child v-model:name="userName" v-model:age="userAge" />),那在父组件里监听这些绑定值,和前面两种场景完全一样,因为绑定的还是父组件的ref/reactive响应式值;但如果在子组件内部监听父组件传过来的modelValue呢?

在子组件内部监听的话,首先要注意:prop是只读的,不能直接修改,必须通过emit update:modelValue来同步父组件的值,监听prop的话,prop本身如果是父组件传的ref.value或者reactive的属性,直接传prop给watch(或者用getter包)是可以的,因为子组件接收的prop如果是响应式的,Vue会自动保持响应式(前提是父组件没有用.prop或者.attr这种非响应式的绑定修饰符)。

举个例子,写一个子组件NumberInput,父组件传一个modelValue(number类型),子组件监听它的变化,并且自己维护一个本地的输入框字符串ref(避免输入非数字字符的时候直接触发父组件校验报错):

// Child.vue 子组件
import { ref, watch } from 'vue'
export default {
  props: {
    modelValue: {
      type: Number,
      default: 0
    }
  },
  emits: ['update:modelValue'],
  setup(props, { emit }) {
    // 本地输入框ref,存字符串,方便过滤非数字
    const localInput = ref(String(props.modelValue))
    // 监听父组件传过来的modelValue变化,同步到本地输入框
    watch(props, (newProps) => {
      localInput.value = String(newProps.modelValue)
    })
    // 监听本地输入框的变化,过滤非数字,再同步给父组件
    watch(localInput, (newVal) => {
      const num = Number(newVal.replace(/[^\d]/g, ''))
      emit('update:modelValue', isNaN(num) ? 0 : num)
    })
    return { localInput }
  }
}
<template>
  <input type="text" v-model="localInput" placeholder="请输入数字" />
</template>
// Parent.vue 父组件
import { ref, watch } from 'vue'
import Child from './Child.vue'
export default {
  components: { Child },
  setup() {
    const userScore = ref(0)
    // 父组件监听嵌套组件的v-model:modelValue值
    watch(userScore, (newScore) => {
      console.log('用户分数变了:', newScore)
    })
    return { userScore }
  }
}
<template>
  <div>
    <h3>当前分数:{{ userScore }}</h3>
    <Child v-model="userScore" />
  </div>
</template>

这里子组件里直接传props给watch是可以的,会监听所有prop的变化;如果只想监听modelValue,就用watch(() => props.modelValue, ...),更精准。

进阶避坑:那些容易踩的watch监听v-model的坑

刚才说了基础写法,现在说几个开发中90%的人都会踩的坑:

坑1:深层对象/数组的newVal和oldVal一样

这个坑不管是Vue2还是Vue3都有,但Vue3里更常见,因为直接传reactive对象默认开启deep,为什么会一样?因为Proxy返回的是同一个代理对象的引用,不管你怎么修改内部属性,代理对象本身的引用地址是不变的,所以watch拿到的newVal和oldVal都是同一个东西。

怎么解决?如果需要对比新旧值的差异,有两种方法:

  1. 浅拷贝/深拷贝旧值:用getter返回的时候,顺便拷贝一份,比如watch(() => JSON.parse(JSON.stringify(formData)), (newVal, oldVal) => { ... }),但JSON.parse(JSON.stringify())有局限性(不能处理函数、Symbol、Date等特殊类型);
  2. 用VueUse的useWatch或者watchDeep配合computed缓存旧值:VueUse是一个非常流行的Vue3工具库,里面的useWatch可以帮你自动处理新旧值的深拷贝对比,或者你可以自己写一个computed缓存旧值:
    import { reactive, watch, computed } from 'vue'
    export default {
    setup() {
     const formData = reactive({ userName: '张三', age: 20 })
     // 缓存旧值的computed,computed是惰性的,只有当formData.userName变了才会重新计算
     const oldUserName = computed(() => formData.userName)
     let prevOldUserName = oldUserName.value
     watch(() => formData.userName, (newVal) => {
       console.log('新值:', newVal, '旧值:', prevOldUserName)
       prevOldUserName = newVal
     })
     return { formData }
    }
    }

    这个方法更稳妥,没有JSON的局限性,性能也不错。

坑2:immediate: true的时候oldVal是undefined

这个不管是Vue2还是Vue3也都有,是正常现象——因为immediate是在组件挂载前(或者setup执行完后立即)触发一次回调,这时候还没有旧值,所以oldVal是undefined,如果你的逻辑里必须用到oldVal,要先做个判断:

watch(userPhone, (newVal, oldVal) => {
  if (oldVal === undefined) {
    console.log('初始加载,手机号是:', newVal)
    return
  }
  console.log('手机号从', oldVal, '变成了', newVal)
}, { immediate: true })

坑3:数组用索引修改元素或者直接修改length属性,watch没触发

等等,这个坑在Vue3里应该已经被解决了吧?对!Vue2里因为Object.defineProperty的局限性,直接修改数组索引或者length属性是不会触发响应式的;但Vue3用的是Proxy,不管你是修改索引、length、还是用push/pop/splice等方法,都会触发响应式,包括watch的回调,不信你试试:

import { reactive, watch } from 'vue'
export default {
  setup() {
    const list = reactive([1, 2, 3])
    watch(() => list, (newList) => {
      console.log('数组变了:', newList)
    }, { deep: true })
    // 测试修改索引
    setTimeout(() => list[1] = 22, 1000)
    // 测试修改length
    setTimeout(() => list.length = 2, 2000)
    return { list }
  }
}

控制台会分别在1秒和2秒的时候打印数组变化,所以这个坑在Vue3里已经不存在了,不用再像Vue2那样用Vue.set或者splice了。

坑4:监听computed返回的响应式值,但computed本身没有依赖变化

computed是依赖追踪的,只有当它的依赖项(ref/reactive的属性)变化时,才会重新计算;如果computed没有依赖任何响应式值,或者依赖项没变,那watch监听computed是不会触发的,不管computed的返回值有没有被手动修改(当然computed的返回值是只读的,不能手动修改,除非是computed的setter)。

最佳实践:什么时候用watch,什么时候用watchEffect?

除了watch,Vue3还有一个watchEffect,很多人分不清什么时候用哪个,这里给个简单的判断标准:

用watch的情况:

  1. 需要明确知道依赖项:比如只监听userPhone,不监听其他的;
  2. 需要获取新旧值:比如要对比手机号的变化做校验;
  3. 不需要初始加载就触发:比如只有当用户修改了手机号才请求接口;
  4. 需要手动控制监听的开启和停止:watch的返回值是一个stop函数,调用stop()就可以停止监听,
    const stopWatch = watch(userPhone, (newVal) => {
    if (newVal.length === 11) {
     console.log('手机号格式正确,停止监听')
     stopWatch()
    }
    })

用watchEffect的情况:

  1. 不需要明确知道依赖项:比如只要用到的任何响应式值变化,就执行回调,比如自动保存表单:
    import { reactive, watchEffect } from 'vue'
    export default {
    setup() {
     const formData = reactive({ userName: '', phone: '', email: '' })
     // 只要formData的任何属性变化,就自动保存到localStorage
     watchEffect(() => {
       localStorage.setItem('userForm', JSON.stringify(formData))
     })
     return { formData }
    }
    }
  2. 需要初始加载就触发:watchEffect默认就是immediate: true,不需要手动加;
  3. 不需要获取新旧值:比如自动保存只需要最新的值。

总结一下

Vue3监听v-model的变化,核心就是抓住响应式载体:单个ref直接传变量,reactive的单个属性用getter包,整个reactive对象默认deep但要注意性能,嵌套组件父子监听的原理一样,子组件不能直接改prop要emit,避坑的话,注意新旧值对比、immediate的oldVal、还有不用再纠结Vue2的数组索引坑,根据需求选watch还是watchEffect,不要乱用。

好了,今天的内容就到这里,有什么问题可以在评论区留言,咱们一起讨论!

版权声明

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

热门