【手撸低代码工具】二次封装UI库(二)表单里的各种组件的封装

1,148 阅读7分钟

上一篇简单介绍了一下想法,那么具体的封装是什么样的呢?又有哪些需要注意的呢?

这里先说说表单里面的那些子组件。

表单里的子组件的封装

为啥要封装?因为我想使用 v-for 的方式生成 el-form-item 里的组件,如果不封装的话,select 需要考虑 option 如何弄,checkbox、radio 等要想办法弄成一组,这还怎么愉快的遍历?

所以需要我们把表单里的子组件封装成统一的风格,这样才好愉快 v-for。

封装后就可以这样:

  <el-form-item
    v-for="(ctrId, index) in colOrder"
    :key="'form_' + ctrId + '_' + index"
    :label="itemMeta[ctrId].meta.label"
    :prop="itemMeta[ctrId].meta.colName"
    :rules="ruleMeta[ctrId] ?? []"
    :label-width="itemMeta[ctrId].meta.labelWidth??''"
  >
    <component
      :is="formItemKey[itemMeta[ctrId].meta.controlType]"
      :model="model"
      :meta="itemMeta[ctrId].meta"
      v-bind="itemMeta[ctrId].props"
    >
    </component>
  </el-form-item>
  • component:使用 Vue3 的动态组件,根据控件的类型,加载对应的子组件。
  • 需要一个 json 文件来配合,把需要的属性都放进去。
  • el-form-item:用 v-for 遍历一下即可,再也不怕表单里的字段多了。

定义接口

表单子组件需要的接口分为几种类型:

  • UI库的组件需要的,对于这些属性,我们要做一个合格的传声筒,只负责传过去,中间不用管。
  • 想要统一设置默认值的属性。比如:clearable。
  • 封装过程中需要的属性,比如 option 需要的数据。
  • meta 需要的属性。

组件的属性

先定义一个总体的属性:

/**
 * 表单的子组件的 props。
 */
export interface IFormItemProps<T extends object> {
  meta: IFormItemMeta,
  model: T,
  optionList?: Array<IOptionList | IOptionTree | IOptionGrup>,
  clearable?: boolean,
  [key: string]: any
}
  • 泛型:用泛型的方式定义 model 的类型,可以更灵活一些。
  • meta:低代码需要的数据,几种存放 meta 需要的各种属性。
  • model:表单的 model,含义多个属性,传入整体的 model,reactive的,这样子组件内部可以直接通过 model 修改对应的属性(字段值),更方便和灵活。
  • optionList:子控件备选项,一级或者多级。option 需要的属性,分为三种:普通的,分组的,树的。
  • clearable:是否显示清空的按钮,默认显示。(UI库默认不显示)
  • UI库里的组件需要的其他属性
    • 通用且需要设置默认值的话,就在这里定义一下。
    • 不通用的,不用在这里定义,在需要的组件里定义即可。
    • 其他的使用 attrs 透传 即可。

option 需要的属性

option 的数据,可以分为三类:普通型、分组、树(多级分组)

普通的

/**
 * 单层的选项,下拉列表等的选项。value、label
 */
export interface IOptionList {
  value: number | string,
  label: string,
  disabled?: boolean
}
  • value:值,提交到后端,可以是数字或者文本
  • label:标签、文字说明
  • disabled:是否可用

分组的

有的时候,分组显示,更清晰一些。

/**
 * 单层分组的选项,下拉列表等的选项。value、label
 */
export interface IOptionGrup {
  label: string,
  options: IOptionList[]
}
  • label:分组的名称
  • options:IOptionList 类型的数组

树,多级的选项

有几种形式:

  • 一个是 el-cascader 这种多级联动的。
  • 一个是 el-tree-select 这种在下拉框里面多级联动的。v2.1.8 新增
  • 还有一个是我自己封装的,在下拉框里直接显示一个展开的树,便于选择,适用于数据小的情况。(好吧,我做的时候,还没有 el-tree-select)
