自定义 v-model 并封装 hooks

349 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情

前言

  • 基础篇: 简单总结 Vue3v-model 的使用方式,包括在原生元素和组件上使用
  • 进阶篇: 对 v-model 进行封装,编写一个选项式 Api , 方便在组件上使用 自定义 v-model

基础

v-model 用于在表单处理中,使用 v-model 语法糖,可以很简单讲表单输入框中的内容雨 Javascript 中的变量进行同步

在单个元素上使用 v-model

<input v-model="value" />

v-model 除了在 input 上使用之外,还可用于 textareaselect 等元素,对于不同元素或者 input 不同 type 属性时, v-model 绑定的元素和监听的事件不同。来看下官网的描述

v-model 对于不同元素监听的属性和事件

Vue 模板编译器会将 v-model 进行解析并展开,等价于下面的代码

<input :value="value" @input=value = $event.target.value /> 

v-model 可用的修饰符

v-model 修饰符可以将输入的只进行一定的处理之后在赋值给 Javascript 变量, v-model 在原生元素上支持一下修饰符。

.lazy 修饰符: 延迟到 change 事件之后更新数据

.number 修饰符: 自动将数据转化成 Number 类型

.trim 修饰符: 去除数据中两端的空格

组件上使用 v-model

上面回顾了一下在原生元素上使用 v-model 语法糖,在组件上使用 v-model 就没有像原生元素上那么简单,当子组件的数据发生改变,需要通过 emit 事件通知父组件更新数据。

Vue 中,父子组件中用于传递数据的 props 数据为单向数据流,只能是父组件向子组件传递,子组件不直接修改 props 的值,否则会抛出警告。

来看下组件中 v-model 的使用方式。

<!-- 子组件 -->
<template>
   <input type="text" :value="modelValue" @input="update">
</template>

<script lang="ts" setup>
defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

const emit: any = defineEmits();

const update = (e: any)=>{
  emit('update:modelValue', e.target.value)
}
</script>
<!-- 父组件 -->
<BaseVModel v-model="val"></BaseVModel>

当在组件上使用 v-model 时,等价于以下代码:

<BaseVModel :modelValue="value" @update:modelValue="newVal => value = newVal"></BaseVModel>

默认情况下在组件中使用 v-model , 子组件需要在 props 中定义一个 modelValue 值, 将 modelValue 绑定到 inputvalue 属性中作为初始值。 然后通过 emit 触发 update:modelValue 事件并传递新值,在这种情况下,不需要显示的注册 update:modelValue 事件, Vue 在编译过程中会自动注册。

v-model 参数

在组件上使用 v-model 时,如果你不想使用 modelValue 作为 props 变量名,那么可以使用 v-model 参数来自定义 props 变量名

<!-- 子组件 -->
<template>
   <input type="text" :value="title" @input="update">
</template>

<script lang="ts" setup>
defineProps({
  title: {
    type: String,
    default: ''
  }
})

const emit: any = defineEmits();

const update = (e: any)=>{
  emit('update:title', e.target.value)
}
</script>
<!-- 父组件 -->
<BaseVModel v-model:title="val"></BaseVModel>

父组件中使用 v-model:title 的形式来定义 v-model 的参数,其中的 title 与子组件中的 props 相对应,在子组件输入框中的数据发生变化时,则需要出发 update:title 事件来通知父组件更新对应的变量值。

通过 v-model 参数的方式,可以实现多个 v-model 绑定

<!-- 子组件 -->
<template>
   <input type="text" :value="title" @input="updateTitle" />
   <input type="text" :value="content" @input="updateContent" />
</template>

<script lang="ts" setup>
defineProps({
  title: {
    type: String,
    default: ''
  },
  content: {
    type: String,
    default: ''
  }
})

const emit: any = defineEmits();

const updateTitle = (e: any)=>{
  emit('update:title', e.target.value)
}

const updateContent = (e: any)=>{
  emit('update:content', e.target.value)
}
</script>
<!-- 父组件 -->
<BaseVModel v-model:title="val" v-model:content="content"></BaseVModel>

自定义修饰符

