基于vue3+ts配置化form表单组件实现浅析

背景

新起Vue3项目表单组件编写没有表单组件封装,表单编写大量的重复el-col、el-form-item等组件,费时费力,大篇幅代码也不利于维护。这里基于Vue2及之前无为低代码平台的一些经验,封装了一份Vue3+Ts版本的配置化表单基础组件。

你已经是一个成熟的表单了,你要学会:

  • 配置化渲染
  • 布局支持(单列、双列、多列)
  • 支持表单验证
  • 支持配置动态调整
  • 配置常用字段代码提示
  • 兼容element-plus(推荐)、Ant Design Vue所有原生配置项
  • 支持自定义组件
  • 表单字段过多时支持收起/展开

此组件在element-uiant-design-vue项目中均可直接使用,实现原理vue3+ts组件库同时兼容多种ui框架

效果图

最终实现的效果是这样滴!!!

概要实现逻辑

组件目录

"食用"例子

我们先看下上述效果图的配置化JOSN实例,最终我们将实现所有表单都能通过这样一个表单JSON实现渲染,表单需要的属性,统统放入json里面,最后通过一个简单的调用即可渲染一个form表单

调用

<BaseForm
  ref="BaseFormRef"
  v-model="form"
  class="BaseForm"
  :config="config"
/>
<script lang="tsx" setup>
  // 表单值
  const form = ref<any>({})
  // 表单配置json
  const config = reactive<formConfig>(...)
</script>

上述编码我们即可渲染一个from表单

接口定义

先看配置JSON对象ts接口定义

/*
 * @Author: 陈宇环
 * @Date: 2022-05-30 14:29:12
 * @LastEditTime: 2023-04-20 21:04:32
 * @LastEditors: 陈宇环
 * @Description:
 */


// 表单组件config配置接口
export interface formConfig {
  columns: columnsBase[]  // 表单项配置
  colNum?: number   // columns项默认宽度(1-24整数)
  labelWidth?: string  // label宽度
  disabled?: boolean  // 是否禁用
  loading?: boolean  // 是否加载中
  notOpBtn?: boolean // 不需要(搜索,重置,导出)操作按钮
  opBtnCol?: number // 操作按钮col宽度(24等分)
  isSearch?: boolean // 是否需要搜索按钮
  searchFn?: () => any // 搜索按钮点击触发函数
  isExport?: boolean // 是否需要导出按钮
  exportFn?: () => any // 搜索按钮点击触发函数
  isReset?: boolean // 是否需要重置按钮
  resetFn?: () => any // 搜索按钮点击触发函数
  isExpand?: boolean // 是否需要展示/收起按钮
  appendOpBtn?: () => any | void // 附加操作按钮render
  
  nativeProps?: {   // ui框架原生属性
    [key: string]: any
  }
}

// 所有表单控件的联合类型
export type columnsBase =
  | inputProps
  | selectProps
  | radioProps
  | checkboxProps
  | numberProps
  | dateProps
  | dateRangeProps
  | numberRangeProps
  | cascaderProps
  | switchProps
  | uploadProps
  | textProps
  | renderProps


// 基础属性接口
interface defaultProps {
  prop: string  // key值
  label?: string  // label值
  colNum?: number  // 列宽 24等分
  labelWidth?: number | string // label宽度
  hide?: boolean  // 是否隐藏(隐藏直接销毁dom)
  disabled?: boolean  // 是否禁用
  required?: boolean  // 是否必填
  placeholder?: string  // 描述字符
  clearable?: boolean  // 是否需要清除按钮
  expandDefault?: boolean // 该字段展开收起默认值
  prop2?: string // 附加字段(部分selelct等需要绑定两个key)
  rules?: any[]  // 附加检验规则
  change?: (e: any) => void // change事件触发函数

  nativeProps?: {   // ui框架原生属性
    [key: string]: any
  }
}

// options选项 select、radio、checkbox、cascader(可能包含children)选项接口
export type optionsType = {
  [label: string]: any, children?: any[]
}[]  // 直接传数组对象
| { type: 'dic'; key: string }   // 字典获取
| {   // 接口获取
  type: 'api'
  getData: () => Promise<{ [label: string]: any, children?: any[] }[]>  // 必须返回对象数组,不一定是label,value格式
}