export interface IOptionTree {
  value: idType,
  label: string,
  disabled: boolean,
  children: Array<IOptionTree>
}
  • value:值,提交到后端,可以是数字或者文本
  • label:标签、文字说明
  • disabled:是否可用
  • children:递归的方式定义子选项,数组形式。

一开始 elementPlus 没有提供 树形的下拉列表,后来在 v2.1.8版本里提供了一个 el-tree-select ,看了一下数据结构,也就这种形式。

meta 需要的属性

meta,也叫元数据,也可以叫做配置信息,就是存放实现低代码的方式所需要的各种数据。

export interface IFormItemMeta {
  columnId?: idType,
  colName: string,
  label?: string,
  controlType?: EControlType | number,
  defValue?: any,
  colCount?: number,
  webapi?: IWebAPI,
  delay?: number
}
  • columnId: 字段ID、控件ID。前端的数据,一般不使用ID作为关联依据,但是数据复杂的时候,这种ID就非常关键了。
  • colName: 字段名称,model 的属性名称,也是很重要的属性。
  • label: 字段的中文名称,标签。
  • controlType: 子控件类型,即 input、select等,不过这里采用数字的方式,这样可以相对来说更稳定一些。
  • defValue: 子控件的默认值。
  • colCount: 一个控件占据的空间份数,做多列的表单的时候使用。
  • webapi: 访问后端API的配置信息,有备选项的控件需要。简单的选项可以直接放在json里面,复杂的,或者动态的就需要到后端动态获取,所以需要设置后端API的一些参数。
  • delay:防抖延迟时间,0:不延迟。一般用于查询的时候。

表单里的组件的类型

原生表单里的 dom 的类型,就比较随意(无语),比如 input 对应的不全是文本,而是各种可以输入的类型,比如单行文本、颜色、radio、checkbox等,这些都属于 input 里的 type。而多行文本(文本域)却又是另一个 dom。

而 UI库,基本没惯着原生的习惯,而是按照自己的方式设置组件,比如 el-input,包括单行、多行、密码等。颜色、数字等作为单独的组件。

所以,我们怎么分类呢?

一开始做的粒度非常细致,一个小功能就是一个组件,结果弄了一大堆.vue文件,虽然是灵活了,但是维护起来就有多酸爽。

当然也不能都弄到一个组件里,这样更乱,所以只好折中一下,只是这个标准不会掌握。

设置组件的 controller

定义完了属性的 Interface,我们还要做一个 controller,把各个组件需要的通用操作集中在一起。

组件的 value,并不是简单的绑定上 model[key] 就行了,还需要考虑一下防抖功能、一个组件对应多个字段的功能,还需要设置初始值等。

// 不防抖
import modelRef from './ref/ref-model'
// 防抖
import modelDebounceRef from './ref/ref-model-debounce'
// 多字段
import modelRangeRef from './ref/ref-model-range'

/**
 * 表单子控件的控制函数
 * @param props 组件的 props,T 对应 model 的类型
 * @returns 返回一个 Ref<T>。
 */
export default function itemController<T extends object>(
  props: IFormItemProps<T>
) {

  // 从 props 里面获取 model 和属性
  const { model, meta } = props

  // 父子组件传值的中转站,根据需求调用对应的函数。 
  let value: Ref<T> | Ref<T[keyof T]> | Ref<Array<T>>  

  // 判断是否多字段
  let isMoreColumn = false
  const colName = meta.colName // 获取字段名称

  let tmpArrayColName: Array<string> = [] // 拆分后的字段名称
  if (typeof colName === 'string') {
    tmpArrayColName = colName.split('_')
    // 需要支持多个字段
    isMoreColumn = (tmpArrayColName.length > 1)
  }

  // 判断是否需要多字段(无防抖)
  if (isMoreColumn && tmpArrayColName) {
    // 一个字段对应多个字段的情况,不防抖
    value = modelRangeRef(model, ...tmpArrayColName) 
  } else {
    // 判断防抖
    if (meta.delay && meta.delay > 0) {
      // 需要防抖
      value = modelDebounceRef(model, colName, ctlEvents, meta.delay)
    } else {
      // 不需要防抖
      value = modelRef(model, colName)
    }
  }
  
  // 设置默认值
  if (meta.defValue) {
    value.value = meta.defValue
  }

  return {
    value 
  }
}

