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

背景

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

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

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

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

效果图

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

基于vue3+ts配置化form表单组件实现浅析_第1张图片

概要实现逻辑

组件目录

基于vue3+ts配置化form表单组件实现浅析_第2张图片

"食用"例子

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

基于vue3+ts配置化form表单组件实现浅析_第3张图片

调用



上述编码我们即可渲染一个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 &&
      
        
          {
            item.type === 'render' ?  // 自定义render函数(只替换form-item-conent部分,label不会被render)
            item?.render() : // ep-form-item__content 部分的render函数
            componentRender(item)    // 根据item:columnsFormBase中的type属性获取对应的自定义组件
          }
        
      
    }
  
})

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

// @/components/BaseForm/index

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

const componentRender = (item: columnsBase) => {
  const componentInstance = widget.getComponentByType(item)
  return  {
      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 &&
        
          
            ...
          
        
      }
      ....
    
  })
}

表单验证实现

监听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


支持自定义组件

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

{
  label: '测试render',
  prop: 'test',
  type: 'render',
  render: () => {
    return 
  },
  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 && (
   {
      expandFn()
    }}
    >
    {currentExportState.value ? '收起' : '展开'}
  
)}

部分子组件内部逻辑

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([])
const optionsLoading = ref(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'

// 使用

深度样式处理:

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

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

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

基于vue3+ts配置化form表单组件实现浅析_第4张图片

解决:修改.eslintrc.js

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

示例地址

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

源码及实现浅析

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

作者:快落的小海疼

你可能感兴趣的:(vue3组件库,vue.js,elementui,前端)