在原生元素上使用 v-model 只能使用内置的修饰符,在组件上使用 v-model 可以自定义修饰符,在自定义修饰符需要为每一个修饰符制定一个处理还是来修改抛出的值。 在子组件中,可以通过在 props 中声明 modelModifiers 来访问修饰符的值,如果存在修饰符,则 modelModifiers 对象中存在以修饰符 key 的属性,并且值为 true

对于有参数又有修饰符的情况下,子组件中对应的 props 名称将是 ${arg} + Modifiers

<!-- 子组件 -->
<template>
   <input type="text" :value="title" @input="updateTitle" />
</template>

<script lang="ts" setup>
defineProps({
  title: {
    type: String,
    default: ''
  },
  titleModifiers: { default: ()=> ({})}
})

const emit: any = defineEmits();

const updateTitle = (e: any)=>{
  // 这里进行一些逻辑处理,然后出发事件
  let value = e.target.value
  if (props.titleModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:title', value)
}

</script>
<!-- 父组件 -->
<BaseVModel v-model:title.capitalize="val"></BaseVModel>

进阶

通过基础篇的一些总结,我们已经了解了如何使用 v-model 语法糖, 但是在开发过程中,这个过程略显繁琐,我们可以将子组件中一些逻辑进行抽离封装成 hook ,方便在实际开发中使用。

如何对 props 中的值进行绑定

对于这个问题, Vue 官方文档中给出了一种解决方式,就是使用一个可写,同时具有 settergettercomputed 属性, get 方法返回 props 中对应的值, 而 set 方法则触发相应的事件。

<!-- 子组件 -->
<template>
   <input type="text" v-model="titleValue" />
</template>

<script lang="ts" setup>
const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  modelModifiers: { default: ()=> ({})}
})

const emit: any = defineEmits();

const titleValue = computed({
  get(){
    return props.title
  },
  set(val){
    emit('update:title', val)
  }
})
</script>

还有另外一种方式,就是定义一个响应式变量,初始值设置为 props 中对应的值,然后使用 watch 监听这个响应式变量,当数据值发生变化时,出发相应的事件

封装 v-model 的 hook

基础功能

export function useVModel(options: ModelOptions){}

首先,我们定义一个函数 useVModel ,该函数接收一个配置对象, ModelOptions 定义如下

type ModelOptions = {
  props: any // 这里为 vue props 类型,暂时用 any 代替
  keys?: string[] // keys 为需要绑定的 props 中的 key 数组
  emit: any // 事件触发器
}

接下来,我们将上面 props 数据绑定的逻辑进行抽离

export function useVModel(options: ModelOptions){

  // 将配置对象进行解构取值
  const {props, keys = ['modelValue'], emit} = options 
  // 遍历 keys ,将 props 中对应的属性转换成 computed 对象

  let proxyObj = {}

  keys.forEach((key)=>{
    proxyObj[`${key}Proxy`] = computed({
      get(){
        return props[key]
      },
      set(val){
        emit(`update:${key}`, val)
      }
    })
  })
  // 将 proxyObj 对象返回
  return proxyObj
}

到这里, 我们完成一个基本的封装,下面来看下怎么使用

<!-- 子组件 -->
<template>
   <input type="text" v-model="titleProxy" />
</template>

<script lang="ts" setup>
const props = defineProps({
  title: {
    type: String,
    default: ''
  },
})

const emit: any = defineEmits();

// 使用 useVModel 并结构返回值,然后在模版中使用 v-model 进行绑定即可
const { titleProxy } = useVModel({
  props: props,
  keys: ['title']
  emit: emit
})
</script>

添加修饰符处理

在使用 v-model 时,可能会自定义修饰符,在封装 hooks 时,需要对自定义修饰符的逻辑进行处理。 我们对 useVModel 函数参数类型进行修改,增加一个可选参数 modifiers , 该参数是一个对象,对象的 key 为 修饰符名称, 对应的值为修饰符的处理函数,处理函数接收表单数据作为参数,并需要将处理后的数据进行返回。

type ModelOptions = {
  props: any
  keys?: string[]
  emit: any
  modifiers?: { [key: string]: (val: any) => any }
}

接下来编写修饰符的处理逻辑