我们做个约定:如果需要对应多个字段,那么用下划线(_)连接多个字段名。

比如 province_city,表示一个级联的组件对应省份和城市两个字段。

有时候代码稍微多一点就不知道要如何解释,基本思路就是:传入组件的 props,从中获取 meta 和 model。

然后判断一下是否需要防抖、和支持多字段。依据判断结果调用对应的函数创建 value,用于绑定组件 的 v-model。

最后设置一下默认值,基本就搞定了。

封装的具体方式

我们来看看具体的封装方式,以文本和日期为例,其他大同小异。

文本类

我们先来一个简单的:

<template>
  <!--颜色-->
  <el-color-picker
    v-if="meta.controlType === 108"
    v-model="value"
    v-bind="$attrs"
  >
  </el-color-picker>
  <!--密码-->
  <el-input
    v-else-if="meta.controlType === 102"
    v-model="value"
    v-bind="$attrs"
    :clearable="clearable"
    show-password
  >
  </el-input>
  <!--单行文本、多行文本、email、tel、search-->
  <el-input v-else
    v-model="value"
    v-bind="$attrs"
    :type="inputType[meta.controlType]"
    :clearable="clearable"
    :prefix-icon="prefixIcon"
    :suffix-icon="suffixIcon"
    @blur="run"
    @change="run"
    @clear="run"
    @keydown="clear"
  >
  </el-input>
</template>
  • 本组件包括的类型:单行文本、多行文本、密码、颜色等做成一个组件,内部用 v-if 判断控件的类型,似乎好像有点不好看,这样可以避免弄出来一大堆组件,看着也是比较烦的。
  • 类型的标记方式:这里使用数字的方式,因为个人比较喜欢,虽然有时候也记不住数字对应的组件类型,但是看看设定文档,也就想起来了,或者可以用TS的 enum 来做一个,这样就不是魔数了,不知道 template里面是否支持。
  • 图标:el-input 支持加入一些图标,图标类型是 string 或者是图标组件
  • 事件:辅助实现防抖功能。
<script setup lang="ts" generic="T extends object">
  import type { IFormItemProps } from '../map'
  import { itemController } from '../map'
 
  // 定义 文本类的类型
  const inputType = {
    100: 'textarea',
    101: 'text',
    102: 'password',
    103: 'email', // 没特效
    104: 'tel', // 没特效
    105: 'url', // 请使用 t-url
    106: 'search', // 没特效
    107: 'autocomplete', // 不支持,请使用 t-autocomplete
    108: 'color'
  }
  // 定义 props,设置默认值
  const props = withDefaults(defineProps<IFormItemProps<T> & {
    prefixIcon?: string,
    suffixIcon?: string
  }>(), {
    clearable: true,
    prefixIcon: '',
    suffixIcon: ''
  })

  // 使用 controller
  const { value, run, clear } = itemController(props)
</script>

基本比较简洁吧,引入 Interface,定义 props、设置默认值,引入 controller,获取 value 等。然后绑定即可。

关于图标的问题

研究了一下,如果传入字符串的话,需要把对应的图标组件在组件内注册,或者干脆注册为全局组件。

如果是常规方式开发项目,怎么做都行,那么在低代码里面要如何实现呢?

因为meta 需要存放在 json 里面,这样就无法存放图标组件,只能存放图标的名称。

所以我们可以把需要的图标组件都注册为全局组件,这样传入字符串形式的图标,就可以使用了。

// 引入需要的图标
import {
  CloseBold,
  Close,
  Plus,
  ...
} from '@element-plus/icons-vue'

