用Typescript 的方式封装Vue3的表单绑定,支持防抖等功能。
阅读原文时间:2022年06月23日阅读:1

Vue3 的父子组件传值、绑定表单数据、UI库的二次封装、防抖等,想来大家都很熟悉了,本篇介绍一种使用 Typescript 的方式进行统一的封装的方法。

基础使用方法

Vue3对于表单的绑定提供了一种简单的方式:v-model。对于使用者来说非常方便,v-model="name" 就可以了。

但是当我们要自己做一个组件的时候,就有一点麻烦:

https://staging-cn.vuejs.org/guide/components/events.html#usage-with-v-model

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

需要我们定义 props、emit、input 事件等。

如果我们想对UI库进行封装的话,就又麻烦了一点点:

https://staging-cn.vuejs.org/guide/components/events.html#usage-with-v-model

// <script setup>
import { computed } from 'vue'

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

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

<template>
  <el-input v-model="value" />
</template>

由于 v-model 不可以直接用组件的 props,而 el-input 又把原生的 value 变成了 v-model 的形式,所以需要使用 computed 做中转,这样代码就显得有点繁琐。

如果考虑防抖功能的话,代码会更复杂一些。

代码为啥会越写越乱?因为没有及时进行重构和必要的封装!

建立 vue3 项目

情况讲述完毕,我们开始介绍解决方案。

首先采用 vue3 的最新工具链:create-vue, 建立一个支持 Typescript 的项目。

https://staging-cn.vuejs.org/guide/typescript/overview.html

先用 Typescript 的方式封装一下 v-model,然后再采用一种更方便的方式实现需求,二者可以对照看看哪种更适合。

v-model 的封装

我们先对 v-model、emit 做一个简单的封装,然后再加上防抖的功能。

  • ref-emit.ts

    import { customRef } from 'vue'

    /**

    • 控件的直接输入,不需要防抖。负责父子组件交互表单值
    • @param props 组件的 props
    • @param emit 组件的 emit
    • @param key v-model 的名称,用于 emit
      */
      export default function emitRef
      (
      props: T,
      emit: (event: any, …args: any[]) => void,
      key: K
      ) {
      return customRef((track: () => void, trigger: () => void) => {
      return {
      get(): T[K] {
      track()
      return props[key] // 返回 modelValue 的值
      },
      set(val: T[K]) {
      trigger()
      // 通过 emit 设置 modelValue 的值
      emit(update:${key.toString()}, val)
      }
      }
      })
      }
  • K keyof T

    因为属性名称应该在 props 里面,所以使用 keyof T 的方式进行约束。

  • T[K]

    可以使用 T[K] 作为返回类型。

  • key 的默认值

    尝试了各种方式,虽然可以运行,但是TS会报错。可能是我打开的方式不对吧。

  • customRef

    为啥没有用 computed?因为后续要增加防抖功能。

    在 set 里面使用 emit 进行提交,在 get 里面获取 props 里的属性值。

  • emit 的 type

    emit: (event: any, ...args: any[]) => void,各种尝试,最后还是用了any。

这样简单的封装就完成了。

官网提供的防抖代码,对应原生 input 是好用的,但是用在 el-input 上面就出了一点小问题,所以只好修改一下:

  • ref-emit-debounce.ts

    import { customRef, watch } from 'vue'

    /**

    • 控件的防抖输入,emit的方式

    • @param props 组件的 props

    • @param emit 组件的 emit

    • @param key v-model的名称,默认 modelValue,用于emit

    • @param delay 延迟时间,默认500毫秒
      */
      export default function debounceRef
      (
      props: T,
      emit: (name: any, …args: any[]) => void,
      key: K,
      delay = 500
      ) {
      // 计时器
      let timeout: NodeJS.Timeout
      // 初始化设置属性值
      let _value = props[key]

      return customRef((track: () => void, trigger: () => void) => {
      // 监听父组件的属性变化,然后赋值,确保响应父组件设置属性
      watch(() => props[key], (v1) => {
      _value = v1
      trigger()
      })

      return {
      get(): T[K] {
      track()
      return _value
      },
      set(val: T[K]) {
      _value = val // 绑定值
      trigger() // 输入内容绑定到控件,但是不提交
      clearTimeout(timeout) // 清掉上一次的计时
      // 设置新的计时
      timeout = setTimeout(() => {
      emit(update:${key.toString()}, val) // 提交
      }, delay)
      }
      }
      })
      }

  • timeout = setTimeout(() => {})

    实现防抖功能,延迟提交数据。

  • let _value = props[key]

    定义一个内部变量,在用户输入字符的时候保存数据,用于绑定组件,等延迟后再提交给父组件。

  • watch(() => props[key], (v1) => {})

    监听属性值的变化,在父组件修改值的时候,可以更新子组件的显示内容。

    因为子组件的值对应的是内部变量 _value,并没有直接对应props的属性值。

这样就实现了防抖的功能。

直接传递 model 的方法。