// select、radio、checkbox 选项格式化函数(对应element-plus组件插槽)
export type format = (item: any) => any

// 输入框控件props
export interface inputProps extends defaultProps {
  type: 'input' | 'textarea' | 'password'  // 这里还能添加很多类型 参考:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Form_%3Cinput%3E_types
  showPassword?: boolean  // 是否需要密码*号 显示隐藏开关
  minlength?: number
  maxlength?: number
  rows?: number // textarea 行数
}

// 数字输入控件
export interface numberProps extends defaultProps {
  type: 'number'
  min?: number
  max?: number
  step?: number
  precision?: number
  controls?: boolean
}

// 下拉菜单控件props
export interface selectProps extends defaultProps {
  type: 'select'
  prop2?: string
  filterable?: boolean
  remote?: boolean
  remoteMethod?: (query: string) => Promise<{ [label: string]: any }[]>
  multiple?: boolean
  collapseTags?: boolean  // 多选时,是否需要折叠展示
  collapseTagsTooltip?: boolean   // 多选并折叠展示时,鼠标放上去是否需要Tooltip展示
  multipleLimit?: number // 多选限制个数
  labelKey?: string
  valueKey?: string
  reserveKeyword?: boolean  // 搜索状态下,选择一个项之后,是否保留当前关键字
  format?: format // 格式化函数
  options?: optionsType
}

// 单选控件props
export interface radioProps extends defaultProps {
  type: 'radio'
  labelKey?: string
  valueKey?: string
  border?: boolean
  showType?: 'button' | undefined // 用按钮的形式展示 button
  format?: format
  options?: optionsType
}

// 多选控件props
export interface checkboxProps extends defaultProps {
  type: 'checkbox'
  labelKey?: string
  valueKey?: string
  border?: boolean
  showType?: 'button' | undefined // 用按钮的形式展示 button
  format?: format
  options?: optionsType
}

// 日期控件props
export interface dateProps extends defaultProps {
  // year/month/week/date/datetime/dates
  type: 'year' | 'month' | 'week' | 'date' | 'datetime' | 'dates'
}

// 日期控件范围props
export interface dateRangeProps extends defaultProps {
  type: 'yearRange' | 'monthRange' | 'dateRange' | 'weekRange' | 'datetimeRange'
  propEnd?: string // 范围选择控件(dateRange、numberRange)结束key
  disabledDate?: (date: any) => boolean // 禁止选择的日期
  disabledEndDate?: (date: any) => boolean // 禁止选择的日期
}

// 数字范围控件props
export interface numberRangeProps extends defaultProps {
  type: 'numberRange'
  propEnd?: string // 范围选择控件(dateRange、numberRange)结束key
  min?: number
  max?: number
  step?: number
  precision?: number
  controls?: boolean
}

// 联动Cascader
export interface cascaderProps extends defaultProps {
  type: 'cascader'
  multiple?: boolean
  labelKey?: string,
  valueKey?: string,
  childrenKey?: string,
  emitPath?: boolean,
  props?: any,
  format?: (node: any, data: any) => any
  options?: optionsType
}

// 开关控件props
export interface switchProps extends defaultProps {
  type: 'switch'
  activeValue?: boolean | string | number, // switch 状态为 on 时的值
  inactiveValue?: boolean | string | number, // switch 状态为 off 时的值
}

export interface uploadProps extends defaultProps {
  type: 'upload'
  maxNum?: number  // 最多上传文件数量
  accept?: string[]  // 允许上传文件类型
  maxSize?: number  // 文件最大Size  单位:M
  multiple?: boolean // 是否允许多传
  annexType?: string, // 财务oss相关配置 默认:IMPORT_FILE
  businessType?: string, // 财务oss相关配置 默认:SOSIAL_SECURITY
  ossFolder?: string, // 财务oss相关配置 默认:SOSIAL_SECURITY
}

// 文本控件props
export interface textProps extends defaultProps {
  type: 'text',
  defaultText?: string | number,
}

