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' 的方式。