Skip to content
本页目录

妙用 computed 拦截 v-model

大家都知道, vue是单项数据流的,子组件不能直接修改父组件传过来的 props,但是在我们封装组件使用v-model时,不小心就会打破单行数据流的规则,例如下面这样

vue
<!-- 子组件 -->
<script setup lang="ts">
defineOptions({ name: 'Child' })
defineProps(['modelValue'] as const)
</script>

<template>
  <el-input v-model="modelValue" />
</template>
vue
<!-- 父组件 -->
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const msg = ref('')
</script>

<Child v-model="msg" />

emit 通知父组件修改 prop

上面代码中,直接在子组件上修改props的值,就打破了单向数据流, 为了解决这个问题,我们通常会使用 emit自定义事件去通知父组件修改prop

vue
<!-- 子组件 -->
<script setup lang="ts">
defineOptions({ name: 'Child' })
defineProps(['modelValue'] as const)

const emit = defineEmits(['update:modelValue']) 
const handleValueChange = value => emit('update:modelValue', value) 
</script>

<template>
  <el-input v-model="modelValue" @update:modelValue="handleValueChange" /> 
</template>
vue
<!-- 父组件 -->
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const msg = ref('')
</script>

<Child v-model="msg" />

TIP

这也是大多数开发者封装组件修改值的方法,其实还有另一种方案,就是利用 computedgetset

computed 拦截 prop

vue中的 computed 支持对象形式写法, 可以传入一个拥有 getset 函数的对象,创建一个可手动修改的计算状态。

利用 computedget set 进行拦截

vue
<!-- 子组件 -->
<script setup lang="ts">
import { computed } from 'vue'

defineOptions({ name: 'Child' })
defineProps(['modelValue'] as const)

const emit = defineEmits(['update:modelValue'])

const msg = computed({ 
  get: () => props.modelValue, 
  set: newVal => emit('update:modelValue', newVal) 
}) 
</script>

<template>
  <el-input v-model="msg" /> 
</template>
vue
<!-- 父组件 -->
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const msg = ref('')
</script>

<Child v-model="msg" />

v-model 绑定对象

那么当v-model绑定的是对象呢?

可以像下面这样,使用 computed 依次拦截多个对象的属性值:

ts
const name = computed({
  get: () => props.formData.name,
  set: newVal => emit('update:formData', { ...props.modelValue, name: newVal })
})

const age = computed({
  get: () => props.formData.age,
  set: newVal => emit('update:formData', { ...props.modelValue, age: newVal })
})

const phoneNumber = computed({
  get: () => props.formData.phoneNumber,
  set: newVal => emit('update:formData', { ...props.modelValue, phoneNumber: newVal })
})

const remark = computed({
  get: () => props.formData.remark,
  set: newVal => emit('update:formData', { ...props.modelValue, remark: newVal })
})

但是一个个手动拦截 v-model 对象的属性值,太过于麻烦,假如有10个输入,我们就需要拦截10次,所以我们需要将拦截整合起来!

computed 无法直接监听对象

我们会很容易写出下面的代码:

