一个成熟的表单
表单表单,你已经长大了,你要学会:
- 动态渲染
- 支持单列、双列、多列
- 支持调整布局
- 支持表单验证
- 支持调整排列(显示)顺序
- 依据组件值显示需要的组件
- 支持 item 扩展组件
- 可以自动创建 model
这个表单控件是基于 element-plus 的 el-form 做的二次封装,所以首先感谢 element-plus 提供了这么强大的UI库,以前用 jQuery 做过类似的,但是非常麻烦,既不好看,可维护性、扩展性也差,好多想法都实现不了(技术有限)。
现在好了,站在巨人的肩膀上,实现自己的想法了。
实现动态渲染
把表单需要的属性,统统放入json里面,然后用require(方便) 或者aioxs(可以热更新)加载进来,这样就可以实现动态渲染了。 比如要实现公司信息的添加、修改,那么只需要加载公司信息需要的json即可。 想要实现员工信息的添加、修改,那么只需要加载员工信息需要的json。
总之,加载需要的json即可,不需要再一遍一遍的手撸代码了。
那么这个神奇的 json 是啥样子的呢?文件有点长,直接看截图,更清晰一些。
另外还有几个附带功能:
- 支持单行下的合并。
在单行的情况下,一些短的控件会比较占空间,我们可以把多个小的合并到一行。
- 支持多行下的扩展。
多行的情况下,一些长的控件需要占更多的空间,我们可以设置它多占几个格子。
- 自动创建表单需要的 model。
不需要手动写 model了。
实现多行多列的表单
再次感谢 el-form,真的很强大,不仅好看,还提供了验证功能,还有很多其他的功能。 只是好像只能横着排,或者竖着排。那么能不能多行多列呢?似乎没有直接提供。
我们知道 el-row、el-col 可以实现多行多列的功能,那么能不能结合一下呢?官网也不直说,害的我各种找,还好找到了。(好吧,其实折腾了一阵着的table)
二者结合一下就可以了,这里有个小技巧,el-row 只需要一个就可以,el-col 可以有多个,这样一行排满后,会自动排到下一行。
<el-form ref="form" :inline="false" class="demo-form-inline" :model="formModel" label-suffix=":" label-width="130px" size="mini" > <el-row> <!--不循环row,直接循环col,放不下会自动往下换行。--> <el-col v-for="(ctrId, index) in formColSort" :key="'form_'+index" :span="formColSpan[ctrId]" > <el-form-item :label="getCtrMeta(ctrId).label"> <!--表单item组件,采用动态组件的方式--> <component :is="ctlList[getCtrMeta(ctrId).controlType]" v-model="formModel[getCtrMeta(ctrId).colName]" :meta="getCtrMeta(ctrId)" @myChange="mySubmit"> </component> </el-form-item> </el-col> </el-row> </el-form> 复制代码
- formColSort
存放组件ID的数组,决定了显示哪些组件以及显示的先后顺序。
- v-for
遍历 formColSort 得到组件ID,然后获取ID对应的span(确定占位)以及组件需要的meta。
- formColSpan
存放组件占位的数组。依据el-col的span的24格设定。
- getCtrMeta(ctrId)
根据组件ID获取组件的meta。 为啥要写个函数呢?因为model的属性不允许中括号套娃,所以只好写个函数。 为啥不用计算属性呢?计算属性好像不能传递参数。
- component :is="xxx"
Vue提供的动态组件,用这个可以方便加载不同类型的子组件。
- ctlList
组件字典,把组件类型变成对应的组件标签。
这样一个v-for搞定了很多事情,比如单列、多列,组件的排序问题,组件的占位问题,还有依据用户的选择显示不同的组件的问题,其实就是修改一下 formColSort 里的组件ID的构成和顺序。
自动创建 model
我比较懒,手撸 model 是不是有点麻烦?如果能够自动获得该多好,于是我写了这个函数。
// 根据表单元素meta,创建 v-model const createModel = () => { // 依据meta,创建module for (const key in formItemMeta) { const m = formItemMeta[key] // 根据控件类型设置属性值 switch (m.controlType) { case 100: // 文本类 case 101: case 102: case 103: case 104: case 105: case 106: case 107: case 130: case 131: formModel[m.colName] = '' break case 110: // 日期 case 111: // 日期时间 case 112: // 年月 case 114: // 年 case 113: // 年周 formModel[m.colName] = null break case 115: // 任意时间 formModel[m.colName] = '00:00:00' break case 116: // 选择时间 formModel[m.colName] = '00:00' break case 120: // 数字 case 121: formModel[m.colName] = 0 break case 150: // 勾选 case 151: // 开关 formModel[m.colName] = false break case 153: // 单选组 case 160: // 下拉单选 case 162: // 下拉联动 formModel[m.colName] = null break case 152: // 多选组 case 161: // 下拉多选 formModel[m.colName] = [] break } // 看看有没有设置默认值 if (typeof m.defaultValue !== 'undefined') { switch (m.defaultValue) { case '': break case '{}': formModel[m.colName] = {} break case '[]': formModel[m.colName] = [] break case 'date': formModel[m.colName] = new Date() break default: formModel[m.colName] = m.defaultValue break } } } // 同步父组件的v-model context.emit('update:modelValue', formModel) return formModel } 复制代码
可以根据类型和默认值,设置 model 的属性,这样就方便多了。
创建用户选择的 model
就是用户选了某个选项,表单的组件响应变化后的model。 在我的计划里面是需要一个这样的简单的model,所以我又写了一个函数
// 依据用户选项,创建对应的 model const createPartModel = (array) => { // 先删除属性 for (const key in formPartModel) { delete formPartModel[key] } // 建立新属性 for (let i = 0; i < array.length; i++) { const colName = formItemMeta[array[i]].colName formPartModel[colName] = formModel[colName] } } 复制代码
这样就可以得到一个简洁的 model 了。
多列的表单
这个是最复杂的,分为两种情况:单列的挤一挤、多列的抢位置。
单列
单列的表单有一个特点,一行比较宽松,那么有时候就需要两个组件在一行里显示,其他的还是一行一个组件,那么要如何调整呢?
这里做一个设定:
- 一个组件一行的,记做1
- 两个组件挤一行的,记做-2
- 三个组件挤一行的,记做-3
以此类推,理论上最多支持 -24,当然实际上似乎没有这么宽的显示器。
这样记录之后,我们就可以判断,≥1的记做span=24,负数的,用24去除,得到的就是span的数字。当然记得取整数。
为啥用负数做标记呢?就是为了区分开多列的调整。
多列
调多了之后发现一个问题,看起来和单列调整后似乎一样的。
多列的表单有一个特点,一个格子比较小,有些组件太长放不下,这个时候这个组件就要抢后面的格子来用。
那么我们还是做一个设定:
- 一个组件占一格的,还是记做1
- 一个组件占两格的,记做2
- 一个组件占三格的,记做3
以此类推。
这样记录之后,我们可以判断,≤1的,记做 24 / 列数,大于1的记做 24/ 列数 * n。 这样就可以了,可以兼容单列的设置,不用因为单列变多列而调整设置。 只是有个小麻烦,占得格子太多的话,就会提取挤到下一行,而本行会出现“空缺”。 这个暂时靠人工调整吧。 毕竟哪个字段在前面,还是需要人工设置的。
一顿分析猛如虎,一看代码没几行。
// 根据配置里面的colCount,设置 formColSpan const setFormColSpan = () => { const formColCount = formMeta.formColCount // 列数 const moreColSpan = 24 / formColCount // 一个格子占多少份 if (formColCount === 1) { // 一列的情况 for (const key in formItemMeta) { const m = formItemMeta[key] if (typeof m.colCount === 'undefined') { formColSpan[m.controlId] = moreColSpan } else { if (m.colCount >= 1) { // 单列,多占的也只有24格 formColSpan[m.controlId] = moreColSpan } else if (m.colCount < 0) { // 挤一挤的情况, 24 除以 占的份数 formColSpan[m.controlId] = moreColSpan / (0 - m.colCount) } } } } else { // 多列的情况 for (const key in formItemMeta) { const m = formItemMeta[key] if (typeof m.colCount === 'undefined') { formColSpan[m.controlId] = moreColSpan } else { if (m.colCount < 0 || m.colCount === 1) { // 多列,挤一挤的占一份 formColSpan[m.controlId] = moreColSpan } else if (m.colCount > 1) { // 多列,占的格子数 * 份数 formColSpan[m.controlId] = moreColSpan * m.colCount } } } } } 复制代码
最后看看效果,可以动态设置列数:
依据用户的选择,显示对应的组件
这个也是一个急需的功能,否则的话,动态渲染的表单控件适应性就会受到限制。 其实想想也不难,就是改一下 formColSort 里面的组件ID就好了。 我们设置一个watch来监听组件值的变化,然后把需要的组件ID设置给 formColSort 就可以了。
// 监听组件值的变化,调整组件的显示以及显示顺序 if (typeof formMeta.formColShow !== 'undefined') { for (const key in formMeta.formColShow) { const ctl = formMeta.formColShow[key] const colName = formItemMeta[key].colName watch(() => formModel[colName], (v1, v2) => { if (typeof ctl[v1] === 'undefined') { // 没有设定,显示默认组件 setFormColSort() } else { // 按照设定显示组件 setFormColSort(ctl[v1]) // 设置部分的 model createPartModel(ctl[v1]) } }) } } 复制代码
因为需要监听的组件可能不只一个,所以做了个循环,这样就可以监听所有需要的组件了。
看看效果www.zhihu.com/zvideo/1347…
完整代码
上面的代码比较凌乱,这里整体介绍一下。
- el-form-manage.js
表单组件的管理类,单独拿出来,这样就可以支持其他UI库了,比如antdv
import { reactive, watch } from 'vue' /** * 表单的管理类 * * 创建v-model * * 调整列数 * * 合并 */ const formManage = (props, context) => { // 定义 完整的 v-model const formModel = reactive({}) // 定义局部的 model const formPartModel = reactive({}) // 确定一个组件占用几个格子 const formColSpan = reactive({}) // 定义排序依据 const formColSort = reactive([]) // 获取表单meta const formMeta = props.meta console.log('formMeta', formMeta) // 表单元素meta const formItemMeta = formMeta.itemMeta // 表单验证meta,备用 // const formRuleMeta = formMeta.ruleMeta // 根据表单元素meta,创建 v-model const createModel = () => { // 依据meta,创建module for (const key in formItemMeta) { const m = formItemMeta[key] // 根据控件类型设置属性值 switch (m.controlType) { case 100: // 文本类 case 101: case 102: case 103: case 104: case 105: case 106: case 107: case 130: case 131: formModel[m.colName] = '' break case 110: // 日期 case 111: // 日期时间 case 112: // 年月 case 114: // 年 case 113: // 年周 formModel[m.colName] = null break case 115: // 任意时间 formModel[m.colName] = '00:00:00' break case 116: // 选择时间 formModel[m.colName] = '00:00' break case 120: // 数字 case 121: formModel[m.colName] = 0 break case 150: // 勾选 case 151: // 开关 formModel[m.colName] = false break case 153: // 单选组 case 160: // 下拉单选 case 162: // 下拉联动 formModel[m.colName] = null break case 152: // 多选组 case 161: // 下拉多选 formModel[m.colName] = [] break } // 看看有没有设置默认值 if (typeof m.defaultValue !== 'undefined') { switch (m.defaultValue) { case '': break case '{}': formModel[m.colName] = {} break case '[]': formModel[m.colName] = [] break case 'date': formModel[m.colName] = new Date() break default: formModel[m.colName] = m.defaultValue break } } } // 同步父组件的v-model context.emit('update:modelValue', formModel) return formModel } // 先运行一次 createModel() // 向父组件提交 model const mySubmit = (val, controlId, colName) => { context.emit('update:modelValue', formModel) // 同步到部分model if (typeof formPartModel[colName] !== 'undefined') { formPartModel[colName] = formModel[colName] } context.emit('update:partModel', formPartModel) } // 依据用户选项,创建对应的 model const createPartModel = (array) => { // 先删除属性 for (const key in formPartModel) { delete formPartModel[key] } // 建立新属性 for (let i = 0; i < array.length; i++) { const colName = formItemMeta[array[i]].colName formPartModel[colName] = formModel[colName] } } // 根据配置里面的colCount,设置 formColSpan const setFormColSpan = () => { const formColCount = formMeta.formColCount // 列数 const moreColSpan = 24 / formColCount // 一个格子占多少份 if (formColCount === 1) { // 一列的情况 for (const key in formItemMeta) { const m = formItemMeta[key] if (typeof m.colCount === 'undefined') { formColSpan[m.controlId] = moreColSpan } else { if (m.colCount >= 1) { // 单列,多占的也只有24格 formColSpan[m.controlId] = moreColSpan } else if (m.colCount < 0) { // 挤一挤的情况, 24 除以 占的份数 formColSpan[m.controlId] = moreColSpan / (0 - m.colCount) } } } } else { // 多列的情况 for (const key in formItemMeta) { const m = formItemMeta[key] if (typeof m.colCount === 'undefined') { formColSpan[m.controlId] = moreColSpan } else { if (m.colCount < 0 || m.colCount === 1) { // 多列,挤一挤的占一份 formColSpan[m.controlId] = moreColSpan } else if (m.colCount > 1) { // 多列,占的格子数 * 份数 formColSpan[m.controlId] = moreColSpan * m.colCount } } } } } // 先运行一次 setFormColSpan() // 设置组件的显示顺序 const setFormColSort = (array = formMeta.colOrder) => { formColSort.length = 0 formColSort.push(...array) } // 先运行一下 setFormColSort() // 监听组件值的变化,调整组件的显示以及显示顺序 if (typeof formMeta.formColShow !== 'undefined') { for (const key in formMeta.formColShow) { const ctl = formMeta.formColShow[key] const colName = formItemMeta[key].colName watch(() => formModel[colName], (v1, v2) => { if (typeof ctl[v1] === 'undefined') { // 没有设定,显示默认组件 setFormColSort() } else { // 按照设定显示组件 setFormColSort(ctl[v1]) // 设置部分的 model createPartModel(ctl[v1]) } }) } } return { // 对象 formModel, // v-model createModel() formPartModel, // 用户选择的组件的 model formColSpan, // 确定组件占位 formColSort, // 确定组件排序 // 函数 createModel, // 创建 v-model setFormColSpan, // 设置组件占位 setFormColSort, // 设置组件排序 mySubmit // 提交 } } export default formManage 复制代码
- el-form-map.js
动态组件需要的字典
import { defineAsyncComponent } from 'vue' /** * 组件里面注册控件用 * * 文本 * ** eltext 单行文本、电话、邮件、搜索 * ** elarea 多行文本 * ** elurl * * 数字 * ** elnumber 数字 * ** elrange 滑块 * * 日期 * ** eldate 日期、年月、年周、年 * ** eltime 时间 * * 选择 * ** elcheckbox 勾选 * ** elswitch 开关 * ** elcheckboxs 多选组 * ** elradios 单选组 * ** elselect 下拉选择 */ const formItemList = { // 文本类 defineComponent eltext: defineAsyncComponent(() => import('./t-text.vue')), elarea: defineAsyncComponent(() => import('./t-area.vue')), elurl: defineAsyncComponent(() => import('./t-url.vue')), // 数字 elnumber: defineAsyncComponent(() => import('./n-number.vue')), elrange: defineAsyncComponent(() => import('./n-range.vue')), // 日期、时间 eldate: defineAsyncComponent(() => import('./d-date.vue')), eltime: defineAsyncComponent(() => import('./d-time.vue')), // 选择、开关 elcheckbox: defineAsyncComponent(() => import('./s-checkbox.vue')), elswitch: defineAsyncComponent(() => import('./s-switch.vue')), elcheckboxs: defineAsyncComponent(() => import('./s-checkboxs.vue')), elradios: defineAsyncComponent(() => import('./s-radios.vue')), elselect: defineAsyncComponent(() => import('./s-select.vue')), elselwrite: defineAsyncComponent(() => import('./s-selwrite.vue')) } /** * 动态组件的字典,便于v-for循环里面设置控件 */ const formItemListKey = { // 文本类 100: formItemList.elarea, // 多行文本 101: formItemList.eltext, // 单行文本 102: formItemList.eltext, // 密码 103: formItemList.eltext, // 电话 104: formItemList.eltext, // 邮件 105: formItemList.elurl, // url 106: formItemList.eltext, // 搜索 // 数字 120: formItemList.elnumber, // 数组 121: formItemList.elrange, // 滑块 // 日期、时间 110: formItemList.eldate, // 日期 111: formItemList.eldate, // 日期 + 时间 112: formItemList.eldate, // 年月 113: formItemList.eldate, // 年周 114: formItemList.eldate, // 年 115: formItemList.eltime, // 任意时间 116: formItemList.eltime, // 选择固定时间 // 选择、开关 150: formItemList.elcheckbox, // 勾选 151: formItemList.elswitch, // 开关 152: formItemList.elcheckboxs, // 多选组 153: formItemList.elradios, // 单选组 160: formItemList.elselect, // 下拉 161: formItemList.elselwrite, // 下拉多选 162: formItemList.elselect // 下拉联动 } export default { formItemList, formItemListKey } 复制代码
- el-form-div.vue
表单控件的代码 模板
<div > <el-form ref="form" :inline="false" class="demo-form-inline" :model="formModel" label-suffix=":" label-width="130px" size="mini" > <el-row> <!--不循环row,直接循环col,放不下会自动往下换行。--> <el-col v-for="(ctrId, index) in formColSort" :key="'form_'+index" :span="formColSpan[ctrId]" > <el-form-item :label="getCtrMeta(ctrId).label"> <!--表单item组件,采用动态组件的方式--> <component :is="ctlList[getCtrMeta(ctrId).controlType]" v-model="formModel[getCtrMeta(ctrId).colName]" :meta="getCtrMeta(ctrId)" @myChange="mySubmit"> </component> </el-form-item> </el-col> </el-row> </el-form> </div> 复制代码
js
import { watch } from 'vue' import elFormConfig from '@/components/nf-el-form/el-form-map.js' import formManage from '@/components/nf-el-form/el-form-manage.js' export default { name: 'el-form-div', components: { ...elFormConfig.formItemList }, props: { modelValue: Object, partModel: Object, meta: Object }, setup (props, context) { // 控件字典 const ctlList = elFormConfig.formItemListKey // 表单管理类 const { formModel, // 依据meta,创建 Model formColSpan, // 依据meta,创建 span formColSort, setFormColSpan, setFormColSort, // 设置组件排序 mySubmit } = formManage(props, context) // 监听列数的变化 watch(() => props.meta.formColCount, (v1, v2) => { setFormColSpan() }) // 监听reload watch(() => props.meta.reload, (v1, v2) => { setFormColSpan() setFormColSort() }) // 监听组件值的变化, // 依据ID获取组件的meta,因为model不支持【】嵌套 const getCtrMeta = (id) => { return props.meta.itemMeta[id] || {} } return { formModel, formColSpan, formColSort, ctlList, getCtrMeta, mySubmit } } } 复制代码
这里就简单多了,因为实现具体功能的js代码都分离出去了。要么做成子组件,要么组成独立的js文件。 这里主要就是负责重新渲染表单组件。
表单验证
这个使用 el-form 提供的验证功能。 目前暂时还没有归纳好 el-form 的验证,因为需要把这个验证用的数据写入到json里面,然后读取出来设置好即可。 所以肯定没难度,只是需要点时间。
支持 扩展组件
自带的组件肯定是不够的,因为用户的需求总是千变万化的,那么新组件如何加入到表单控件里面呢?可以按照接口定义封装成符合要求的组件,然后做一个map字典,就可以设置进去了。
因为接口统一,所以可以适应表单控件的调用。
简单的方法是,直接修改两个js文件。 如果不方便修改的话,也可以通过属性传递进来。目前暂时还没有想好细节,不过似乎不是太难。