// 自定义render函数(只替换form-item-conent部分,label不会被render)
export interface renderProps extends defaultProps {
  type: 'render'
  render: () => any // 自定义组件render
}

// 实例是否是columnsOtherBase类型
// export const isColumnsOtherBase = (item: columnsBase): item is columnsOtherBase => {
//   return (item as columnsOtherBase).fullRender !== undefined
// }

表单项渲染实现

遍历columns字段渲染对应表单子组件,简略版代码如下:

// @/components/BaseForm/index

cloneConfig.columns.map((item) => {
  return <>
    {
      item.hide !== true &&
      <dynamicCol
        xs={item.colNum || props.config.colNum || getSpan(item)[0]}
        sm={item.colNum || props.config.colNum || getSpan(item)[1]}
        md={item.colNum || props.config.colNum || getSpan(item)[2]}
        lg={item.colNum || props.config.colNum || getSpan(item)[3]}
        xl={item.colNum || props.config.colNum || getSpan(item)[4]}
        >
        <dynamicFormItem
          label-width={item.labelWidth || props.config.labelWidth || '100px'}
          label={item.label}
          prop={item.prop}
          >
          {
            item.type === 'render' ?  // 自定义render函数(只替换form-item-conent部分,label不会被render)
            item?.render() : // ep-form-item__content 部分的render函数
            componentRender(item)    // 根据item:columnsFormBase中的type属性获取对应的自定义组件
          }
        </dynamicFormItem>
      </dynamicCol>
    }
  </>
})

其中componentRender函数将通过columns中每一项type字段来渲染对应的组件,实现如下:

// @/components/BaseForm/index

import * as widget from './components/index'

const componentRender = (item: columnsBase) => {
  const componentInstance = widget.getComponentByType(item)
  return <componentInstance
    v-models={[
      [initForm.value[item.prop]],
      [initForm.value[(item as {propEnd?: any}).propEnd], 'propEnd'],
      [initForm.value[(item as {files?: any}).files], 'fileList'],
    ]}
    config={item}
    onChange={(params: any) => {
      item?.change && item?.change(params)
      updateModelValue()
    }}
    onSetProp2={(value: any) => {
      item.prop2 && setProp2(item.prop2, value)
    }}
  />
}

所有表单组件入口:

// @/components/BaseForm/components/index  

import { defineAsyncComponent } from 'vue'
import { columnsBase } from '../interface/index'

export const BaseInput = defineAsyncComponent(() => import('./BaseInput'))
export const BaseNumber = defineAsyncComponent(() => import('./BaseNumber'))
export const BaseSelect = defineAsyncComponent(() => import('./BaseSelect'))
export const BaseRadio = defineAsyncComponent(() => import('./BaseRadio'))
export const BaseCheckbox = defineAsyncComponent(() => import('./BaseCheckbox'))
export const BaseDate = defineAsyncComponent(() => import('./BaseDate'))
export const BaseDateRange = defineAsyncComponent(() => import('./BaseDateRange'))
export const BaseNumberRange = defineAsyncComponent(() => import('./BaseNumberRange'))
export const BaseCascader = defineAsyncComponent(() => import('./BaseCascader'))
export const BaseSwitch = defineAsyncComponent(() => import('./BaseSwitch'))
export const BaseText = defineAsyncComponent(() => import('./BaseText'))
export const BaseUpload = defineAsyncComponent(() => import('./BaseUpload'))

// 根据item.type返回对应的组件
export const getComponentByType = (item: columnsBase): any => {
  switch (item.type) {
    case 'input':
    case 'textarea':
      return BaseInput
    case 'number':
      return BaseNumber
    case 'select':
      return BaseSelect
    case 'radio':
      return BaseRadio
    case 'checkbox':
      return BaseCheckbox
    case 'year':
    case 'month':
    case 'week':
    case 'date':
    case 'datetime':
    case 'dates':
      return BaseDate
    case 'yearRange':
    case 'monthRange':
    case 'dateRange':
    case 'weekRange':
    case 'datetimeRange':
      return BaseDateRange
    case 'numberRange':
      return BaseNumberRange
    case 'cascader':
      return BaseCascader
    case 'switch':
      return BaseSwitch
    case 'upload':
      return BaseUpload
    case 'text':
      return BaseText
    default:
      throw new Error('配置项控件${col.type}不存在')
  }
}