// 使用柯里化
function execModifiesProgress(props: any, modifiers: { [key: string]: <T>(value: T) => T }) {
  return (key: string, val: any) => {
    // 取得 props 属性对应的修饰符 名称
    const modifierName = key === 'modelValue' ? `modelModifiers` : `${key}Modifiers`

    const propsModifiers = props[modifierName]
    // 如果该 props 没有定义修饰符,直接返回数据
    if (!propsModifiers) {
      return val
    }
    // 获取到该 props 所有的修饰符名称
    const propsModifiersKeys = Object.keys(propsModifiers)

    // 遍历所有的修饰符名称,取得相应的处理函数,并执行
    propsModifiersKeys.forEach((modifierKey: string) => {
      const callback = modifiers[modifierKey]

      if (!callback) {
        return
      }

      val = callback(val)
    })
    // 返回经过处理的最终值
    return val
  }
}

在修饰符进行处理过程中,需要考虑同一个 props 存在多个修饰符的情况;需要判断是否有定义修饰符, 对于 v-model 自定义的修饰符,需要在子组件中的 props 定义 ${arg} + Modifiers 名称的属性,默认为一个对象,否则, 无法处理该修饰符

最终完整 hooks 函数

import { ref, computed, getCurrentInstance } from 'vue'

type ModelOptions = {
  props: any
  keys?: string[]
  emit: any
  modifiers?: { [key: string]: (val: any) => any }
}

export function useVModel(options: ModelOptions) {

  const { props, keys = ['modelValue'], emit, modifiers } = options

  const proxyKeys = generateReturnValueType(keys)
  type keysModule = typeof proxyKeys[number]

  const _vm = getCurrentInstance()

  const _emit = _vm?.emit

  let prxoyObj: { [key in keysModule]: any } = {}

  const modifierProgress = modifiers ? execModifiesProgress(props, modifiers) : (key: string, val: any) => val

  keys.forEach((key: string) => {
    const name = `${key}Proxy`

    prxoyObj[name] = computed({
      get() {
        return props[key]
      },
      set(val) {
        _emit!(`update:${key}`, modifierProgress(key, val))
      }
    })
  })

  return prxoyObj as { [key in keysModule]: any }

}

function generateReturnValueType(keys: string[]): string[] {
  return keys.map((key: string) => {
    return `${key}Proxy`
  })
}

function execModifiesProgress(props: any, modifiers: { [key: string]: <T>(value: T) => T }) {
  return (key: string, val: any) => {
    const modifierName = key === 'modelValue' ? `modelModifiers` : `${key}Modifiers`

    const propsModifiers = props[modifierName]
    if (!propsModifiers) {
      return val
    }
    const propsModifiersKeys = Object.keys(propsModifiers)

    console.log(propsModifiersKeys)

    // 考虑存在多个修饰符的情况
    propsModifiersKeys.forEach((modifierKey: string) => {
      const callback = modifiers[modifierKey]

      if (!callback) {
        return
      }

      val = callback(val)
    })


    return val
  }
}

使用实例

<!-- 子组件 -->
<script setup lang="ts">

import { useVModel } from '../utils/useVModel'

const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) },
  content: String,
  contentModifiers: { default: ()=> ({}) }
})

const emit = defineEmits()


const { modelValueProxy, contentProxy } = useVModel({
  props: props,
  emit: emit,
  keys: ['modelValue', 'content'],
  modifiers: {
    aaa: (val): string => {
      return '1' + val
    },
    bbb: (val): string=>{
      return '0' + val 
    },
    ccc:(val): string=>{
      return '2' + val 
    },
    ddd: (val): string=>{
      return 'a' + val
    }
  }
})

</script>

<template>
  <input type="text" v-model="modelValueProxy" />
  <input type="text" v-model="contentProxy" />
</template>
<!-- 父组件 -->
<template>
  <HelloWorld v-model.aaa.bbb.ccc="title" v-model:content.ddd = "content" />
</template>

总结

在封装 hooks ,需要考虑多种情况,例如: props 中哪些是需要进行 v-model 绑定; 需要处理多个修饰符的情况。

上述代码只是一个示例,还存在许多不足和可优化的地方,欢迎各位大佬批评指正。