开发后台管理系统,在业务上接触的最多就是表单了,对于使用 Vue 框架进行开发的同学来说,组件库 Element 是肯定会接触的
然而,一旦项目的表单多起来,每个不同的配置,以及多一个字段少一个字段,都要重新写一大段组件代码,显得非常麻烦。或许你会考虑将这些代码封装起来,可是又会发现,封装的表单大多数只在一处地方使用,还不如不封装呢。到底要如何封装,可以让每处使用el-form, 都可以复用相同的组件,减少代码量的同时又具备高度的可定制性?
一般的el-form封装
ini
<MyForm
defValue={searchDefValue}
objectArr={searchObj.value}
inline={true}
ref={searchRef}
labelWidth="80px"
isAsync={false}
v-slots={{
append() {
return (
<el-form-item>
<el-button onClick={handleQuery}>
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-button onClick={resetQuery}>
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
</el-form-item>
)
}
}}
/>
这只是代码的template部分,可以看到这样封装的缺点:
- 每次需要使用el-form的属性时,还需要组件作为一个中间载体在props中再写一遍
- 需要使用el-form的方法,比如表单验证时,还需要先获取组件的ref,然后再通过 searchRef.value && searchRef.value.resetFields() 或者 searchRef.value && searchRef.value.getValue() 这样获取验证和重置,增加了代码的数量
使用组合式函数封装的特点
首先就是代码简洁
主要部分
go
const { MyForm, getValue, resetFields } = useForm(
formConfig,
formData.value,
append
)
- 相比上面普通的封装,代码简直少了一大半,需要使用验证和重置时,直接使用导出的{ getValue, resetFields }就行,再也不用写一段先获取封装组件ref的代码了
- 传入el-form的属性时,再也不用一个个的往myform组件的props里写了
- 模版里看着更加简洁,不需要其它无关的属性,属性全部写在formConfig里,模版中只需要一个标签,把所有的逻辑都放在JS中,更加的紧密清晰


像这么长的一个官网表单的例子,去除掉formConfig配置的部分,实际的代码量就这么多

