【vue3】手撸 defineModel 实现防抖、多字段转换等功能

747 阅读8分钟

vue3.4 的 defineModel 很好很强大,只是有两个小功能实现起来好像有点麻烦,一个是防抖,一个是后端传来的属性转换成组件需要的数组。
基于 defineModel 实现防抖,没找到好方法,至于转换的当然是没有问题,只是有点繁琐,所以不如手撸一套hooks来统一管理。

前篇:【vue3】defineModel的多种打开方式:多Model、ts、泛型、修饰符等

需求分析

首先要有个需求,否则就是瞎折腾了,那么到底是什么需求呢?不知道大家有没有做过低代码的表单控件。
我们需要一个通用的表单组件,低代码里用的那种,表单组件的需求如下:

  • 组件需要的属性值都放在 JSON 里面
  • 表单项需要支持 v-for 遍历
  • 表单的 Model,使用 reactive 的形式整体传入

表单项需要的 Model 的类型以及需求:

  • 基础类型(可以忽略)
    • 不需要防抖
    • 需要防抖
  • 引用类型(reactive)的属性
    • 不需要防抖
    • 需要防抖
    • 多字段转换
      • 范围:开始日期,结束日期等
      • 联动:省市区县的联动等

设计类型和接口

以前 defineProps 的类型定义必须在 script setup 内部编写,不能放在外面,后来 vue3.3 更新了,可以把类型定义放在单独的文件里面,这样就方便我们复用 Props 的定义。

  • 我们来定义一个Props的类型:
/**
 * 表单里 input 这类组件的 props,含 meta
 * * meta: TFormChildMeta,input 这一类的需要的 meta
 * * model: T,表单的 model,含义多个属性
 * * modelModifiers:接收 v-model 的修饰符
 * * modelValue:v-model 用的 Model
 * * colName:可以直接传递 字段名称
 */
export type TFormChildProps = {
  /**
   * 接收 v-model 的修饰符
   */
  modelModifiers?: { default: () =>  {[key: string]: boolean} }
  /**
   * v-model 使用
   */
  modelValue?: any,
  /**
   * input 这一类的需要的 meta
   */
  meta?: TFormChildMeta,
  /**
   * 可以直接传递字段名称
   */
  colName?: '',
  /**
   * 表单的 model,含义多个属性
   */
  model?: any,
  /**
   * 子控件的扩展属性
   */
  [key: string]: any
}

以前不喜欢 Typescript,但是使用之后发现:真香!至少可以当文档用。

基础类型

基础类型分为需要防抖和不需要防抖两种情况,都比较简单。

不防抖的话用 computed 也可以,不过为了统一返回类型,这里都使用 customerRef,其实 defineModel 内部使用的也是 customerRef。

不需要防抖

不需要防抖就比较简单了,用 customerRef 的 get 和set 做中转:

  • get 里面返回 props.xxx 的值;
  • set 里面用 emit 提交申请。
import { customRef, getCurrentInstance } from 'vue'

/**
 * 控件的直接输入,不需要防抖。负责父子组件交互表单值
 * @param props 组件的 props
 * @param emit 组件的 emit
 * @param name v-model 的名称,默认 modelValue,用于 emit
 */
export default function emitRef<T, K extends keyof T & string>
(
  props: T,
  name: K
) {
  // 获取 emit
  const i = getCurrentInstance()

  const _name = name ?? 'modelValue'
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    return {
      get(): T[K] {
        track()
        return props[_name] // 返回 modelValue 的值
      },
      set(val: T[K]) {
        trigger()
        // 通过 emit 提交申请
        i?.emit(`update:${_name}`, val)
      }
    }
  })
}

通过 getCurrentInstance() 获取 emit,这样就不需要通过参数的方式传递了,比较方便。

使用方式:

  • 子组件
  // 定义 props 和 emit
  const props = defineProps<TFormChildProps<T>>()
  // 获取一个value
  const value = emitRef(props, 'modelValue')
  // template
  <el-input v-model="value" placeholder=""></el-input>

测试了一下,其实不使用 defineEmits 也是一样可以运行的。

  • 父组件
  const person = reactive({
    name: 'jyk',
    age:22,
    startDate: '',
    endDate: '',
    province: '',
    city:'',
    districts: ''
  })
<sonEmit v-model="person.name"></sonEmit>

比 defineModel 要麻烦一些,需要定义 props 和 emit。

需要防抖

如果需要防抖的话,就稍微麻烦一点,官网里面 customerRef 的例子就是一个防抖的代码,用在 input 上面没啥问题,但是用在 el-input 上面就会出现“卡顿”的情况,所以需要修改一下。

