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

Vue3里watch和watchEffect直接绑定modelValue会不会踩坑?怎么正确监听双向绑定的props值?

terry 1小时前 阅读数 23 #Vue

为什么一上来就提这个?真的有坑吗?

别着急否定,刚接触Vue3组合式API做父子组件双向绑定的时候,很多人(包括我前阵子赶项目)都直接给watch或者watchEffect传了props.modelValue,结果要么监听失效,要么偶尔跳一下数据,要么控制台偶尔跳个警告框提示「避免直接修改props」但明明只是想看看变化,这些不是幻觉,都是因为Vue3里双向绑定的props值(也就是默认的modelValue)有几个容易被忽略的特性,和组合式API的监听器机制没对齐导致的。

先捋捋Vue3父子双向绑定的底层逻辑

要解决问题,得先搞明白“道具”在这时候到底是个啥,以前Vue2用v-model绑定的时候,底层是prop value和事件input;现在Vue3把这个默认规则保留但简化成了更通用的 v-model:modelValue(单绑直接写v-model就行),但核心逻辑没变——父组件给子组件传prop,子组件不能直接改,得触发update:modelValue事件让父组件自己改。

重点来了:组合式API里接收的props,不管是不是双向绑定的,都是只读的响应式浅层对象(除非用了reactive或者readonly的深层代理,但Vue默认只做浅层的响应式绑定,传递普通值、数组对象引用的时候才会有不同表现),那普通的响应式ref/reactive和这个只读响应式prop有啥区别?ref/reactive自己内部有响应式更新,你可以直接触发监听;但双向绑定的prop modelValue,更新源永远在父组件那边,子组件只是被动接收新值,而且如果prop传的是普通值(比如数字、字符串、布尔),Vue只会做浅层代理的表面更新——如果直接传props.modelValue给watch的第一个参数,这个普通值本身是没有响应式依赖追踪标记的,自然监听不到变化。

watch直接绑定modelValue的几种常见情况和对应的坑

刚才说的是根本原因,但具体场景里坑的表现不一样,得一个一个说清楚,你才好对号入座:

父组件传的是普通值(比如字符串title)

比如你做了一个自定义搜索框组件,父组件用v-model="searchText"传了个字符串给modelValue,你在子组件里直接写:

import { watch } from 'vue'
const props = defineProps(['modelValue'])
watch(props.modelValue, (newVal, oldVal) => {
  console.log('搜索框值变了', newVal)
})

然后在父组件里疯狂敲搜索框的真实输入——哦不对,敲真实输入你肯定会先在子组件里改个localValue然后触发update事件对吧?那好,父组件的searchText变了,子组件的props.modelValue确实更新了,但控制台就是没打印,为啥?因为普通值的prop是「值传递」的,每次父组件更新传过来的都是个新的原始值,而watch的第一个参数如果是原始值的话,Vue会把它当成普通的静态值来处理,不会去追踪它的变化——相当于你一开始传了个空字符串给watch,它就只记住了空,以后不管传啥新的,都不会触发回调。

父组件传的是引用值(比如对象userInfo)

这次父组件传的是v-model="userInfo",里面有个name字段,你还是直接写:

watch(props.modelValue, (newVal, oldVal) => {
  console.log('用户信息变了', newVal.name)
})

哎,这次好像有用?比如你在子组件里改userInfo的name,触发update事件让父组件更新,控制台会打印,但别急,再试试两种情况:第一种,父组件直接把userInfo整个替换成新对象(比如userInfo = {name:'新的'}),子组件会打印;第二种,父组件只改userInfo的name字段,子组件如果用了defineEmits触发update但没传新对象,而是传了原来的引用,会不会打印?不一定,要看父组件那边是不是真的让这个引用指向了新的东西——如果父组件也是直接改userInfo.name再传给子组件,那整个props.modelValue的引用没变化,浅层代理的watch根本不会理,这时候很多人又会加个{ deep: true },但加了之后坑又来了——你会发现有时候明明只是父组件初始化或者传了个新的空引用,回调都会跑好几次,而且有时候控制台还会悄悄提示「避免直接修改prop」,虽然你只是想深度监听,但因为Vue的响应式系统在追踪深层属性的时候,会误触到子组件尝试访问深层响应式依赖的边缘情况,偶尔就会出这个警告。

