在后台项目里,表单是最容易产生重复代码的区域:
同样的输入框、同样的校验、同样的抽屉弹窗,不同页面反复写,改一处要改一片。
这次我在 daotu-cloud-vue3 里做了一个轻量级表单封装:src/components/FormControl。目标很直接:统一控件行为 + 配置化渲染 + 保留业务灵活性。
1. 组件全景:一层分发 + 一层容器 + 一组原子控件
index.js 统一导出所有能力,调用方不关心内部细节:
index.jsLines 1-23
/**
* 常用表单控件封装(Element Plus),全局注册见 registerGlobal.js
*
* FormField 统一入口,as 选择 input | password | textarea | select | switch | date | daterange | ...
* ...
*/
export { default as FormField } from './FormField.vue'
export { default as FormInput } from './FormInput.vue'
export { default as FormPasswordInput } from './FormPasswordInput.vue'
export { default as FormTextarea } from './FormTextarea.vue'
export { default as FormSelect } from './FormSelect.vue'
export { default as FormSwitch } from './FormSwitch.vue'
export { default as FormDatePicker } from './FormDatePicker.vue'
export { default as FormStatic } from './FormStatic.vue'
export { default as FormSchemaDrawer } from './FormSchemaDrawer.vue'
并且在全局注册入口里直接注册成全局组件:
registerGlobal.jsLines 56-64
import FormField from '@/components/FormControl/FormField.vue'
import FormInput from '@/components/FormControl/FormInput.vue'
import FormPasswordInput from '@/components/FormControl/FormPasswordInput.vue'
import FormTextarea from '@/components/FormControl/FormTextarea.vue'
import FormSelect from '@/components/FormControl/FormSelect.vue'
import FormSwitch from '@/components/FormControl/FormSwitch.vue'
import FormDatePicker from '@/components/FormControl/FormDatePicker.vue'
import FormSchemaDrawer from '@/components/FormControl/FormSchemaDrawer.vue'
app.component('FormField', FormField)
app.component('FormInput', FormInput)
app.component('FormPasswordInput', FormPasswordInput)
app.component('FormTextarea', FormTextarea)
app.component('FormSelect', FormSelect)
app.component('FormSwitch', FormSwitch)
app.component('FormDatePicker', FormDatePicker)
app.component('FormSchemaDrawer', FormSchemaDrawer)
2. 核心思想:FormField 做统一分发
FormField 的价值是"页面只写类型,不写实现"。
比如 as="select"、as="date",它自动映射到底层组件。
const TAG_BY_AS = {
input: 'FormInput',
forminput: 'FormInput',
password: 'FormPasswordInput',
formpasswordinput: 'FormPasswordInput',
textarea: 'FormTextarea',
formtextarea: 'FormTextarea',
select: 'FormSelect',
formselect: 'FormSelect',
switch: 'FormSwitch',
formswitch: 'FormSwitch',
date: 'FormDatePicker',
formdatepicker: 'FormDatePicker',
datetime: 'FormDatePicker',
daterange: 'FormDatePicker',
datetimerange: 'FormDatePicker',
month: 'FormDatePicker',
year: 'FormDatePicker',
week: 'FormDatePicker'
}
它还支持日期类型的自动补全(没传 type 时根据 as 推断):
mergedBind() {
const attrs = { ...this.$attrs }
if (this.resolvedTag !== 'FormDatePicker' || attrs.type) {
return attrs
}
const a = normalizeKey(this.as).toLowerCase()
const fromAs = {
date: 'date',
datetime: 'datetime',
daterange: 'daterange',
datetimerange: 'datetimerange',
month: 'month',
year: 'year',
week: 'week',
formdatepicker: 'date'
}
if (fromAs[a]) attrs.type = fromAs[a]
return attrs
}
3. 容器层:FormSchemaDrawer 负责"规则与骨架"
FormSchemaDrawer 统一了抽屉、表单、校验和底部按钮,并按 formlist 渲染字段:
<template v-for="(item, index) in formlist" :key="item.prop || item.slot || index">
<el-form-item
v-if="item.component === 'slot' && item.slot"
:label="item.label"
:prop="item.prop"
:required="item.required"
>
<slot :name="item.slot" :item="item" />
</el-form-item>
<el-form-item
v-else-if="isFormStaticItem(item)"
:label="item.label"
>
<FormStatic
:content="item.content"
:content-class="item.contentClass"
:wrapper-class="item.wrapperClass"
/>
</el-form-item>
<el-form-item
v-else
:label="item.label"
:prop="item.prop"
:required="item.required"
>
<FormField
:as="item.component || item.as || 'input'"
:model-value="formModel[item.prop]"
v-bind="item.props || {}"
@update:model-value="(v) => onSchemaFieldInput(item.prop, v)"
/>
</el-form-item>
</template>
提交时由容器做统一校验拦截:
handleConfirm() {
this.$refs.schemaFormRef?.validate((valid) => {
if (!valid) return
this.$emit('confirm', this.formModel)
})
}
4. 业务页怎么用:动态表单是关键收益点
在 orgVisitor 的新增/编辑弹窗中,visitorFormList 用 computed 动态组装,避免写大量模板分支:
visitorFormList() {
const read = this.isRead;
const spec = this.specification;
const t = this.type;
const blurUser = () => this.setGraduationUser();
const fixedPropsMap = {
visitorName: { placeholder: "请输入姓名", class: inputClass, readonly: read, onBlur: blurUser },
sex: {
placeholder: "请选择性别",
disabled: read,
class: inputClass,
options: this.sexOptions,
labelKey: "dictLabel",
valueKey: "dictValue",
onChange: blurUser,
},
birthday: {
disabled: read,
type: "date",
format: "YYYY/MM/DD",
valueFormat: "YYYY-MM-DD",
placeholder: "请选择出生日期",
class: inputClass,
disabledDate: this.disabledDate,
},
nation: {
placeholder: "请选择民族",
disabled: read,
class: inputClass,
options: this.nationOptions,
labelKey: "dictLabel",
valueKey: "dictValue",
},
visType: {
placeholder: "请选择人员类型",
disabled: read,
class: inputClass,
options: this.visTypeOptions,
labelKey: "typeName",
valueKey: "typeValue",
onChange: blurUser,
},
health: {
placeholder: "请选择健康状态",
disabled: read,
class: inputClass,
options: this.healthOptions,
labelKey: "dictLabel",
valueKey: "dictValue",
},
};
const list = this.fixedFormList.map((item) => ({
...item,
props: fixedPropsMap[item.key] || item.props,
}));
const dynamicAccountField =
spec === "2"
? {
label: "手机号 : ",
prop: "phonenumber",
component: "FormInput",
props: {
type: "number",
disabled: t == 1,
placeholder: "请输入手机号",
class: inputClass,
readonly: read,
onBlur: blurUser,
},
}
: spec === "3"
? {
label: "身份证号 : ",
prop: "identity",
component: "FormInput",
props: {
disabled: t == 1,
placeholder: "请输入身份证号",
class: inputClass,
maxlength: 50,
readonly: read,
onBlur: blurUser,
},
}
: spec === "4" || spec === "5"
? t == 0 && spec === "5"
? {
label: "人员账号 : ",
prop: "nickName",
component: "slot",
slot: "visitorNickNameComposite",
required: true,
}
: {
label: "人员账号 : ",
prop: "nickName",
component: "FormInput",
required: true,
props: {
disabled: t == 1,
placeholder: "请输入人员账号",
class: inputClass,
maxlength: 20,
readonly: read,
onBlur: blurUser,
onInput: (v) => this.handleNickNameInput(v),
},
}
: null;
if (dynamicAccountField) {
list.splice(1, 0, dynamicAccountField);
}
if (this.showJoin) {
list.push({
label: "",
prop: "joinTask",
component: "slot",
slot: "visitorJoinTask",
});
}
return list;
},
根据不同规则插入不同账号字段:
const dynamicAccountField =
spec === "2"
? {
label: "手机号 : ",
prop: "phonenumber",
component: "FormInput",
props: {
type: "number",
disabled: t == 1,
placeholder: "请输入手机号",
class: inputClass,
readonly: read,
onBlur: blurUser,
},
}
: spec === "3"
? {
label: "身份证号 : ",
prop: "identity",
component: "FormInput",
props: {
disabled: t == 1,
placeholder: "请输入身份证号",
class: inputClass,
maxlength: 50,
readonly: read,
onBlur: blurUser,
},
}
最后把 formModel + rules + formlist 统一交给容器:
<FormSchemaDrawer
ref="schemaDrawerRef"
v-model="isShow"
direction="rtl"
:modal="true"
class="el-drawer-pr"
size="25%"
:title="title"
label-width="120px"
form-class="el-form-height"
body-class="form-schema-drawer-body org-visitor-add-body"
:form-model="formVal"
:formlist="visitorFormList"
:rules="rules"
:show-footer="type != 2"/>
5. 这套封装带来的实际价值
- 降低样板代码:同类字段复用同一套行为和事件模型。
- 提升变更效率:字段改动主要集中在配置,不再大面积改模板。
- 控件行为一致:输入、选择、日期、开关都遵循同一
v-model和 attrs 透传模式。 - 复杂场景可扩展:保留
slot机制,不把业务灵活性封死。