/**
 * 控件的防抖输入
 * @param props 组件的 props
 * @param emit 组件的 emit
 * @param key v-model的名称,默认 modelValue,用于 emit
 * @param delay 延迟时间,默认500毫秒
 */
export default function debounceEmit<T, K extends keyof T> 
(
  props: T,
  name: K,
  delay = 500
) {
  // 获取 emit
  const i = getCurrentInstance()

  const _name = (name) ?? 'modelValue'

  // 计时器
  let timeout: number
  // 初始化设置属性值
  let _value = props[_name]

  /**
   * 立即向父组件提交申请
   */
  const submit =  () => {
    // 清掉上一次的计时
    clearTimeout(timeout)
    // 立即提交
    i?.emit(`update:${_name.toString()}`, _value)
  }

  /**
   * 清掉上次计时,用于汉字的连续输入
   * @param e 
   */
  const clear = (e: any) => {
    if (e.key !== 'Backspace') {
      clearTimeout(timeout) // 输入汉字时清除上次计时
    }
  }

  const value = customRef<T[K]>((track: () => void, trigger: () => void) => {
    // 监听父组件的属性变化,然后赋值,确保响应父组件设置属性
    watch(() => props[_name], (v1) => {
      if (!Object.is(_value, v1)) {
        // 有变化,更新
        _value = v1
      }
      trigger()
    })

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

  return {
    value,
    submit, // 不等了,立即提交
    clear // 清空timeOut
  }
}

和官网的例子相比,get 部分没有变化,set 部分需要先给中间变量赋值,然后调用 trigger,在 setTimeout 里面再提交给父组件。

另外加上一个 watch,当父组件里修改数据的时候,同步子组件的数据。这种方式和 defineModel 内部的处理方式比较相似。

使用方式:

  • 子组件
  // 定义 props 和 emit
  const props = defineProps<TFormChildProps<T>>()
  // 获取一个 Model、value
  const value = debounceRef(props, 'modelValue', 600)
  <el-input v-model="value" placeholder=""></el-input>
  • 父组件
  <sonEmitDebounce v-model="person.name"></sonEmitDebounce>

使用方式一样,多了一个防抖的时间间隔。

引用类型,reactive

这里除了防抖之外,还需要考虑属性和数组的转换的问题。

不需要防抖

这里就很简单了和基础类型基本一致:

  • get 里面返回 Proxy 的 get;
  • set 里面用 Proxy 的 set 提交申请。
/**
 * 控件的直接输入,不需要防抖。负责父子组件交互表单值。
 * @param model 组件的 props 的 model
 * @param colName 需要使用的属性名称
 */
export default function modelRef<T, K extends keyof T>
(
  model: T,
  colName: K
) {
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    return {
      get(): T[K] {
        track()
        // 返回 model 里面指定属性的值
        return model[colName]
      },
      set (val: T[K]) {
        trigger()
        // 调用 Proxy 的 set 实现赋值
        model[colName] = val
      }
    }
  })
}

使用方式:

  • 子组件
  const props = defineProps<TProps<T>>()
  const objModi = props.modelModifiers
  const arr = Object.keys(objModi as object)
  const colName = arr[0] as K // keyof T
  const val = modelRef(props, 'modelValue', colName)
  
  <el-input v-model="val" placeholder=""></el-input>
  • 父组件
<sonReactive v-model.name="person"></sonReactive>

可以使用修饰符传递字段名称,这样就比较简洁了,不用多个 Props。

不知道大家有没有发现,这里有个小问题。

需要防抖

原理也是一样的,用 Proxy 替换一下即可。

/**
 * 直接修改 model 的防抖
 * @param model 组件的 props 的 model
 * @param colName 需要使用的属性名称
 * @param delay 延迟时间,默认 500 毫秒
 */
export default function debounceModel<T, K extends keyof T>
(
  model: T,
  colName: K,
  delay = 500
) {

  // 计时器
  let timeout: number
  // 初始化设置属性值
  let _value: T[K] = model[colName]
  
  const value = customRef<T[K]>((track: () => void, trigger: () => void) => {
    // 监听父组件的属性变化,然后赋值,确保响应父组件设置属性
    watch(() => model[colName], (v1) => {
      if (!Object.is(_value, 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 // 用 Proxy 的 set 提交
        }, delay)
      }
    }
  })

  return {
    value,
  }
}

和 emit 的防抖大同小异,既然差不多那么为啥写成两个函数呢?因为还有几个小地方不一样,如果弄成一个的话,需要写各种if,看起来比较乱糟。

转换

