AntDesignVue
1.使用表单页面,反推下需要实现哪些功能
// page.vue
// 组件需要满足传入FormItems作为表单配置项动态渲染,并且支持传入form的配置比如rules
<template>
<FormBuilder
:rules="rules"
:form-items="formItems"
v-model="formData"
ref="form"
></VFormBuilder>
<a-button @click="submit">提交</a-button>
</template>
<script setup lang="ts">
// 创建实现formBuilder 文件 index.ts 和index.vue
// index.ts export {default as fromBuilder} from './index.vue'
import { FormBuilder } from '@/components/formBuilder'
const formInstance = useTemplateRef('form')
const formData = ref({})
const formItems = [
{
type: 'input',
label: '姓名',
field: 'name',
placeholder: '请输入姓名',
},
{
type: 'date',
label: '出生日期',
field: 'birthDate',
placeholder: '请选择出生日期',
format: 'YYYY-MM-DD',
},
]
// 如果需要动态显隐列 把formItems用computed包一下,传入配置项时加hidden即可
const rules = {
name: [{ required: true, message: '请输入姓名' }],
birthDate: [{ required: true, message: '请选择出生日期' }],
}
async function submit() {
await formInstance.value.validate()
console.log('formData.value ==> ', formData.value)
}
</script>
2.实现formBuilder index.vue
<template>
<a-form ref="formRef" :model="formData" :rules="rules" v-bind="formConfig">
<a-row>
<a-col v-for="item of formItemsComputed" :key="item.field" :span="item.span || span">
<a-form-item :name="item.field" v-bind="getFormItemProps(item)">
<!-- 兼容一下 label传递vnode的场景 -->
<template v-slot:label>
<span v-if="typeof item.label === 'function'">
<component :is="item.label as Function"></component>
</span>
<span v-else>{{ item.label }}</span>
</template>
// 定义插槽 如果外面传了插槽以外面传的为准就不渲染对应的组件了
<slot :name="item.field">
<ComponentItem :item="item"> </ComponentItem>
</slot>
</a-form-item>
</a-col>
</a-row>
</a-form>
</template>
<script lang="ts" setup>
import { getFormItemComponent } from './config'
import {type Component,ref,computed, h } from 'vue'
import type { FormInstance } from 'ant-design-vue'
defineOptions({
name: 'FormBuilder',
})
interface IFormItem<T extends object = Record<string, any>> {
// 表单项 label
label?: string | (() => VNode)
// 表单项绑定字段
field: string
// 占位符
placeholder?: any
// 禁用标识
disabled?: boolean
// 表单项属性,组件会将所有的 props 传递给 type 绑定的组件
props?: T
// 给formItem传递的props 可写优先级更高的 labelCol配置
formProps?: any
// 组件类型,根据所传递的类型,动态渲染表单项,默认显示为 input 输入框 也可以传入一个组件
type?: string | Component
// 表单项栅格数
span?: number
// 表单项唯一标识,未传递时会使用 field 作为唯一标识,若表单项中存在相同的 field 则必须传递 key
key?: string
// 隐藏标识
hidden?: boolean
// 是否必填
required?: boolean
// 栅格配置
[key: string]: any
}
interface Props {
formItems: IFormItem[]
formConfig?: Record<string, any>
span?: number // 列跨度
rules?: Record<string, any>
}
// 定义表单数据模型
const formData = defineModel<Record<string, any>>({
default: () => ({}), // 默认值为空对象
})
// 定义组件属性并设置默认值
const props = withDefaults(defineProps<Props>(), {
formItems: () => [], // 默认表单项为空数组
formConfig: () => ({}), // 默认表单配置为空对象
span: 24, // 默认列跨度为24
})
// 表单实例引用
const formRef = ref<FormInstance>()
// 默认标签宽度
const defaultLabelWidth = '80px'
// 计算表单项,过滤掉隐藏的项
const formItemsComputed = computed(() => {
return props.formItems.filter((item) => item.hidden !== true)
})
/**
* 获取表单项属性
*/
function getFormItemProps(formItem: IFormItem) {
const { formProps = {} } = formItem
const { labelCol = {}, ...rest } = formProps
const formLabelWidth = props?.formConfig?.labelCol?.style?.width
// 优先级:formItem.labelCol > formConfig.labelCol > defaultLabelWidth 要处理下 formProps配置了labelCol为0px的情况
const labelWidth = labelCol?.style?.width === '0px' ? formLabelWidth : labelCol?.style?.width
return {
labelCol: {
style: {
width: labelWidth || defaultLabelWidth,
},
},
...rest, // 其他属性
}
}
const selectType = new Set(['select', 'date', 'time', 'treeSelect'])
// 传入input/select等组件时过滤掉不需要的props
const baseFieldReg = /^(type|label|props|on|span|key|hidden|required|rules|col|formProps)$/
const ComponentItem = {
props: ['item'],
setup({ item }) {
// 处理传递给组件的props 初始值即为配置项传入的props 过滤掉不需要传递的key
const props = Object.keys(item).reduce<Record<string, any>>(
(prev, key) => {
if (!baseFieldReg.test(key)) {
prev[key] = item[key]
}
return prev
},
{
...item.props,
// formData: formData.value
},
)
if (!('placeholder' in props)) {
const { type } = item
const text = selectType.has(type) ? '请选择' : '请输入'
props.placeholder = text + item.label
}
// 通过 type 获取对应的组件进行渲染
const tag = getFormItemComponent(item.type)
return () =>
h(
tag,
{
...props,
// 处理成v-model 至于antD input框等需要 v-model:value才可以生效,会在getFormItemComponent内部劫持一下
modelValue: formData.value[item.field],
'onUpdate:modelValue': (val: string) => {
formData.value[item.field] = val
}, // 更新 modelValue
},
// 配置型可以传入的插槽内容
item.slots, // 插槽内容
)
},
}
/**
* 验证表单
*/
function validate() {
return formRef.value?.validate()
}
defineExpose({
validate,
})
</script>
3. 实现getFormItemComponent 核心方法
// config.ts
import {
Cascader,
Checkbox,
CheckboxGroup,
DatePicker,
Input,
InputNumber,
Radio,
RadioGroup,
Slider,
Switch,
Textarea,
TimePicker,
TreeSelect,
Select,
} from 'ant-design-vue'
import { type Component,defineComponent, h } from 'vue'
import { isString } from 'lodash-es'
// 获取type对应的组件
export const getFormItemComponent = (type?: string | Component): Component => {
if (type && !isString(type)) return type
return (type && formItemMap.get(type)) || formItemMap.get('input')
}
/**
* 由于 ant-design-vue 组件许多都是 v-model:value 绑定的,为了统一处理,使用这个函数将组件转换为支持 v-model 的组件。
* 将传入的组件转换为支持 v-model 双向绑定的组件。
* @param component 要转换为支持 v-model 的 Vue 组件
* @param key 绑定属性的名称,默认为 'value'
* @returns 返回一个新的包装组件,支持 v-model 绑定
*/
export function transformModelValue(component: Component, key = 'value'): Component {
return defineComponent({
setup(props: any, { attrs, slots, emit }) {
return () => {
const { modelValue, ..._props } = { ...props, ...attrs }
return h(
component,
{
..._props,
// 将接收到的v-model值传给 组件需要的 key(input是value checkbox是checked)
[key]: modelValue,
// v-mode:value 变化时会触发的钩子 重新赋值触发为 modelValue 使得外面的v-model能生效
[`onUpdate:${key}`]: emit.bind(null, 'update:modelValue'),
},
slots,
)
}
},
})
}
const formItemMap = new Map<string, Component>([
['input', transformModelValue(Input)],
['textarea', transformModelValue(Textarea)],
['number', transformModelValue(InputNumber)],
['time', transformModelValue(TimePicker)],
[
'date',
transformModelValue((props, { slots, attrs }) =>
h(DatePicker, { valueFormat: 'YYYY-MM-DD', ...attrs, ...props }, slots),
),
],
['cascader', Cascader],
['slider', Slider],
['checkbox', transformModelValue(Checkbox, 'checked')],
['checkboxGroup', CheckboxGroup],
['radio', Radio],
['radioGroup', transformModelValue(RadioGroup)],
['switch', Switch],
['treeSelect', TreeSelect],
['select', transformModelValue(Select)],
])
4. 打开弹窗显示 我们定义好的表单配置
1.实现一个基础的命令式弹窗
import {Modal} from 'ant-design-vue'
import {type Component,h,createApp} from 'vue'
const renderDialog = (component: Component) => {
const modal = ()=>{
return h(Modal,{
open:true
},h(component))
}
const app = createApp(modal)
const div = document.createElement('div')
document.body.appendChild(div)
app.mount(div)
}
<button @click="btnClick"><button>
const btnClick = ()=>{
renderDialog({
render(){
()=>h('div','helloWord')
}
})
}
// 此时看到弹窗可以加载出来了,这里open是写死的,并且还没关闭弹窗,接下来再优化一下
2.完善弹窗组件
import AntD from 'ant-design-vue'
const renderDialog = (component: Component,props,modalProps) => {
const open = ref(false)
const componentInstance = ref()
const modal = ()=>{
h(Modal,{
open:open.value.
async onOk(e){
await compnentInstance.value.validate?.()
modalProps?.onOk(e)
open.value = false
},
onCancel(e){
modalProps?.onCancel(e)
open.value = false
},
afterClose(){
unMount()
}
},h(component,{
...props,
ref:componentInstance
}))
}
const app = createApp(modal)
const div = document.createElement('div')
document.body.appendChild(div)
app.mount(AntD)
app.mount(div)
function unMount(){
app.unMount()
document.body.removeChild(div)
}
}
2.命令式弹窗渲染定义好的表单项
import { FormBuilder } from '@/components/formBuilder'
const formItems = [
{
type: 'input',
label: '姓名',
field: 'name',
placeholder: '请输入姓名',
},
{
type: 'date',
label: '出生日期',
field: 'birthDate',
placeholder: '请选择出生日期',
format: 'YYYY-MM-DD',
},
]
const rules = {
name: [{ required: true, message: '请输入姓名' }],
birthDate: [{ required: true, message: '请选择出生日期' }],
}
const formData= ref()
const formProps = {FormItems,rules,modelValue:formData}
// formProps 可以自己实现v-model 实现formData的双向绑定 参考上面
btn.onClick=()=>renderDialogForOptions(formProps)
//js 思路:包装一下
export const renderDialogForOptions = (formProps,...args) => {
return renderDialog({
const formBuilderInstance = ref()
setup(props,{exposed}){
h(FormBuilder, {...formProps,ref:formBuilderInstance})
function validate(){
return formBuilderInstance.value.validate()
}
// 使用和template的用法一样 传入即可
exposed({validate})
}
}),...args)
}
3.弹窗组件终极版本
// 避免重复创建空对象
const EMPTY_OBJ = Object.freeze({});
const renderDialog = (
component:Component,
props:Record<string,any> = EMPTY_OBJ,
modalProps:ModalProps = EMPTY_OBJ
) => {
// 调用方可以 renderDialog(Comp,{methodKey:'mySubmit' }) 组件内可以没有onSubmit方法,提交前就可以调这个methodKey方法了
const {methodKey = 'submit',onSubmit} = props
const open = ref(false);
const isLoading = ref(false);
const instance = ref();
const _modalProps ={
async onOk:()=>{
isLoading.value = true;
try {
if(onSubmit){
await onSubmit(instance.value);
}else if (instance.value?.[methodKey]){
await instance.value?.[methodKey]?.();
}
unMount()
} finally () {
isLoading.value = false;
}
open.value = false
},
onCancel(){
open.value = false
},
afterClose:()=>{
unMount()
}
};
const _component = ()=>h(component,{..props,ref:instance})
// 使用reactive包一下 不需要title.value 相当于 外面可以传入一个title的ref 可以动态改title也可以响应式
const reactiveModalProps = reactive(modalProps);
const dialog = defineComponent({
setup(_,{expose}){
return h(Modal,{
...reactiveModalProps,
..._modalProps,
open:open.value,
confirmLoading:isLoading.value,
},_component)
}
})
const app = createApp(dialog);
const div = document.createElement('div');
document.body.appendChild(div);
app.mount(div);
function unMount(){
app.unmount();
document.body.removeChild(div);
}
}
ElementPlus
1.思路
formData传入id时代表是编辑,弹窗保存时可以传编辑接口,否则调新增接口。 和AntD一样,实现一个基础的表单,要传入formData,formItems rules
<template>
<div>
<ElFormBuilder :formItems v-model="formData" :rules> </ElFormBuilder>
</div>
</template>
<script lang="ts" setup>
import ElFormBuilder from '@/components/ElFormBuilder/index.vue'
import { ref } from 'vue'
import HelloWorld from '@/components/HelloWorld.vue'
const formData = ref<any>({})
const options = ref([])
interface FormItem{
label:string
key:string
type:string | Component
[key:string]:any
}
// 不传 type 渲染默认input
// options从后台获取 所以要用computed包一下
// 如果考虑到组件每次依赖变更整个computed都重新执行可以不用computed将props传入响应式对象
// 二次封装组件传入props时用reactive解包一下
const formItems = computed(()=>[
{
label: '姓名',
key: 'users',
placeholder: '请输入姓名',
onInput(){
console.log('prop统一处理传给了组件,所以传入事件监听也能生效')
}
},
{
label: '年龄',
key: 'age',
placeholder: '请输入姓名',
type: number,
},
{
label: '性别',
key: 'sex',
type: 'radioGroup',
// 年龄小于18隐藏,所以要在组件里过滤掉hidden为false
hidden:formData.value.age < 18
options:options.value
},
{
label: '下拉项',
key: 'select_key',
type: 'select',
options:[{label:'option1',value:'1',slots:()=>h('div','helloworld')}]
},
{
label: '自定义组件',
key: 'key_1',
// 要传入自定义组件,需要在二次封装时判断一下 如果type不是字符串则返回本身否则返回componentMap(item)
// 引入的组件也可以(template)二次封装可以defineModel声明modelValue也满足双向绑定因为渲染组件处理了
type: ()=>h('div','hello world'),
options:[{label:'option1',value:'1',slots:()=>h('div','helloworld')}]
},
])
const rules = {
users: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 5, message: '长度在 2 到 5 个字符之间', trigger: 'blur' },
],
age: [
{ required: true, message: '请输入年龄', trigger: 'blur' },
{ type: 'number', min: 1, max: 100, message: '年龄必须在1到100之间', trigger: 'blur' },
],
}
2.实现一下这个form表单
<script setup lang="ts">
import { get, omit, set } from 'lodash-es'
const fromData = defineModel() as Ref<Record<string,any>>
defineOptions({
name: 'ElFormBuilder',
})
const props = defineProps(['formItems', 'rules'])
// 如果需要处理hidden后字段不传就watch一下formData 如果hidden为false就把字段变为undefined
const items = computed(() => props.formItems.filter((item) => !item.hidden))
const rootProps = ['label', 'key', 'type', 'span']
// 传给组件的props要剔除掉这些基础属性(是传给formItem用的)
// 如果真的需要传 key这些属性给组件用props方式传递或者props太多了也可以写到props里面
function getProps(item: Record<string, any>) {
if (item.props) return item.props
// 这里用reactive包一下是外面如果传的是响应式对象会把他解包,不考虑传响应式对象的话直接返回也行
return reactive(omit(item, rootProps))
}
const slots = useSlots()
function transformOptions(component: Component, optionsComponent: Component) {
return (props: { options: { label: string; value: string }[] }) => {
const { options = [] } = props
return h(component, props, () => {
return options.map((item) => {
// 处理插槽的一种方式,先取默认的配置项传入的插槽,如果取到的是字符串可以去useSlots里面去找
// 作用:插槽为'mySlot'字符串时<FormBuilder><template #mySlot>我是template传入的插槽</template></FormBuilder>
let _slots = item.slots
if (typeof _slots === 'string') {
_slots = slots[_slots]
}
return h(optionsComponent, item, _slots)
})
})
}
}
// transformOptions(ElSelect, ElOption) 这里也可以自己写个组件更灵活可操作。MySelect 或者这里不改 直接外面传的时候用type Myselect也是一样的
/* MySelect.vue
const props=defineProps(['code','format'])
const options = ref([])
// 渲染cOptions 如果外面传了format函数(后台返回的格式不是label value)
// 当然不传入format也可以写filedNames:{label:'label',value:'id'} 自己处理一下
const cOptions = computed(()=>{
const _options = options.value、
if(!props.format) return _options
return props.format(_options)
})
function loadData(){
setTimeout(() => {
// 通过配置项传入code 'user'发不同的请求获取数据字典
options.value =[]
},1000)
}
// 当然获取字典的文件要抽离出来写个缓存 因为字典一般不会变
loadData()
<template>
<el-select>
<el-option v-for="item in cOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</template>
*/
const componentMap: Record<string, any> = {
input: ElInput,
number: ElInputNumber,
select: transformOptions(ElSelect, ElOption),
radioGroup: transformOptions(ElRadioGroup, ElRadio),
checkboxGroup: transformOptions(ElCheckboxGroup, ElCheckbox),
// 当然也可以传入自定义组件
selectUser: HelloWorld,
// 异步组件
selectUser:a(()=>{
return new Promise((resove)=>{
setTimeout(import('@/components/HelloWorld').then(comp=>resolve(comp.default)),1000)
})
})
date: ElDatePicker,
}
function getComponent(item: Record<string, any>) {
const { type } = item
if (type === undefined) return ElInput
if (typeof type !== 'string') {
// 函数式组件或者有状态的组件
return type
}
return componentMap[type]
}
const ComponentItem = {
props: ['item'],
setup(props: { item: Record<string, any> }) {
return () => {
const { item } = props
/**
* MutableHandler
*
*/
return h(
getComponent(item), // ElInput
{
modelValue: get(formData.value, item.key),
'onUpdate:modelValue': (value: any) => {
if(item.trim) value = value.trim()
set(formData.value, item.key, value)
},
...getProps(item),
formData: formData.value,
},
// 没特殊情况的话,配置项传递slots就能正常渲染了
// item.slots,
// 如果要在template传,则需要处理一下,可能传多个所以要遍历slots
// {label:'找模板的插槽',type:'input',slots:{append:'append'}}
// <FormBuilder><template #append>后面内容</template></FormBuilder>
// 如果append传了字符串模板里没有定义#append要渲染这个字符串所以合并一下
Object.assign(Object.entries(item.slots ||{}).reduce((acc,[key,value])=>{
// 循环传入的插槽,如果是字符串就去组件的插槽中去找
if(typeof key === 'string' && slots[key]) acc[key] = slots[key]
return acc
},{} as Record<string,any>),
item.slots
)
)
}
},
}
</script>
<template>
<el-form :model="formData" :rules="rules" label-width="80px">
<el-row>
<el-col v-for="item in items" :key="item.key" :span="item.span || 24">
<el-form-item :label="item.label" :prop="item.key">
<slot :name="item.key">
<ComponentItem :item="item"></ComponentItem>
</slot>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
<style scoped></style>
AntD renderDialogForm实现
1.使用方
<script setup>
import { ref} from 'vue';
import { renderDialogForm } from './DynamicallyCreateForms'
const title = ref('【新增】菜单应用');
const typeDict = ref([]);
const formData = ref({});
const items1 = [
{
type: 'input',
label: '菜单ID',
field: 'id',
hasFeedback: true,
placeholder: '请输入菜单ID',
span: 12,
labelCol: {
span: 6,
},
wrapperCol: {
span: 18,
},
},
{
type: 'radioGroup',
label: '菜单层级',
field: 'type',
options: typeDict,
span: 12,
labelCol: {
span: 6,
},
wrapperCol: {
span: 18,
},
}
]
setTimeout(() => {
typeDict.value = [
{ label: '菜单', value: 1 },
{ label: '目录', value: 2 },
{ label: '按钮', value: 3 }
]
}, 5000)
const rules = {};
function show() {
formData.value = {};
renderDialogForm(
{ formItems: items1, rules, modelValue: formData.value },
{},
{
title: () => title.value,
width: window.innerWidth * 0.6,
cancelText: '取消',
okText: '确定',
wrapClassName: 'full-modal',
centered: true,
bodyStyle: {
height: 'calc(60vh)',
overflow: 'auto',
},
}
);
}
</script>
<template>
<button @click="show">打开的item1表单,使用了computed</button>
</template>
2.DynamicallyCreateForms.js renderDialogForm & renderDialog
import { type ModalProps, Modal } from 'ant-design-vue';
import * as Antd from 'ant-design-vue';
import {
type Component,
h,
ref,
createApp,
reactive,
} from 'vue';
import { VFormBuilder } from './index';
// 避免重复创建空对象
const EMPTY_OBJ = Object.freeze({});
export const renderDialog = (
component: Component,
props: Record<string, any> = EMPTY_OBJ,
modalProps: ModalProps = EMPTY_OBJ
) => {
const { methodKey = 'submit', onSubmit } = props;
const open = ref(true);
const mask = ref(true);
const maskClosable = ref(false);
const isLoading = ref(false);
const instance = ref();
const _modalProps: ModalProps = {
async onOk(e) {
isLoading.value = true;
try {
if (onSubmit) {
await onSubmit(instance.value);
} else if (instance.value?.[methodKey]) {
await instance.value?.[methodKey]?.();
}
modalProps?.onOk?.(e);
} finally {
isLoading.value = false;
}
open.value = false;
},
onCancel(e) {
open.value = false;
modalProps?.onCancel?.(e);
},
afterClose() {
unMount();
}
};
const reactiveModalProps = reactive(modalProps);
const _component = () => h(component, { ...props, ref: instance });
const dialog = () => {
// 处理响应式的title
const modalTitle =
typeof reactiveModalProps.title === 'function'
? reactiveModalProps.title()
: reactiveModalProps.title;
return h(
Modal,
{
..._modalProps,
...reactiveModalProps,
title: modalTitle, // 使用处理后的title
open: open.value,
mask: mask.value,
maskClosable: maskClosable.value,
confirmLoading: isLoading.value
},
_component
);
};
const app = createApp(dialog);
const div = document.createElement('div');
document.body.appendChild(div);
app.use(Antd);
app.mount(div);
function unMount() {
document.body.removeChild(div);
app.unmount();
}
};
export function renderDialogForm(formProps: any, ...args: any[]) {
formProps = reactive(formProps)
return renderDialog(
{
setup(_, { expose }) {
const formInstance = ref();
// renderDialog 时候可以传个ref可以拿到submit方法(args)
expose({
async submit() {
try {
// 先进行表单验证(保持原有校验逻辑)
const validateResult = await formInstance.value?.validate();
// 如果验证通过,调用组件的 submit 方法
if (validateResult) {
// 获取表单数据
const formData =
formInstance.value?.getFormData?.() || formProps.modelValue;
// 调用组件的 submit 方法,传递表单数据
if (formProps.componentInstance?.submit) {
return await formProps.componentInstance.submit(formData);
}
}
return validateResult;
} catch (error) {
console.error('表单验证失败:', error);
throw error;
}
},
getFormData() {
return formInstance.value?.getFormData?.() || formProps.modelValue;
}
});
return () =>
// 通过传递h函数的ref formInstance获取form表单的实例
h(VFormBuilder, {
...formProps,
ref: formInstance
});
}
},
...args
);
}
3.index.js
export { default as VFormBuilder } from './index.vue'
export * from './types'
4.index.vue
<template>
<a-form ref="formRef" :colon="false" :label-col="labelCol" :wrapper-col="wrapperCol" :model="formData" :rules="rules"
v-bind="formConfig">
<a-row :gutter="24">
<a-col v-for="item of formItemsComputed" :key="item.field" :span="item.span || span">
<a-form-item :name="item.field" v-bind="getFormItemProps(item)" :wrapper-col="item.wrapperCol"
:label-col="item.labelCol">
<!-- 兼容一下 label传递vnode的场景 -->
<template v-slot:label>
<span v-if="typeof item.label === 'function'">
<component :is="item.label()"></component>
</span>
<span v-else>{{ item.label }}</span>
</template>
<!-- 定义插槽 如果外面传了插槽以外面传的为准就不渲染对应的组件了 -->
<slot :name="item.field">
<ComponentItem :item="item" :style="{ width: item.width ? 'unset' : '100%' }"></ComponentItem>
</slot>
</a-form-item>
</a-col>
</a-row>
</a-form>
</template>
<script lang="ts" setup>
import { getFormItemComponent } from './config';
import type { IFormItem } from './types';
import { type Component, ref, computed, h, VNode } from 'vue';
import type { FormInstance } from 'ant-design-vue';
defineOptions({
name: 'VFormBuilder',
});
const baseFieldReg =
/^(type|label|props|on|span|key|hidden|required|rules|col|formProps)$/;
interface Props {
formItems: IFormItem[];
formConfig?: Record<string, any>;
span?: number; // 列跨度
rules?: Record<string, any>;
labelCol?: { span: number };
wrapperCol?: { span: number };
}
// 定义表单数据模型
const formData = defineModel<Record<string, any>>({
default: () => ({}), // 默认值为空对象
});
// 定义组件属性并设置默认值
const props = withDefaults(defineProps<Props>(), {
formItems: () => [], // 默认表单项为空数组
formConfig: () => ({}), // 默认表单配置为空对象
span: 24, // 默认列跨度为24
labelCol: () => ({ span: 9 }),
wrapperCol: () => ({ span: 15 }),
});
// 表单实例引用
const formRef = ref<FormInstance>();
// 默认标签宽度
const defaultLabelWidth = '80px';
// 计算表单项,过滤掉隐藏的项
const formItemsComputed = computed(() => {
return props.formItems.filter((item) => item.hidden !== true);
});
/**
* 获取表单项属性
*/
function getFormItemProps(formItem: IFormItem) {
const { formProps = {} } = formItem;
const { labelCol = {}, ...rest } = formProps;
const formLabelWidth = props?.formConfig?.labelCol?.style?.width;
const labelWidth =
labelCol?.style?.width === '0px' ? formLabelWidth : labelCol?.style?.width;
return {
labelCol: {
style: {
width: labelWidth || defaultLabelWidth
}
},
...rest // 其他属性
};
}
const selectType = new Set(['select', 'date', 'time', 'treeSelect']);
const ComponentItem = {
props: ['item'],
setup({ item }: { item: IFormItem }) {
return () => {
// 处理传递给组件的props 初始值即为配置项传入的props 过滤掉不需要传递的key
const props = Object.keys(item).reduce<Record<string, any>>(
(prev, key) => {
if (!baseFieldReg.test(key)) {
prev[key] = item[key];
}
return prev;
},
{
...item.props,
// formData: formData.value
}
);
if (!("placeholder" in props)) {
const { type } = item;
let text = "请输入";
if (typeof type === "string" && selectType.has(type)) {
text = "请选择";
}
// 确保item.label存在且为字符串
const labelText = typeof item.label === "string" ? item.label : "";
props.placeholder = text + labelText;
}
// 通过 type 获取对应的组件进行渲染
const tag = getFormItemComponent(item.type);
return h(
tag,
{
...props,
// 处理成v-model 至于antD input框等需要 v-model:value才可以生效,会在getFormItemComponent内部劫持一下
modelValue: formData.value[item.field],
"onUpdate:modelValue": (val: string) => {
formData.value[item.field] = val;
}, // 更新 modelValue
},
// 配置型可以传入的插槽内容
item.slots // 插槽内容
);
};
},
};
/**
* 验证表单
*/
function validate() {
return formRef.value?.validate();
}
/**
* 获取表单数据
*/
function getFormData() {
return formData.value;
}
defineExpose({
validate,
getFormData,
});
</script>
5.config.ts
import {
Input,
Radio,
RadioGroup,
Select
} from 'ant-design-vue';
import { type Component, defineComponent, h } from 'vue';
import {isString} from 'lodash-es';
// 获取type对应的组件
export const getFormItemComponent = (type?: string | Component): Component => {
if (type && !isString(type)) return type;
// 只在 type 是 string 时查表
const component = type && isString(type) ? formItemMap.get(type) : undefined;
// 如果查不到,兜底用 input,并用 ! 断言一定有值
return component || formItemMap.get('input')!;
};
/**
* 由于 ant-design-vue 组件许多都是 v-model:value 绑定的,为了统一处理,使用这个函数将组件转换为支持 v-model 的组件。
* 将传入的组件转换为支持 v-model 双向绑定的组件。
* @param component 要转换为支持 v-model 的 Vue 组件
* @param key 绑定属性的名称,默认为 'value'
* @returns 返回一个新的包装组件,支持 v-model 绑定
*/
export function transformModelValue(
component: Component,
key = 'value'
): Component {
return defineComponent({
setup(props: any, { attrs, slots, emit }) {
return () => {
const { modelValue, ..._props } = { ...props, ...attrs };
return h(
component,
{
..._props,
// 将接收到的v-model值传给 组件需要的 key(input是value checkbox是checked)
[key]: modelValue,
// v-mode:value 变化时会触发的钩子 重新赋值触发为 modelValue 使得外面的v-model能生效
[`onUpdate:${key}`]: emit.bind(null, 'update:modelValue')
},
slots
);
};
}
});
}
const formItemMap = new Map<string, Component>([
['input', transformModelValue(Input)],
['radio', Radio],
['radioGroup', transformModelValue(RadioGroup)],
['select', transformModelValue(Select)]
]);
6.type.js
import type { VNode, Component } from 'vue';
/**
* 表单项
*/
export interface IFormItem<T extends object = Record<string, any>> {
// 表单项 label
label?: string | (() => VNode);
// 表单项绑定字段
field: string;
// 占位符
placeholder?: any;
// 禁用标识
disabled?: boolean;
// 给formItem传递的props 可写优先级更高的 labelCol配置
formProps?: any;
// 表单项属性,组件会将所有的 props 传递给 type 绑定的组件
props?: T;
// 组件类型,根据所传递的类型,动态渲染表单项,默认显示为 input 输入框
type?: string | Component;
// 表单项栅格数
span?: number;
// 表单项唯一标识,未传递时会使用 field 作为唯一标识,若表单项中存在相同的 field 则必须传递 key
key?: string;
// 隐藏标识
hidden?: boolean;
// 是否必填
required?: boolean;
// 表单项label宽度
labelCol?: { span: number };
// 表单项内容宽度
wrapperCol?: { span: number };
// 是否自适应宽度
width?: Boolean;
// icon图标
typeIcon?: any;
// 栅格配置
[key: string]: any;
}