用watchEffect直接绑定modelValue

这个比watch的坑更隐蔽,很多人觉得watchEffect自动追踪依赖,肯定没问题,比如子组件里写:

import { watchEffect } from 'vue'
const props = defineProps(['modelValue'])
const localTitle = ref('')
watchEffect(() => {
  localTitle.value = props.modelValue.title
})

如果父组件传的是引用值,这个好像能跑,对吧?初始化的时候localTitle会变,父组件改引用对象的title,有时候也能变,但你试试传普通值,直接改成localTitle.value = props.modelValue——和watch情况一一样,普通值不会被自动追踪,父组件更新后localTitle纹丝不动,而且就算传引用值,watchEffect的自动追踪也是有边界的:如果你只访问了props.modelValue.title的某个属性,后来父组件把整个modelValue替换成新对象但新对象里的title没变,watchEffect也会跑一次,因为它追踪的是整个props.modelValue的引用(哦不对,准确说如果是普通ref/reactive的引用,替换会触发,但这里是只读响应式prop的引用值,深层属性访问会不会影响?其实要看父组件是怎么传的,但很多时候会出现冗余更新,性能上不划算,还容易引入难以调试的逻辑bug。

正确监听modelValue的几种姿势,总有一种适合你

好了,坑讲完了,现在说解决办法,都是经过实践验证、不会有问题的:

直接传函数返回props.modelValue(最通用的万能解法)

不管父组件传的是普通值还是引用值,不管你用watch还是想做更精细的控制,这个方法都不会错,组合式API里,watch的第一个参数可以是一个getter函数,Vue会自动追踪这个函数里用到的所有响应式依赖——包括props里的所有属性!对,你没看错,整个props对象是响应式的(是defineProps返回的那个代理对象,不是里面的单个原始值属性),所以只要在getter里返回props.modelValue,Vue就能追踪到父组件对这个prop的任何修改,不管是替换整个普通值,还是替换引用值,甚至如果配合deep的话还能监听引用值的深层属性(但尽量少用deep,除非真的需要,性能会有损耗)。

刚才的搜索框普通值场景,改成这样就好了:

watch(() => props.modelValue, (newVal, oldVal) => {
  console.log('搜索框值变了', newVal)
  // 这里可以同步localValue或者做其他操作
})

引用值的场景,如果只想监听引用的变化,不用加deep:

watch(() => props.modelValue, (newVal, oldVal) => {
  console.log('整个用户信息对象被替换了', newVal, oldVal)
})

如果真的需要监听引用值的深层属性,比如userInfo的name或者id,加个deep就行,但尽量把监听范围缩小到具体的属性,比如改成() => props.modelValue.name,这样既不用deep,性能也更好:

// 比加deep高效10倍的做法,只监听单个深层属性
watch(() => props.modelValue?.name, (newVal, oldVal) => {
  console.log('用户姓名变了', newVal)
})

这里加个可选链是为了避免父组件一开始传空对象或者undefined报错,好习惯要保持。

用computed做一个本地镜像,再监听镜像(适合需要同时做双向同步的场景)

很多时候我们监听modelValue不是为了看变化,而是为了同步到子组件内部的某个变量,然后再让子组件可以修改这个变量触发update事件——这时候直接用computed的get和set组合是最优雅的,既实现了双向同步,又能顺便在get或者set里加你想要的监听逻辑(或者直接再用watch监听这个computed镜像)。

还是拿搜索框举例,直接用computed做localSearchText:

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
// 优雅的本地镜像,自动同步父组件,修改时自动触发事件
const localSearchText = computed({
  get() {
    return props.modelValue
    // 这里其实已经可以做一些简单的监听逻辑了,比如打印日志
  },
  set(val) {
    emit('update:modelValue', val)
    // 这里也可以做修改后的逻辑,比如发送搜索请求前的防抖初始化
  }
})
// 如果需要更复杂的监听(比如防抖后的请求),可以再监听localSearchText
watch(localSearchText, (newVal) => {
  // 加个防抖,避免每次敲字都发请求
  clearTimeout(timer)
  timer = setTimeout(() => {
    console.log('发送搜索请求,关键词:', newVal)
  }, 300)
})

这个方法我现在做自定义表单组件、搜索组件的时候用得最多,代码非常简洁,逻辑也很清晰,完全符合Vue3的设计理念,而且不会有任何监听失效或者修改prop的问题——因为修改的是computed的镜像,不是props本身。

用toRef或者toRefs把modelValue变成真正的响应式ref(适合组合式API里需要频繁传递modelValue的场景)

defineProps返回的是一个响应式对象,但里面的单个原始值属性不是响应式的,只是绑定在代理对象上的值——这时候你可以用Vue3提供的toRef或者toRefs把它变成一个真正的响应式ref,不管你把这个ref传到哪里,它都会和父组件的modelValue保持同步,而且可以直接用watch监听这个ref(不需要加getter函数)。

比如你要把modelValue传给子组件内部的一个工具函数,或者要在多个地方用这个值,不想每次都写props.modelValue或者getter函数,就可以用toRef:

import { watch, toRef } from 'vue'
const props = defineProps(['modelValue'])
// 把modelValue变成真正的响应式ref
const modelRef = toRef(props, 'modelValue')
// 直接监听这个ref,不需要getter
watch(modelRef, (newVal, oldVal) => {
  console.log('modelValue变了', newVal)
})
// 可以直接传给其他函数
someUtilFunction(modelRef)

toRefs的话适合一次性把所有props都变成响应式ref,但如果你只需要modelValue,用toRef更节省内存:

import { toRefs } from 'vue'
const props = defineProps(['modelValue', 'placeholder', 'disabled'])
// 把所有props变成响应式ref
const { modelValue: modelRef, placeholder, disabled } = toRefs(props)
// 同样可以直接监听
watch(modelRef, ...)

这里要注意:toRef和toRefs返回的ref是只读的吗?其实不是完全只读的——你可以修改它的value,但因为它绑定的是父组件的prop,修改后不会触发父组件的update事件,只是子组件内部的ref变了,而且很快就会被父组件传过来的新值覆盖,所以千万不要直接修改toRef/toRefs返回的modelRef value,要修改的话还是得用emit触发update事件。

最后再啰嗦一句:v-model的自定义参数怎么监听?

刚才讲的都是默认的v-model:modelValue,如果你用了自定义参数,比如v-model:title或者v-model:visible,监听方法是完全一样的——只是把modelValue换成你的自定义参数名就行,比如() => props.title或者toRef(props, 'visible')

Vue3.4之后还新增了一个defineModel的宏,这个宏可以帮你自动生成computed的get和set,连defineProps和defineEmits都不用写(哦不对,其实底层还是会帮你生成的,只是简化了代码),监听的话直接监听defineModel返回的ref就行,非常方便——如果你用的是Vue3.4及以上的版本,强烈建议试试这个宏,代码量能减少一半还多,而且逻辑更清晰,比如用defineModel做刚才的搜索框:

import { watch } from 'vue'
// 自动生成modelValue的prop和update:modelValue的emit
const localSearchText = defineModel()
// 直接监听就行
watch(localSearchText, (newVal) => {
  console.log('搜索框值变了', newVal)
})

自定义参数的话,defineModel里传个字符串就行:

const title = defineModel('title')

不过defineModel目前还有点小限制,比如不能和defineProps里的同名prop同时用,但大部分场景下都没问题,大家可以放心尝试。

好了,今天关于Vue3 watch和watchEffect监听modelValue的坑和解决办法就讲完了,希望能帮到正在踩坑或者即将踩坑的你,其实Vue3的组合式API比Vue2的选项式API灵活很多,但也有一些容易被忽略的细节,只要搞懂了底层的响应式逻辑和双向绑定的原理,这些坑其实都很容易避开。

版权声明

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

热门