表单的配置
配置方面,和普通封装的数据差不多,唯一不同的是,el-form的属性,写在了formConfig里,el-form-item的属性写在了formConfig.item下面
php
const formConfig = reactive({
labelWidth: '120px', 标签的长度
itemWidth: '200px',
inline: false, 行内表单模式
labelPosition: 'right', 表单域标签的位置
size: 'default', 表单内组件的尺寸
item: [
{
label: 'Activity name',
prop: 'name',
type: 'text',
rule: [...] 单独的验证规则
}
...
]
rules: [... 表单验证规则,可以自定义校验规则
})
创建el-form标签
我们的useForm因为是ts文件,所以创建节点的时候只能使用createVNode
createVNode的用法:createVNode(标签名, {属性,DOM事件}, 子节点)
首先创建el-form节点
csharp
const MyForm = (prop: any, context: any) => {
if (!formConfig.item.length) return createVNode('')
props.value = prop
return createVNode(
ElForm,
{
ref: formRef,
...context.attrs, 通过标签传入的属性
model: formValue, 绑定的表单数据对象
rules: rules, 表单验证规则
...createFormAttrs()
},
[createItem(), appendDom()]
)
}
createFormAttrs方法是用来返回节点的属性,就是formConfig对象的第一层,因为item不是form节点的属性,所以我们使用扩展运算把item属性去掉,以及rules
javascript
const createFormAttrs = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { item, rules, ...rest } = formConfig // 返回formConfig中除了item和rules之外的所有属性
return rest
}
创建el-form-item节点
这里没什么好说的,跟创建el-form节点一样
php
if (!formConfig.item.length) return
return formConfig.item.map((item: any) => {
return createVNode(
ElFormItem,
{
label: item.label,
prop: item.prop,
rules: item.rule
},
[createItemInput(item)]
)
})
创建用户输入的组件
首先有插槽,直接返回插槽,然后设置一个共同的属性defaultAttrs,包含了输入组件都有的 v-model, disabled, 共同的样式
arduino
const createItemInput = (item: ObjectForm) => {
if (item.slots) {
return item.slots(item)
}
const defaultAttrs = {
modelValue: formValue[item.prop],
'onUpdate:modelValue': (newValue: any) => {
formValue[item.prop] = newValue
},
disabled: item.disabled,
style: {
width: item.width ? item.width : formConfig.itemWidth
}
}
...
})
接下来,就是判断item的type,判断是什么输入组件
go
const isCheckboxOrRadio = item.type
? ['checkbox', 'radio'].includes(item.type)
: false
if (item.type === 'switch') {
return createVNode(ElSwitch, {
...defaultAttrs
})
} else if (item.type === 'textarea') {
return createVNode(ElInput, {
type: 'textarea',
...defaultAttrs
})
} else if (item.type === 'number') {
return createVNode(ElInputNumber, {
...defaultAttrs,
'controls-position': 'right',
'step-strictly': item.strictly
})
} else if (isCheckboxOrRadio) {
const options = item.options?.map((option: any) =>
createVNode(
item.type === 'checkbox' ? ElCheckbox : ElRadio,
{ label: option.value },
[option.label]
)
)
const Component =
item.type === 'checkbox' ? ElCheckboxGroup : ElRadioGroup
return createVNode(Component, { ...defaultAttrs }, options)
} else if (item.type === 'text' || !item.type || item.type === 'password') {
return createVNode(ElInput, {
...defaultAttrs,
placeholder: item.placeholder
? item.placeholder
: `请输入${item.label}`,
maxlength: item.maxlength ? item.maxlength : null,
type: item.type === 'password' ? 'password' : 'text',
'auto-complete': 'off',
'show-word-limit': true
})
}
下拉框的数据获取
select下拉框的数据,有时候需要从后台获取,如果需要获取的数据比较多,在页面一个个调接口会比较麻烦 所有接口的名称和参数也加入到了formConfig中
python
import { getOption } from '@/api'
{
label: '地址',
prop: 'address',
type: 'select',
apiFun: getOption,
params: ''
},
然后在组件中遍历一遍有接口的item,获取接口数据塞到options里面
ini
const initDefault = async () => {
if (!formConfig.item.length) return
for (let i = 0; i < formConfig.item.length; i++) {
const item = formConfig.item[i]
if (item.apiFun) {
item.loading = true
const res = await item.apiFun(item.params)
item.loading = false
item.options = res.data
}
}
}
initDefault()
useForm完整代码
useForm.ts
import {
ElForm,
ElFormItem,
ElSwitch,
ElInput,
ElInputNumber,
ElCheckbox,
ElCheckboxGroup,
ElRadioGroup,
ElRadio,
ElSelect,
ElOption,
ElDatePicker
} from 'element-plus'
import { createVNode, reactive, ref, VNode, Ref } from 'vue'
export interface ObjectForm {
loading?: boolean
type?:
| 'switch'
| 'textarea'
| 'number'
| ''
| 'text'
| 'checkbox'
| 'radio'
| 'multipleselect'
| 'select'
| 'datetime'
| 'daterange'
| 'upload'
| 'group'
| 'password'
| string
rule?: rule[]
hidden?: boolean
prop: string
label: string
disabled?: boolean
strictly?: number
multiple?: boolean
stepStrictly?: number
placeholder?: string
width?: string
clearable?: boolean
filterable?: boolean
defaultProp?: { label: string; value: string }
maxlength?: number
options?: Array<{ label: string; value: string | number }>
valueFormat?: string // 时间值的格式化
format?: string // 时间显示的格式化
remoteMethod?: (value: string, callback: (list: any[]) => void) => any
disabledDate?: (time: any) => boolean
apiFun?: (params: any) => Promise<any>
params?: any
slots?: any
}
export type rule = {
validator?: any
required?: boolean
message?: string
pattern?: RegExp
trigger?: 'blur' | 'change' | string
}
type RulesType = Record<string, Array<rule>>
export interface FormConfig {
itemWidth?: string
labelWidth?: string
inline?: boolean
labelPosition?: string | 'top' | 'left' | 'right'
disabledAll?: boolean
rules: RulesType
item: ObjectForm[]
}
interface FormProps {
disabledAll?: boolean
labelWidth?: string
labelPosition?: string
itemWidth?: string
inline?: boolean
}
export default (
formConfig: FormConfig,
formData: { [key: string]: any },
append?: () => VNode
) => {
const formValue =
reactive(JSON.parse(JSON.stringify(formData))) || reactive({})
const rules = formConfig.rules || {}
const setRules = (item: ObjectForm) => {
if (item.rule && !item.hidden) {
rules[item.prop] = item.rule
}
}
const initDefault = async () => {
if (!formConfig.item.length) return
for (let i = 0; i < formConfig.item.length; i++) {
const item = formConfig.item[i]
if (item.apiFun) {
item.loading = true
const res = await item.apiFun(item.params)
item.loading = false
item.options = res.data
}
setRules(item)
}
}
initDefault()
const formRef = ref()
const resetFields = () => {
formRef.value.resetFields()
}
const getValue = async () => {
if (Object.keys(rules).length > 0) {
const res = await formRef.value.validate()
if (res) {
return formValue
}
return null
}
return formValue
}
const props: Ref<FormProps> = ref({})
const MyForm = (prop: any, context: any) => {
if (!formConfig.item.length) return createVNode('')
props.value = prop
return createVNode(
ElForm,
{
ref: formRef,
...context.attrs,
model: formValue,
rules: rules,
...createFormAttrs()
},
[createItem(), appendDom()]
)
}
const createFormAttrs = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { item, rules, ...rest } = formConfig
return rest
}
const createItem = () => {
if (!formConfig.item.length) return
return formConfig.item.map((item: any) => {
return createVNode(
ElFormItem,
{
label: item.label,
prop: item.prop,
rules: item.rule
},
[createItemInput(item)]
)
})
}
const appendDom = () => {
return createVNode(ElFormItem, {}, [append && append()])
}
const createItemInput = (item: ObjectForm) => {
if (item.slots) {
return item.slots(item)
}
const defaultAttrs = {
modelValue: formValue[item.prop],
'onUpdate:modelValue': (newValue: any) => {
formValue[item.prop] = newValue
},
disabled: item.disabled,
style: {
width: item.width ? item.width : formConfig.itemWidth
}
}
const isCheckboxOrRadio = item.type
? ['checkbox', 'radio'].includes(item.type)
: false
if (item.type === 'switch') {
return createVNode(ElSwitch, {
...defaultAttrs
})
} else if (item.type === 'textarea') {
return createVNode(ElInput, {
type: 'textarea',
...defaultAttrs
})
} else if (item.type === 'number') {
return createVNode(ElInputNumber, {
...defaultAttrs,
'controls-position': 'right',
'step-strictly': item.strictly
})
} else if (isCheckboxOrRadio) {
const options = item.options?.map((option: any) =>
createVNode(
item.type === 'checkbox' ? ElCheckbox : ElRadio,
{ label: option.value },
[option.label]
)
)
const Component =
item.type === 'checkbox' ? ElCheckboxGroup : ElRadioGroup
return createVNode(Component, { ...defaultAttrs }, options)
} else if (item.type === 'text' || !item.type || item.type === 'password') {
return createVNode(ElInput, {
...defaultAttrs,
placeholder: item.placeholder
? item.placeholder
: `请输入${item.label}`,
maxlength: item.maxlength ? item.maxlength : null,
type: item.type === 'password' ? 'password' : 'text',
'auto-complete': 'off',
'show-word-limit': true
})
} else if (item.type === 'select') {
return createVNode(
ElSelect,
{
...defaultAttrs,
clearable: item.clearable || true,
multiple: item.multiple,
filterable: item.filterable,
'default-first-option': true,
'v-loading': item.loading,
remoteMethod: async (value: any) => {
if (item.remoteMethod) {
item.loading = true
item.remoteMethod(value, (list) => {
item.options = reactive(list)
item.loading = false
})
}
}
},
item.options?.map((option: any, index: number) => {
return createVNode(ElOption, {
key: index,
label: item.defaultProp
? option[item.defaultProp.label]
: option.label,
value: item.defaultProp
? option[item.defaultProp.value]
: option.value,
disabled: option.disabled ? option.disabled : false
})
})
)
} else if (item.type === 'datetime') {
return createVNode(ElDatePicker, {
...defaultAttrs,
format: item.format || 'YYYY-MM-DD HH:mm:ss',
'value-format': item.valueFormat,
placeholder: '选择日期时间',
'disabled-date': item.disabledDate
})
} else if (item.type === 'daterange') {
return createVNode(ElDatePicker, {
type: 'daterange',
...defaultAttrs,
format: item.format || 'YYYY-MM-DD HH:mm:ss',
'value-format': item.valueFormat,
'range-separator': '至',
'start-placeholder': '开始日期',
'end-placeholder': '结束日期'
})
}
}
return {
MyForm,
getValue,
resetFields,
formRef
}
}
使用
typescript
import {
defineComponent,
reactive,
ref
} from 'vue'
import useForm from '@/hooks/useForm'
export default defineComponent({
props: {},
emits: [''],
setup() {
const validatePass = (_rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('Please input the password'))
} else {
callback() // 校验通过,调用 callback 继续校验
}
}
const formConfig = reactive({
labelWidth: '120px',
itemWidth: '200px',
inline: false,
labelPosition: 'right',
size: 'default',
item: [
{
label: 'Activity name',
prop: 'name',
type: 'text'
},
{
label: 'slots',
prop: 'slots',
slots: (scope: any) => {
return (
<span>
<el-button
onClick={() => {
console.log(scope)
}}
>
插槽
</el-button>
</span>
)
}
},
{
label: 'age',
prop: 'age',
rule: [{ required: true, message: 'age is required' }]
},
{
label: 'Activity zone',
prop: 'region',
placeholder: 'please select your zone',
type: 'select',
options: [
{ label: 'Zone one', value: 'shanghai' },
{ label: 'Zone two', value: 'beijing' }
]
},
{
label: '密码',
prop: 'pass',
type: 'password'
},
{
label: 'Activity time',
prop: 'date',
width: '200px',
type: 'datetime'
},
{ label: 'Instant delivery', prop: 'delivery', type: 'switch' },
{
label: 'Instant type',
prop: 'type',
type: 'checkbox',
options: [{ label: 'Online activities', value: 'type1' }]
},
{
label: 'Resources',
prop: 'resource',
type: 'radio',
options: [
{ label: 'Sponsor', value: 'type1' },
{ label: 'Venue', value: 'type2' }
]
},
{
label: 'Activity form',
prop: 'desc',
type: 'textarea'
}
],
rules: {
name: [
{
required: true,
message: 'Please input Activity name',
trigger: 'blur'
},
{
min: 3,
max: 5,
message: 'Length should be 3 to 5',
trigger: 'blur'
}
],
pass: [{ required: true, validator: validatePass, trigger: 'blur' }]
}
})
const formData = ref({})
const append = () => {
return (
<>
<el-button type="primary" onClick={onSubmit}>
Create
</el-button>
<el-button onClick={Cancel}>Cancel</el-button>
</>
)
}
const onSubmit = () => {
const searchValue: Promise<Record<string, any>> = getValue()
console.log(searchValue)
searchValue.then((res) => {
console.log(res)
})
}
const Cancel = () => {
resetFields()
formData.value = {}
}
const { MyForm, getValue, resetFields } = useForm(
formConfig,
formData.value,
append
)
return () => {
return (
<div style="padding: 20px 20px">
<MyForm />
</div>
)
}
}
})