vue
<script setup lang="ts">
import { type PropType, computed, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'

interface IForm {
  name: string
  age: string | number
  phoneNumber: string
  remark: string
}

const props = defineProps({
  formData: {
    type: Object as PropType<IForm>,
    required: true,
  },
} as const)

const emit = defineEmits([
  'update:loading',
  'update:formData',
  'submit',
])

const refForm = ref<FormInstance>()

const form = computed({  
  set: (value: IForm)  => { 
    console.log(`-->监听到了 formData 对象某个值改变:${value}`) 
    emit('update:formData', value) 
  }, 
  get: () => props.formData, 
}) 


const rules: FormRules = {
  name:[
    { required: true, message: '请输入姓名', trigger: 'blur' },
  ],
  age: [
    { required: true, message: '请输入年龄', trigger: 'blur' },
  ],
  phoneNumber: [
    { required: true, message: '请输入电话号码', trigger: 'blur' },
  ],
  remark: [{ required: false }],
}
</script>

<template>
  <el-form
    ref="refForm"
    :model="form"
    :rules="rules"
    status-icon
    label-width="80px"
  >
    <el-form-item prop="name" label="姓名">
      <el-input v-model="form.name" placeholder="请输入姓名" />
    </el-form-item>
    <el-form-item prop="age" label="年龄">
      <el-input v-model="form.age" placeholder="请输入年龄" />
    </el-form-item>
    <el-form-item prop="phoneNumber" label="联系方式">
      <el-input v-model="form.phoneNumber" placeholder="请输入手机号" />
    </el-form-item>
    <el-form-item prop="remark" label="备注">
      <el-input
        v-model="form.remark"
        type="textarea"
        placeholder="请输入备注"
      />
    </el-form-item>
  </el-form>
</template>
vue
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import Child from './ErrChild.vue'

const fromData = reactive({
  name: '',
  age: '',
  phoneNumber: '',
  remark: '',
})
</script>

<template>
  <Child v-model:formData="fromData" />
</template>

DANGER

上面的代码中,我们直接使用的是 props 中的 formData对象,这种方式在 computed 的 set 中无法监听到 formData 中具体某个值的变化,所以,我们在表单里输入时,log 语句一直没有输出。原因是:form.xxx = xxx 时, 并不会触发computedset,只有form = xxx时,才会触发 set

使用Proxy代理对象

所以, 我们需要想一个办法,在 formData 的属性修改时,也能emit("update:formData", newValue),为了解决这个问题,我们可以通过Proxy代理

vue
<script setup lang="ts">
import { type PropType, computed, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'

export interface IForm {
  name: string
  age: string | number
  phoneNumber: string
  remark: string
}

const props = defineProps({
  formData: {
    type: Object as PropType<IForm>,
    required: true,
  },
  loading: {
    type: Boolean,
    default: false,
  },
} as const)

const emit = defineEmits([
  'update:loading',
  'update:formData',
  'submit',
])

const refForm = ref<FormInstance>()

const _loading = computed({
  set: (value: boolean) => emit('update:loading', value),
  get: () => props.loading,
})

const form = computed({ 
  get:() => { 
    return new Proxy(props.formData, { 
      get:(target, key) => Reflect.get(target, key), 
      set: (target, key, value) => { 
        console.log('\n ---- Child.vue ---- \n 监听到了 formData 对象某个值改变 -----> :', value, '\n') 
        emit('update:formData', { ...target, [key]: value }) 
        return true 
      }, 
    }) 
  }, 
  set: newValue => emit('update:formData', newValue), 
}) 

const rules: FormRules = {
  name:[{ required: true, message: '请输入姓名', trigger: 'blur' }],
  age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
  phoneNumber: [{ required: true, message: '请输入电话号码', trigger: 'blur' }],
  remark: [{ required: false }],
}
</script>

<template>
  <el-form
    ref="refForm"
    v-loading="_loading"
    :model="form"
    :rules="rules"
    status-icon
    label-width="80px"
  >
    <el-form-item prop="name" label="姓名">
      <el-input v-model="form.name" placeholder="请输入姓名" />
    </el-form-item>
    <el-form-item prop="age" label="年龄">
      <el-input v-model="form.age" placeholder="请输入年龄" />
    </el-form-item>
    <el-form-item prop="phoneNumber" label="联系方式">
      <el-input v-model="form.phoneNumber" placeholder="请输入手机号" />
    </el-form-item>
    <el-form-item prop="remark" label="备注">
      <el-input
        v-model="form.remark"
        type="textarea"
        placeholder="请输入备注"
      />
    </el-form-item>
  </el-form>
</template>
vue
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import Child from './Child.vue'

const loading = ref(true)

const fromData = ref({ // 这里必须使用ref定义,否则无法通过 proxy 拦截
  name: '',
  age: '',
  phoneNumber: '',
  remark: '',
})

watch(fromData, (newValue) => {
  console.log('\n ---- Parent.vue ---- \n fromData -----> 值改变了', newValue, '\n')
})

onMounted(() => {
  loading.value = true
  setTimeout(() => {
    fromData.value = {
      name: 'Yi',
      age: '25',
      phoneNumber: '13246566775',
      remark: 'Hello world~',
    }
    loading.value = false
  }, 3000)
})
</script>

<template>
  <div class="pb-6">
    请查看控制台输出 👀
  </div>
  <Child v-model:loading="loading" v-model:form-data="fromData" />
</template>

TIP

这样,我们就通过了 Proxy + computed 完美拦截了v-model的对象!

封装为 Hooks

为了后面使用方便,我们直接将其封装成 hook

方案一 只适用于父组件传入ref

ts
import { computed } from 'vue'
import type { Ref, UnwrapRef } from 'vue'

/**  为了性能做缓存,针对每个对象,进行缓存 */
const cacheMap = new WeakMap()

export function useVModel<P extends object, K extends keyof P>(
  props: P,
  propsName: K,
  emit: (...args: any[]) => void,
): Ref<UnwrapRef<P[K]>> {
  const _name = propsName as string
  const emit_name = `update:${_name}`

  const data = computed({
    get: () => {
      /**  如果缓存中有对应的代理就不创建新的代理了 */
      if (cacheMap.has(props[_name])) return cacheMap.get(props[_name])

      const proxy = new Proxy(props[_name], {
        get: (target, key) => Reflect.get(target, key),
        set: (target, key, value) => {
          emit(emit_name, { ...target, [key]: value })
          return true
        },
      })
      /** 如果缓存中没有对应的代理就创建新的代理 */
      cacheMap.set(props[_name], proxy)
      return proxy
    },
    set: newValue => emit(emit_name, newValue),
  })
  return data
}

使用

ts
useVModel(props, 'formData', emit)

方案二 ref与reactive通用

ts
import { ref, watch } from 'vue'
import type { UnwrapRef } from 'vue'

export function useVModel<
  P extends object,
  N extends keyof P,
>(
  props: P,
  name: N,
  emit: (...args: any[]) => void,
) {
  const model = ref(props[name])

  watch(
    () => props[name],
    () => model.value = props[name] as UnwrapRef<P[N]>,
  )

  watch(
    model,
    () => emit(`update:${name as string}`, model.value as P[N]),
    { deep: true },
  )
  return model
}

参考 🔗

上面的 useVModel 写法其实很简陋,社区的VueUse库中,有兼容性更强的实现。

  1. 下载 VueUse
shell
pnpm  i @vueuse/core 
  1. 使用
vue
<script setup lang="ts">
import { type PropType, computed } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'

import { useVModel } from '@vueuse/core' 

const props = defineProps({
  formData: {
    type: Object as PropType<{ name: string; age: string }>,
    required: true
  }
})

const emit = defineEmits(['update:formData', 'submit'])
const form = useVModel(props, 'formData', emit) 
// console.log(form.value) // props.formData 
// form.value = { name: 'yi', age: '25'} // emit('update:formData', { name: 'yi', age: '25'}) 
</script>

<template>
  <el-form :model="form">
    <el-form-item prop="name" label="姓名">
      <el-input v-model="form.name" placeholder="请输入姓名" />
    </el-form-item>
    <el-form-item prop="age" label="年龄">
      <el-input v-model="form.age" placeholder="请输入年龄" />
    </el-form-item>
  </el-form>
</template>