一个表单里面往往涉及多个字段,如果每个字段都使用 v-model 的方式传递的话,就会出现“中转”的情况,这里的“中转”指的是 emit,其内部代码比较复杂。

如果组件嵌套比较深的话,就会多次“中转”,这样不够直接,也比较繁琐。

另外如果需要 v-for 遍历表单子控件的话,也不方便处理多 v-model 的情况。

所以为什么不把一个表单的 model 对象直接传入子组件呢?这样不管嵌套多少层组件,都是直接对地址进行操作,另外也方便处理一个组件对应多个字段的情况。

当然,也有一点麻烦的地方,需要多传入一个属性,记录组件要操作的字段名称。

组件的 props 的类型是 shallowReadonly,即根级只读,所以我们可以修改传入的对象的属性。

  • ref-model.ts

    import { computed } from 'vue'

    /**

    • 控件的直接输入,不需要防抖。负责父子组件交互表单值。

    • @param model 组件的 props 的 model

    • @param colName 需要使用的属性名称
      */
      export default function modelRef (model: T, colName: K) {

      return computed({
      get(): T[K] {
      // 返回 model 里面指定属性的值
      return model[colName]
      },
      set(val: T[K]) {
      // 给 model 里面指定属性赋值
      model[colName] = val
      }
      })
      }

我们也可以使用 computed 来做中转,还是用 K extends keyof T做一下约束。

  • ref-model-debounce.ts

    import { customRef, watch } from 'vue'

    import type { IEventDebounce } from '../types/20-form-item'

    /**

    • 直接修改 model 的防抖

    • @param model 组件的 props 的 model

    • @param colName 需要使用的属性名称

    • @param events 事件集合,run:立即提交;clear:清空计时,用于汉字输入

    • @param delay 延迟时间,默认 500 毫秒
      */
      export default function debounceRef (
      model: T,
      colName: K,
      events: IEventDebounce,
      delay = 500
      ) {

      // 计时器
      let timeout: NodeJS.Timeout
      // 初始化设置属性值
      let _value: T[K] = model[colName]

      return customRef((track: () => void, trigger: () => void) => {
      // 监听父组件的属性变化,然后赋值,确保响应父组件设置属性
      watch(() => model[colName], (v1) => {
      _value = v1
      trigger()
      })

      return {
      get(): T[K] {
      track()
      return _value
      },
      set(val: T[K]) {
      _value = val // 绑定值
      trigger() // 输入内容绑定到控件,但是不提交
      clearTimeout(timeout) // 清掉上一次的计时
      // 设置新的计时
      timeout = setTimeout(() => {
      model[colName] = _value // 提交
      }, delay)
      }
      }
      })
      }

对比一下就会发现,代码基本一样,只是取值、赋值的地方不同,一个使用 emit,一个直接给model的属性赋值。

那么能不能合并为一个函数呢?当然可以,只是参数不好起名,另外需要做判断,这样看起来就有点不易读,所以还是做两个函数直接一点。

我比较喜欢直接传入 model 对象,非常简洁。

开始日期、结束日期,可以分为两个控件,也可以用一个控件,如果使用一个控件的话,就涉及到类型转换,字段对应的问题。

所以我们可以再封装一个函数。

  • ref-model-range.ts

    import { customRef } from 'vue'

    interface IModel {
    [key: string]: any
    }

    /**

    • 一个控件对应多个字段的情况,不支持 emit

    • @param model 表单的 model

    • @param arrColName 使用多个属性,数组
      */
      export default function range2Ref
      (
      model: T,
      …arrColName: K[]
      ) {

      return customRef>((track: () => void, trigger: () => void) => {
      return {
      get(): Array {
      track()
      // 多个字段,需要拼接属性值
      const tmp: Array = []
      arrColName.forEach((col: K) => {
      // 获取 model 里面指定的属性值,组成数组的形式
      tmp.push(model[col])
      })
      return tmp
      },
      set(arrVal: Array) {
      trigger()
      if (arrVal) {
      arrColName.forEach((col: K, i: number) => {
      // 拆分属性赋值,值的数量可能少于字段数量
      if (i < arrVal.length) { model[col] = arrVal[i] } else { model[col] = '' } }) } else { // 清空选择 arrColName.forEach((col: K) => {
      model[col] = '' // undefined
      })
      }
      }
      }
      })
      }

  • IModel

    定义一个接口,用于约束泛型 T,这样 model[col] 就不会报错了。

这里就不考虑防抖的问题了,因为大部分情况都不需要防抖。

使用方法

封装完毕,在组件里面使用就非常方便了,只需要一行即可。

先做一个父组件,加载各种子组件做一下演示。

  • js

    // v-model 、 emit 的封装
    const emitVal = ref('')
    // 传递 对象
    const person = reactive({name: '测试', age: 111})
    // 范围,分为两个属性
    const date = reactive({d1: '2012-10-11', d2: '2012-11-11'})

  • template

    emit 的封装


    model的封装


    model 的范围取值

我们做一个子组件:

  • 10-emit.vue

    //