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

Vue3 父子组件传值时,watch 监听 modelValue 为什么经常失效或者触发异常?怎么优雅地解决?

terry 1小时前 阅读数 20 #Vue

很多刚开始接触Vue3新手用 Composition API 写组件的朋友,十有八九踩过 watch 监听 modelValue 的坑:明明父组件明明改了输入框、开关这类双向绑定子组件的绑定值,要么 watch 完全没反应,要么只触发了但获取不到正确的最新值,要么父子双向直接循环触发好几次,今天咱们就从原因、解决办法、避坑指南这几个部分,一步步拆解这个问题。

父子组件用 v-model 和 watch 的底层逻辑

要解决 watch modelValue 的问题,首先得搞懂 Vue3 里 v-model 到底做了啥变化,很多人以为 Vue3 只是把 model 和 sync 删了,默认绑的是 modelValue,但其实不止这点底层处理方式、处理函数也不一样,以前 Vue3 把 v-model 的行为优化了双向传值的双向绑定机制里的双向绑定机制完全统一了,但绑定流程也同步做了默认值的处理逻辑也完全改了很多,以前 Vue2 里的自定义组件的处理流程。

第一个不同是默认双向绑定的绑定流程优化了但核心没变:组件通过 props 接收 modelValue(或者自定义名),然后子组件内部 emit('update:modelValue') 传回去,对吧?但第二个不同是,Composition API 里,如果子组件用 props 时,props 是响应式对象没错,但 watch 默认监听的是 **props 本身的引用,不是它的属性变化,modelValue 是基础类型(比如字符串、数字、布尔值),父组件传过来的引用是不会变的,但如果是引用类型(对象、数组),子组件直接修改内部属性不会触发 watch 除非 deep 选项,但如果父组件直接替换整个对象或数组,引用变了,默认 shallow watch 就能监听到。

很多失效的情况其实不是监听机制的问题,而是新手的写法不对,或者对响应式对象的处理有问题,或者 Vue3 的优化导致的延迟更新机制导致的,很多新手在子组件里直接修改 modelValue,或者父组件里没有正确使用 computed 封装,或者 watch 的监听时机不对。

新手最容易遇到的 watch 踩坑场景和解决方案

子组件里直接赋值修改 modelValue,导致 watch 失效