布局实现

布局这里沿用了 UI框架的 栅栏布局,分为根据屏幕宽度分为xs、sm、md、lg、xl,colNum配置在表单最外配置上以及表单项配置上都有配置,优先级为:表单项中的配置>表单外层配置>默认配置

// @/components/BaseForm/index

// 默认-自适应列宽
const getSpan = (item: columnsBase): number[] => {
  const spanArray = [12, 8, 8, 6, 6]  // [xs,sm,md,lg,xl]
  // 对区间做特殊处理
  if (item.type.indexOf('Range') !== -1) {
    // 区间为分栏数量的两倍
    return spanArray.map((v) => v * 2)
  }
  return spanArray
}

{
  cloneConfig.columns.map((item) => {
    return <>
      {
        item.hide !== true &&
        <dynamicCol
          xs={item.colNum || props.config.colNum || getSpan(item)[0]}
          sm={item.colNum || props.config.colNum || getSpan(item)[1]}
          md={item.colNum || props.config.colNum || getSpan(item)[2]}
          lg={item.colNum || props.config.colNum || getSpan(item)[3]}
          xl={item.colNum || props.config.colNum || getSpan(item)[4]}
        >
          <dynamicFormItem
            label-width={item.labelWidth || props.config.labelWidth || '100px'}
            label={item.label}
            prop={item.prop}
          >
            ...
          </dynamicFormItem>
        </dynamicCol>
      }
      ....
    </>
  })
}

表单验证实现

监听config配置变化,遍历columns项根据prop字段、required字段及rules字段生成UI框架表单的rules对象,这里需要处理支持表单配置项中的required,另外需要将表单配置中的附加规则rules,拼接到结果rules对象对应的字段数组中去,部分代码如下:

// @/components/BaseForm/index

watch(() => props.config, () => {
  ...
  initRulesFn()
}, { immediate: true, deep: true })

const initRulesFn = () => {
  const cloneRules: { [key: string]: any } = {}
  cloneConfig.columns.forEach((item: columnsBase) => {
    if (!item.hide) {
      cloneRules[item.prop] = [
        { required: item.required, message: `${['input', 'textarea'].includes(item.type) ? '请输入' : '请选择'}${item.label}`, trigger: 'change' },
        ...(item.rules ? item.rules : []),
      ]
    }
  })
  Object.assign(rules, cloneRules)
}

支持所有UI框架的原生配置

接口定义修改,避免访问没有定义的属性ts报错,增加

nativeProps?: { // ui框架原生属性

}

// @/components/BaseForm/components/index  

interface defaultProps {
  prop: string  // key值
  label?: string  // label值
  colNum?: number  // 列宽 24等分
  labelWidth?: number | string // label宽度
  hide?: boolean  // 是否隐藏(隐藏直接销毁dom)
  disabled?: boolean  // 是否禁用
  required?: boolean  // 是否必填
  placeholder?: string  // 描述字符
  clearable?: boolean  // 是否需要清除按钮
  expandDefault?: boolean // 该字段展开收起默认值
  prop2?: string // 附加字段(部分selelct等需要绑定两个key)
  rules?: any[]  // 附加检验规则
  change?: (e: any) => void // change事件触发函数

  nativeProps?: {   // ui框架原生属性
    [key: string]: any
  }
}

子组件修改:

所有@/components/BaseForm/components/…中的组件增加{…props.config.nativeProps}

// @/components/BaseForm/components/BaseInput

<dynamicInput
  class="input"
  type='text'
  model-value={props.modelValue}
  placeholder={props.config.placeholder || `请输入${props.config.label}`}
  disabled={!!props.config.disabled}

  /** ant-design-vue && ele 统一封装 - start */
  clearable={props.config.clearable !== false}  // ele 特有属性
  allowClear={props.config.allowClear ?? props.config.clearable !== false} // ant-design-vue特有属性
  /** ant-design-vue && ele 统一封装 - end */

  {...props.config.nativeProps}
  onInput={updateValue}