// 放到字典里
const dictIcon = {
  CloseBold,
  Close,
  Plus,
  ...
}

const installIcon = (app: any) => {
  // 注册为全局图标组件
  Object.keys(dictIcon).forEach(key => {
    app.component(key, dictIcon[key])
  })
}

export default installIcon

这是一个Vue的插件,我们可以在 main 里面挂载这个插件:app.use(installIcon)。这样就可以变成全局组件了。

日期类

我们再对比看一下日期类的组件的封装方式。

<template>
  <el-date-picker
    ref="domDate"
    v-model="value"
    v-bind="$attrs"
    :type="dateType[meta.controlType]"
    :name="'c' + meta.columnId"
    :format="myformat"
    :value-format="myvalueFormat"
    :clearable="clearable"
  >
  </el-date-picker>
</template>

el-date-picker 支持很多种形态,比如:日期、日期 + 时间、年月、年周、年、日期范围等。所以这里就不用 v-if 来区分了。

<script setup lang="ts" generic="T extends object">
  import { computed, watch } from 'vue'
  import type { IFormItemProps } from '../map'
  import { itemController } from '../map'

  defineOptions({
    name: 'el-from-item-date-picker',
    inheritAttrs: false,
  })

  // 类型的字典
  const dateType = {
    120: 'date',
    121: 'datetime',
    122: 'month',
    123: 'week',
    124: 'year',
    125: 'daterange',
    126: 'datetimerange',
    127: 'monthrange',
    128: 'dates'
  }

  // 根据类型设置默认格式化
  const dateProps = {
    120: { format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD'},
    121: { format: 'YYYY-MM-DD HH:mm:ss', valueFormat: 'YYYY-MM-DD HH:mm:ss'},
    122: { format: 'YYYY-MM', valueFormat: 'YYYY-MM'},
    123: { format: 'gggg-ww', valueFormat: 'gggg-ww'},
    124: { format: 'YYYY', valueFormat: 'YYYY'},
    125: { format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD'},
    126: { format: 'YYYY-MM-DD HH:mm:ss', valueFormat: 'YYYY-MM-DD HH:mm:ss'},
    127: { format: 'YYYY-MM', valueFormat: 'YYYY-MM'},
    128: { format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD'}
  }
  // 增加两个属性
  const props = withDefaults(defineProps<IFormItemProps<T> & {
    format?: string,
    valueFormat?: string
  }>(), {
    clearable: true
  })

  const { value } = itemController(props )

  // 设置默认的格式化
  const myformat = computed(() => {
    if (props.format !== '') {
      return props.format
    } else {
      return dateProps[(props.meta.controlType as number)].format
    }
  })
  const myvalueFormat = computed(() => {
    switch(props.valueFormat){
      case undefined:
      case '': // 没有设置,根据详细类型返回默认格式化
        return dateProps[(props.meta.controlType as number)].valueFormat
      case 'date': // 返回日期类型
        return undefined
      default: // 返回设置的格式化
        return props.valueFormat
    }
  })
</script>

因为不同的类型需要不同的格式化的方式,期待使用控件类型来推断格式化,但是控件类型还没有传入,所以只好再用一个 computed 来变更不同的格式化方式了。

  • 类型字典:把控件类型的编号和 el-date-picker 的 type 关联起来,便于设置。

其他组件

其他各种组件,也是类似的封装方式,就不一一列举了。感兴趣的话可以看看源码:

gitee.com/naturefw-co…

在线演示: naturefw-code.gitee.io/nf-rollup-u…

温馨提示:这个 value,可以是对象类型哦。

扩展子组件

表单里需要的组件类型是非常多的,封装的时候肯定不能做穷举,那可以说是无穷无尽的,所以需要支持扩展。

也就是说,我们可以自己做一个子组件,然后列入到表单子组件的列表里面。

这也是定义 Interface 和 controller 的好处,我们可以依据 Interface 来封装自己需要的子组件。

至于如何加入,其实至少有三种:

  • slot
  • props
  • 列表

具体的以后再说。