前言
我的工作主要是做B端的后台管理系统,也因此经常需要与表单打交道。各种乱七八糟的表单写多了之后,就会开始想为啥不用动态生成的形式去生成表单,这样就可以花更多地时间去 划水 研究其他的内容。
之后一段时间陆续找了十几个开源的插件,发现并不能满足自己的全部需求,便产生了自己造个轮子的想法。
开发环境
- Vue.js 2.5.0+
- ElementUI 2.8.0+
如何实现
在初期,我大概总结了我对动态表单的几个要素:
- 组件按需引入
- 表单联动
- 多节点渲染
- 支持双向数据绑定
- 嵌套表单
其中最让我头大的是组件按需引入和表单联动,按需引入是减少包体积的核心举措,表单联动可以最大化地实现复杂业务。如果使用template语法书写会导致表单联动的逻辑变得异常复杂,而如果使用JSX书写,则按需引入则成了一个难题;因此在这里卡了很长一段时间。
转折点是某次蹲坑思考webpack是如何处理.vue文件,忽然想到template语法其实是通过webpack + vue-loader的形式转换成浏览器可以读取的格式,于是通过阅读解析后的代码发现了解决问题的华点:createElement。
// 在.vue文件书写的代码
<template>
<el-form>
<el-form-item label="姓名">张三/el-form-item>
<el-form-item label="性别">男</el-form-item>
</el-form>
</template>
// 通过webpack + vue-loader转换为createElement之后
createElement('el-form', { ref: 'form' }, [
createElement('el-form-item', { attrs: { label: '姓名' } }, '张三'),
createElement('el-form-item', { attrs: { label: '性别' } }, '男'),
])
查阅Vue.js的官方文档(文档链接)之后,可以将上面的代码简化为:
let form = 'el-form',
list = [
{ node: 'el-form-item', label: '姓名', children: '张三' },
{ node: 'el-form-item', label: '性别', children: '男' },
]
return createElement(
form,
{ ref: 'form' },
list.map(i => createElement(i.node, { attrs: { label: i.label } }, i.children))
)
组件按需引入
有了上述的基础之后,按需引入组件就会变得很简单了。
createElement(node, opts, children)
中的node
支持String/Object/Function三种格式,如果组件已在全局声明,可以直接使用String,按需引入则是Object,符合node
的格式要求。同时针对表单项的结构做一下更改,让组件自动生成表单项的el-form-item
,将node转换为需要使用到的组件即可。
// 组件按需引入
import { InputNumber } from 'element-ui'
let list = [
{ node: 'el-input', label: '姓名' },
{ node: InputNumber, label: '年龄' }
]
// 自动生成表单项el-form-item
function renderFormItem(self, h, item) {
return h('el-form-item', { attrs: { label: item.label }, [
h(item.node || 'div', {}, item.children || null)
])
}
return createElement(
form,
{ ref: 'form' },
list.map(item => renderFormItem(this, createElement, item))
)
表单联动
在解决如何组件按需引入之后,表单联动就成了最后一个大问题(和无数的小问题)。表单联动的情况非常多,有表内项互相关联,也有表外项互相联动,不同业务的需求对于表单联动的要求也不一致。
比如在填写员工信息表单时:
- 当员工性别为女性时,婚姻状况为必填,男性则为选填
- 证件类型有身份证、护照两种选择,当证件类型为身份证时,显示身份证号的输入框,隐藏护照的输入框并清空内容,护照同理
这种需求在开发过程中随处可见,但是联动机制也不宜设置地过于复杂,不然后续的维护成本就会变得很大。在尝试了多种情况之后,决定通过工厂函数对组件属性进行基本的封装,将表单值集合form
传入组件的props
作为参数。
const isFunc = (opt) => Object.prototype.toString.call(opt) === '[object Function]'
const isObject = (opt) => Object.prototype.toString.call(opt) === '[object Object]'
let list = [
{ node: 'el-select', label: '证件类型', name: 'card_type',
children: [
{ el-option: 'el-option', props: { label: '身份证', value: 1 } },
{ el-option: 'el-option', props: { label: '护照', value: 2 } },
],
methods: {
change: ({ form, value }) => {
form[ value === 1 ? 'passportno' : 'cardno' ] = ''
}
}
},
{ node: 'el-input', label: '身份证号', name: 'cardno',
isShow: ({ form }) => form.card_type === 1
},
{ node: 'el-input', label: '护照号', name: 'passportno',
isShow: ({ form }) => form.card_type === 2
},
]
// 生成DOM节点
function renderNode(self, h, item) {
let form = Object.assign({}, self.form),
attrs = {},
func = {}
if( isFunc(item.props) ) {
// 使用工厂函数将表单值集合form作为参数传入
// 为了避免随意更改,在以工厂函数调用时冻结form的操作权限
// 约定只有methods/render/itemRender可以直接更改form
attrs = item.props({ form: Object.freeze(form), name: item.name })
}
if( isObject(item.props) ) {
attrs = item.props || {}
}
if( isObject(item.methods) ) {
// 改写Vue.js自带的事件
Object.keys(item.methods).forEach(key => {
func[ key ] = (e) => item.methods[ key ]({ form: self.form, name: item.name, value: e })
})
}
return h(item.node || 'div', { ...attrs, on: func }, item.children || null)
}
// 生成表单项
function renderFormItem(self, h, item) {
if( isFunc(item.isShow) ) {
// 控制是否表单项显示
item.isShow = item.isShow({ form: Object.freeze(form), name: item.name })
}
if( item.isShow !== false ) {
return h('el-form-item', { attrs: { label: item.label }, [
// 引入上述的工厂函数
renderNode(self, h, item)
])
}
return null
}
return createElement(
form,
{ ref: 'form' },
list.map(item => renderFormItem(this, createElement, item))
)
总结
弄到这一步的时候,基本大体的功能就已经实现了,多节点渲染、支持双向数据绑定、嵌套表单的实现相比前两项也就简单了许多 纯粹不想写了 。
不过也有人可能会问 不会有人问的,有没有完整的代码示例或者插件下载呀。
答案是有。不然我写这么多是为了啥
项目地址:www.github.com/aylanbrown/…
求star是不敢的 毕竟bug好多,帮我测测bug就行啦 ୧(﹒︠ᴗ﹒︡)୨