/>

支持自定义组件

实现效果如下,通过自定义组件并绑定form表单值:

{
  label: '测试render',
  prop: 'test',
  type: 'render',
  render: () => {
    return <ElInput v-model={form.value.test}></ElInput>
  },
  placeholder: '请输入',
},

实现思路:

首先表单接口定义columnsBase增加如下类型

// @/components/BaseForm/components/index  

export interface renderProps extends defaultProps {
  type: 'render'
  render: () => any // 自定义组件render 
}

表单渲染模块增加

// @/components/BaseForm/index

{
  item.type === 'render' ?  // 自定义render函数(只替换form-item-conent部分,label不会被render)
    item?.render() : // ep-form-item__content 部分的render函数
    componentRender(item)    // 根据item:columnsFormBase中的type属性获取对应的自定义组件
}

展开收起功能

接口定义:

// form表单配置接口
export interface formConfig {
  ...
  isExpand?: boolean // 是否需要展示/收起按钮
	...
}

// 子组件公用字段接口
interface defaultProps {
  ...
  expandDefault?: boolean // 展开收起默认值
  ...
}

展开收起方法:

const currentExportState = ref(false) // 当前收起展开状态 默认收起
const expandFn = () => {
  currentExportState.value = !currentExportState.value
  const columns = _.cloneDeep(cloneConfig.columns)
  columns.forEach((item: columnsBase) => {
    if (!item.hide && item?.expandDefault !== undefined) {
      item.expandDefault = currentExportState.value
    }
  })
  cloneConfig.columns = columns
}

{cloneConfig.isExpand && (
  <dynamicButton
    type="info"
    size="small"
    onClick={() => {
      expandFn()
    }}
    >
    {currentExportState.value ? '收起' : '展开'}
  </cButton>
)}

部分子组件内部逻辑

select、cascader、radio、checkbox组件options配置

首先明确options配置需要支持3中方式:

  1. 直接配置对象数组
  2. 配置接口获取
  3. 字典获取

接口定义:

// @/components/BaseForm/interface/index

export interface optionsFace {
  options: {
    [label: string]: any, children?: any[]
  }[]  // 直接传数组对象
  | { type: 'dic'; key: string }   // 字典获取
  | {   // 接口获取
    type: 'api'
    getData: () => Promise<{ [label: string]: any, children?: any[] }[]>  // 必须返回对象数组,不一定是label,value格式
  }
}

解析方式

// @/components/BaseForm/components/BaseSelect

const options = ref<any>([])
const optionsLoading = ref<boolean>(false)
watch(() => props.config.options, async() => {
  if (Array.isArray(props.config.options)) {  // 传入对象数组
    options.value = props.config.options
  } else if (Object.prototype.toString.call(props.config.options) === '[object Object]') {  // 字典/接口获取
    if (props.config.options.type === 'api') {
      optionsLoading.value = true
      options.value = await props.config.options.getData()
      optionsLoading.value = false
    } else if (props.config.options.type === 'dic') {
      options.value = utils.getDicByKey(props.config.options.key)
    }
  }
}, { immediate: true, deep: true })

这里获取的options数组,其中的option项不一定都需要是label、value这种键值对,可以通过labelKey、valueKey来调整,参考接口配置:

// @/components/BaseForm/interface/index

// select组件接口定义
// defaultProps 基础属性定义、optionsFace options获取定义
export interface selectProps extends defaultProps, optionsFace {
  ....
  labelKey?: string
  valueKey?: string
	...
}

坑点

样式问题

表单及表单子组件均使用tsx语法编写,样式修改以及deep深度样式问题处理

vue.config.js增加配置,开启css module

// vue.config.js

...
css: {
  requireModuleExtension: true,   // 样式隔离  文件名必须为 *.module.*  // https://cli.vuejs.org/zh/config/#css-requiremoduleextension
},
...

增加style.module.scss样式,一定要以***.module.scss结尾