转换有两种:日期范围、多级联动。不过从代码的角度来看,都是一样的。

/**
 * 一个控件对应多个字段的情况,不支持 emit
 * @param model 表单的 model
 * @param arrColName 使用多个属性,数组
 */
export default function rangeRef<T extends IModel, K extends keyof T>
(
  model: T,
  ...arrColName: Array<K>
) {
  return customRef<Array<any>>((track: () => void, trigger: () => void) => {
    return {
      get(): Array<any> {
        track()
        // 多个字段,需要拼接属性值
        const tmp: Array<any> = []
        arrColName.forEach((col: K) => {
          // 获取 model 里面指定的属性值,组成数组的形式
          tmp.push(model[col])
        })
        return tmp
      },
      set (arrVal: Array<any>) {
        trigger()
        if (arrVal) {
          arrColName.forEach((col: K, i: number) => {
            // 拆分属性赋值,值的数量可能少于字段数量
            if (i < arrVal.length) {
              model[col] = arrVal[i]
            } else {
              if (arrVal.length > 0) {
                model[col] = '' as T[K]
              }
            }
          })
        } else {
          // 清空选择
          arrColName.forEach((col: K) => {
            model[col] = '' as T[K]
          })
        }
      }
    }
  })
}

使用方式:

  • 子组件
  const props = defineProps<TProps<T>>()
  const objModi = props.modelModifiers
  const arr = Object.keys(objModi as object)
  const val = rangeRef(props, 'modelValue', ...arr)
  <el-date-picker
    v-model="val"
    type="daterange"
  />
  • 父组件
<sonRetRang v-model.startDate.endDate="person"></sonRetRang>

创建工厂

看看上面的使用方式,就会发现一个问题:针对不同的情况需要调用不同的 hooks,参数和返回还都不太一样。
如果每次使用的时候,都需要各种判断,是不是有点繁琐,记不住咋办?
其实我们可以使用工厂模式的思路。

我们写一个 controller 来统一创建方式,就简单多了。

/**
 * 表单子控件的控制函数
 * @param props 组件的 props,T 对应 model 的类型
 * @returns 返回一个 Ref<T>。
 */
export default function itemController<T extends object>(
  props: TFormChildProps<T>
) {

  // 根据修饰符、props的属性等判断情况。
  const {
    isMoreColumn, // 判断是否多字段(需要转换)
    colName, // 单字段的情况,字段名
    colNameArray, // 多字段的情况,字段名的数组
    delay, // 防抖间隔
    modelName // Model 的名称
  } = init<T>(props)

  // 判断是否需要多字段(无防抖)
  if (isMoreColumn) {
    // 一个字段对应多个字段的情况,不防抖
    return modelRangeRef(props, modelName, ...colNameArray)
  } else {
    if (delay > 0) { // 需要防抖
      return modelDebounceRef(props, modelName, colName, delay)
    } else { // 不需要防抖
      return modelRef(props, modelName, colName)
    }
  }

这样在子组件里面只需要传入 props 即可,具体调用哪个函数处理,就不用操心了。

为啥不喜欢 emit 呢?没啥大用还得去定义,太麻烦。直接使用 reactive 不是很方便吗?

在组件里面使用

使用比较简单:

  • 定义 props
  • 使用工厂模式创建一个 model。

defineModel 宏可以在内部定义 props、emit、customerRef等,我们自己定义的 hooks,就只能使用 customerRef 了,其他的还需要在组件里面定义。
从这个角度来看,便捷性还是比不上 defineModel 的。

  • 子组件:
  import { itemController } from 'nf-ui-core'
  import type { TFormChildProps } from 'nf-ui-core'
  const props = defineProps<TFormChildProps<T>>()
  const value = itemController(props)
  
  <el-input v-model="value" placeholder=""></el-input>
  • 父组件:
  const person = reactive({
    name: 'jyk',
    age:22,
    startDate: '',
    endDate: '',
    province: '',
    city:'',
    districts: ''
  })
  
  const meta = reactive({
    colName: 'age',
    delay:0
  })
  
  <h1>使用修饰符指定字段名称 </h1>
  <sonController v-model.name="person" ></sonController>
  <hr>
  <h1>使用修饰符指定字段名称和防抖 </h1>
  <sonController v-model.age.1000="person" ></sonController>
  
  <h1>仅使用 props,支持 v-for </h1>
  <sonControllerSelect :model="person" :meta="meta"  ></sonControllerSelect>

基本差不多,两行搞定。

v-model 的修饰符不支持动态,所以无法被 v-for。 所以想要支持 v-for,就只能使用 v-bind='json' 的方式。