很多新手刚从 Vue2 或者原生 HTML 转过来,直接在子组件的输入框里 v-model="modelValue",这样在 Vue2 里加了 sync 可以,但在 Vue3 里,直接修改 props 的属性(哪怕是引用类型都不行,基础类型更不用说,基础类型直接就报错了,但很多新手可能没看控制台的警告,只改了引用类型没报错,但其实是无效的,watch 监听的时候会出现问题。

因为在 Vue3 里,props 是只读的,子组件不能直接修改,哪怕是引用类型,虽然表面上没报错,但 Vue3 会在生产环境下禁用这种行为,或者直接没生效,或者导致响应式失效,或者导致 watch 只触发一次。

解决方案:必须在子组件里用 computed 封装一个中间变量,通过 getter 获取 props.modelValue,通过 setter 触发 emit('update:modelValue', newValue),然后中间变量绑定到子组件的输入框或者开关上,这样的写法既符合 Vue3 的单向数据流的要求,又能正常触发双向绑定,watch 不管是监听 props.modelValue 还是中间变量,都能正常工作。

举个例子,比如一个自定义数字输入框子组件:

import { computed, watch } from 'vue'
const props = defineProps({
  modelValue: Number,
  min: { type: Number, default: 0 },
  max: { type: Number, default: 100 }
})
const emit = defineEmits(['update:modelValue'])
// 封装中间变量
const innerValue = computed({
  get() {
    return props.modelValue
  },
  set(val) {
    // 可以在这里加一些校验逻辑,比如只能输入数字,或者只能在 min 和 max 之间
    let newVal = val
    if (typeof newVal !== 'number') {
      newVal = props.modelValue
    }
    newVal = Math.max(props.min, Math.min(props.max, newVal))
    emit('update:modelValue', newVal)
  }
})
// 监听 innerValue 或者 props.modelValue 都可以
watch(innerValue, (newVal) => {
  console.log('innerValue 变化了', newVal)
})
watch(() => props.modelValue, (newVal) => {
  console.log('props.modelValue 变化了', newVal)
})

然后模板里绑定 innerValue 就可以了:

<input type="number" v-model="innerValue" />

modelValue 是引用类型,父组件只修改内部属性,默认 shallow watch 没反应

很多新手父组件传的是一个对象或者数组,然后父组件里只修改这个对象的某个属性,user.name,或者数组的某个元素,list[0],然后子组件里 watch(() => props.modelValue),这样的话,默认 shallow watch 是监听不到的,因为 props.modelValue 的引用没有变,只是内部属性变了。

解决方案:给 watch 加 deep 选项,或者直接在子组件里用 computed 封装的时候 watch computed,deep 选项如果监听的是大对象或者大数组的话,性能会有问题,因为每次内部任何属性变化都会触发 deep watch,会有性能损耗,所以如果只需要监听某个具体的属性,最好直接监听那个属性,watch(() => props.modelValue.name),这样性能更好。

举个例子,父组件传的是 user 对象:

// 父组件
import { ref } from 'vue'
const user = ref({
  name: '张三',
  age: 18
})
<!-- 父组件模板 -->
<my-user-form v-model="user" />
<button @click="user.name = '李四'">修改名字</button>
<button @click="user = { name: '王五', age: 20 }">替换整个对象</button>
// 子组件
import { computed, watch } from 'vue'
const props = defineProps({
  modelValue: Object
})
const emit = defineEmits(['update:modelValue'])
const innerUser = computed({
  get() {
    return props.modelValue
  },
  set(val) {
    emit('update:modelValue', val)
  }
})
// 监听整个对象,加 deep 选项
watch(innerUser, (newVal) => {
  console.log('整个 innerUser 变化了', newVal)
}, { deep: true })
// 只监听名字,性能更好
watch(() => props.modelValue.name, (newVal) => {
  console.log('名字变化了', newVal)
})

watch 初始化的时候获取不到最新值

很多新手 watch(() => props.modelValue,console.log 打印的还是旧值,或者有时候有时候能有时候不能,这种情况很多时候是因为 Vue3 的响应式更新是异步批量更新的,导致 watch 触发的时候,DOM 还没更新,或者有时候是因为用了 shallowRef 或者 computed 的 immediate 选项的问题?不对,immediate 选项是初始化的时候就触发一次,获取的是初始化的值,没问题啊?哦,不对,很多时候是因为新手在父组件里修改 modelValue 是 ref 或者 reactive 的时候,没有正确触发更新,或者子组件里用了非响应式的变量?或者是因为用了 watchEffect 而不是 watch? 或者是因为 watch 监听的是一个非响应式的表达式? 不对,刚才第一个是 props 是响应式对象,() => props.modelValue 是响应式的表达式,没问题啊?哦,对了,很多时候是因为新手在子组件里修改 modelValue 是 computed 的时候, computed 的 getter 里没有正确获取 props.modelValue,或者 computed 的 setter 里没有正确触发 emit,导致 watch 没有监听到变化。 还有一种情况是,父组件里传的是一个函数的返回值,v-model="getUser()",这样每次渲染的时候都会返回一个新的对象,导致 watch 每次渲染都会触发,但是如果 getUser() 函数里如果没有用 ref 或者 reactive 包裹的话,每次返回的是新对象,子组件里 watch 会每次都能监听到,但这其实是父组件的写法不对,应该把 getUser() 的返回值存起来,用 ref 或者 reactive 包裹,然后再传。

还有一种情况是,watch 用的是 watchEffect,而不是 watch,watchEffect 是依赖收集的时候会触发,然后每次依赖变化都会触发,watchEffect 没有旧值,而且有时候如果依赖的变量没有正确触发依赖收集,innerValue 是 computed 的,watchEffect 里没有用到 innerValue,而是用到了 props.modelValue,props.modelValue 的引用没有变,只是内部属性变了,那 watchEffect 也不会触发,除非用到了内部属性。

解决方案:确保父组件传的 modelValue 是 ref 或者 reactive 包裹的响应式对象,不是函数的返回值每次都是新对象;确保子组件里用 computed 封装中间变量,getter 正确获取 props.modelValue,setter 正确触发 emit;确保 watch 监听的是正确的响应式表达式,需要监听引用类型内部属性的话,要么加 deep 选项,要么监听具体的属性;如果需要获取最新的 DOM 相关的值,比如输入框的 value,那可以用 nextTick,或者用 watch 的 flush 选项设为 'post',这样 watch 会在 DOM 更新之后再触发。

举个例子,比如需要获取输入框的 value 的长度,那可以用 nextTick:

import { computed, watch, nextTick } from 'vue'
const props = defineProps({
  modelValue: String
})
const emit = defineEmits(['update:modelValue'])
const innerValue = computed({
  get() {
    return props.modelValue
  },
  set(val) {
    emit('update:modelValue', val)
  }
})
watch(innerValue, async (newVal) => {
  await nextTick()
  // 这里可以获取到最新的 DOM 相关的值
  const inputEl = document.querySelector('input')
  console.log('输入框的长度', inputEl.value.length)
})
// 或者用 flush 选项设为 'post'
watch(innerValue, (newVal) => {
  const inputEl = document.querySelector('input')
  console.log('输入框的长度', inputEl.value.length)
}, { flush: 'post' })

父子组件循环触发 watch

很多新手父子组件都 watch modelValue,然后子组件里 computed 的 setter 里又修改了某个变量,这个变量又触发了父组件的某个修改,然后父组件又修改了 modelValue,然后子组件又触发 computed 的 setter,然后又触发父组件的修改,这样就循环触发了。

解决方案:在子组件的 computed 的 setter 里加一些校验逻辑,比如只有当 newValue 和 props.modelValue 不一样的时候才触发 emit;不要在父子组件里同时修改同一个变量,除非加了校验;尽量避免在 watch 里直接修改会触发响应式更新的变量,除非加了校验。

举个例子,在子组件的 computed 的 setter 里加校验:

const innerValue = computed({
  get() {
    return props.modelValue
  },
  set(val) {
    if (val === props.modelValue) return // 加校验,只有不一样才触发 emit
    emit('update:modelValue', val)
  }
})

更优雅的监听和封装方式

除了上面的用 computed 封装中间变量的方式,还有没有更优雅的方式?有的,比如用 VueUse 库的 useVModel 函数,这个函数可以直接帮我们封装好中间变量,getter 和 setter,还有校验逻辑,非常方便,而且性能也很好。

如果你不想引入第三方库的话,也可以自己封装一个 composable 函数,useVModel:

import { computed } from 'vue'
export function useVModel(props, emit, propName = 'modelValue') {
  return computed({
    get() {
      return props[propName]
    },
    set(val) {
      if (val === props[propName]) return
      emit(`update:${propName}`, val)
    }
  })
}

然后在子组件里直接引入就可以了:

import { useVModel, watch } from '@/composables/useVModel'
const props = defineProps({
  modelValue: Number,
  min: { type: Number, default: 0 },
  max: { type: Number, default: 100 }
})
const emit = defineEmits(['update:modelValue'])
const innerValue = useVModel(props, emit)
// 然后加自己的校验逻辑的话,可以在 composable 里加,或者在 set 里加,
const innerValueWithValidation = computed({
  get() {
    return innerValue.value
  },
  set(val) {
    let newVal = val
    if (typeof newVal !== 'number') {
      newVal = props.modelValue
    }
    newVal = Math.max(props.min, Math.min(props.max, newVal))
    innerValue.value = newVal
  }
})
// 然后模板里绑定 innerValueWithValidation 就可以了

总结一下

要解决 Vue3 父子组件传值时 watch 监听 modelValue 的问题,首先要搞懂 Vue3 的 v-model 的底层逻辑,props 只读,子组件只能通过 emit('update:modelValue') 传回去;然后要避免踩几个常见的坑:直接修改 props、modelValue 是引用类型不加 deep 选项或者监听具体属性、获取不到最新值、循环触发;然后可以用 computed 封装中间变量的方式,或者自己封装 composable 函数,或者用 VueUse 库的 useVModel 函数。

再提醒一下,尽量遵守 Vue3 的单向数据流的要求,不要在子组件里直接修改 props,哪怕是引用类型,虽然表面上没报错,但 Vue3 会在生产环境下禁用这种行为,或者导致响应式失效,或者导致 watch 只触发一次。

版权声明

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

热门