.width100 {
  width: 100%;
}

.BaseNumber, .BaseDateRange {
  width: 100%;
  :global(.ep-input-number) {
    width: 100%;
  }
  :global(.ep-input-number .ep-input__wrapper) {
    padding-left: 11px !important;
    padding-right: 11px !important;
  }
  :global(.textLeft .ep-input__inner) {
    text-align: left;
  }
}

.BaseNumberRange {
  width: 100%;
  display: flex;
  .inputNumber {
    flex: 1;
  }
  .noControls {
    :global(.ep-input__inner) {
      text-align: left;
    }
  }
}

.BaseUpload {
  .imgwrap {
    .btn {
      transform: scale(0.5);
      display: none;
      position: absolute;
      top: 0px;
      right: 0px;
    }
    &:hover {
      .btn {
        display: block;
      }
    }
  }
  .fileItem {
    &:hover {
      background: rgba(0,0,0, 0.1);
    }
  }
  .elIconUploadDis {
    width: 100%;
    cursor: not-allowed;
    :global(.ep-upload),
    :global(.ep-upload--picture-card),
    :global(.ep-upload-dragger) {
      width: 100%;
      cursor: not-allowed;
    }
  }
}

组件中导入及使用

// @/components/BaseForm/components/BaseInput
import styles from '@/components/BaseForm/style.module.scss'

// 使用
<div class={['baseNumber', styles.width100, styles.BaseNumber]}>
  <dynamicNumber
    style={{ width: '100%' }}
    class={{ number: true, textLeft: props.config.controls !== true }}
    model-value={props.modelValue}
    placeholder={props.config.placeholder || `请输入${props.config.label}`}
    disabled={!!props.config.disabled}
    controls={props.config.controls === true}
    {...props.config.nativeProps}
    onInput={updateValue}
  />
</div>

深度样式处理:

样式文件中添加:global既可样式穿透

:global(.ep-input-number) {
  width: 100%;
}

tsx中props定义中 as PropType语法报错

解决:修改.eslintrc.js

// .eslintrc.js
...
parserOptions: {
  ...
  parser: '@typescript-eslint/parser'  // 修改或增加此行配置
}

示例地址

https://chenyuhuan.gitee.io/backstage-vue3/#/form

源码及实现浅析

https://blog.csdn.net/junner443/article/details/131302051

作者:快落的小海疼

  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
你可以尝试以下步骤来封装一个Vue 3和TypeScript下使用Element Plus的表单提交组件: 1. 安装必要的依赖: - Vue 3:`npm install vue@next` - TypeScript:`npm install -D typescript` - Element Plus:`npm install element-plus` 2. 创建一个新的Vue组件,并为其选择一个合适的名称,例如`FormSubmit.vue`。 3. 在`FormSubmit.vue`文件中,导入必要的模块和样式: ```vue <template> <!-- 表单内容 --> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { ElButton, ElForm, ElFormItem } from 'element-plus'; export default defineComponent({ components: { ElButton, ElForm, ElFormItem, }, }); </script> <style scoped> /* Element Plus 样式 */ @import 'element-plus/packages/theme-chalk/src/index.scss'; /* 自定义样式 */ /* ... */ </style> ``` 4. 在模板中编写表单内容,并使用Element Plus的组件来构建表单: ```vue <template> <el-form ref="form" :model="formData" label-width="120px"> <el-form-item label="姓名" prop="name"> <el-input v-model="formData.name"></el-input> </el-form-item> <!-- 更多表单项 --> <el-form-item> <el-button type="primary" @click="submitForm">提交</el-button> </el-form-item> </el-form> </template> <script lang="ts"> // ... export default defineComponent({ // ... data() { return { formData: { name: '', // 更多表单字段 } }; }, methods: { submitForm() { // 表单提交逻辑 if (this.$refs.form.validate()) { // 表单验证通过,执行提交操作 // ... } } } }); </script> ``` 这样,你就可以使用封装好的表单提交组件来方便地处理表单提交了。你可以根据实际需求添加更多的表单项,并在`submitForm`方法中实现你的提交逻辑。希望这可以帮到你!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值