Vue3 实现动态表单以及命令式弹窗渲染表单

175 阅读12分钟

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实现

demo

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;
}