背景
B端中台项目开发中,表单的开发,是家常便饭的事儿。一般会涉及到大量的重复性工作:
- 字段模板编写
- 字段规则编写
- 字段属性的定制与配置
- 表单联动处理
- 编辑(编辑、新增同时可以理解为编辑态)、查看态页面编写
- 数据抹平适配处理
前端编写表单同时,复杂场景下还需要配置一些结构下发前端来解析、服务端也需要编写字段校验规则。
伴随业务变更、复杂化、场景动态化视图(现在负责的项目中,工单系统,常规下几百个表单项,复杂场景下1000+表单字段,没错1000+?)、前后端开发同步不及时的情况,前后端配置、校验规则可能会存在不一致的问题。
因此,开发一套满足前后端均可配置的配置化表单方案,用来减少团队重复性工作,是必须执行的一个技术方案。
开发现状
现阶段,团队项目技术栈是React,主要使用Antd 3/ Antd 4来支持业务开发。
整体上,表单常规开发不出下面几种代码片段:
3版本方案原始代码
常规布局
多列布局
4版本方案原始代码
常规布局
多列布局
日常开发中,少量几个字段开发还可勉强code,出现几十、上百、上千后,整体代码大量得复制粘贴改一改,开发同学也从前端developer升级优秀的前端copier?。
设计、开发实现一套优雅、扩展性强得配置化的表单方案,是需要解决得问题。
目标
整体上,从完整的方案角度来看,需要梳理下以下实现:
常规实现
- Antd基础类型的可配置化
- Antd元素校验配置
- 不同表单配置的属性处理(比如:valueProp不同字段的配置)
- 不同表单元素类型展示UI出现错乱的问题
- 元素布局方式
增强方案
- 数据:数据自动注入、数据类型抹平
- 类型增强:.trim能力(借鉴vue中.trim语法)、text类型、html、hidden类型等增强
- 表单级联
- 自定义模板、自定义组件注册
- 非表单元素的嵌入
- 服务端化下发的配置的解析(其中主要是,校验规则中的正则的处理)
个性化
- 组件继承
- 参数、属性全局配置
- 布局、继承的全局配置
- 状态:编辑态/查看态的适配
- 接下来,奔着整体实现来设计我们的技术方案。
方案设计
Schema抽离
从原始的Antd 3/4原始开发代码角度出发,进行抽离。
1、对代码片段进行结构抽离:
2、模型形成
3、形成基础版本Schema
Antd 3
Antd 4
常规实现
- 整体上,常规类型均已内置。现在情况下,提供近30种表单类型
- 对于单checkbox、switch、upload等配置时,抹平valuePropName配置
- 解决不同表单元素类型展示UI出现错乱的问题
- 基础布局可配置
增强方案
input/textarea组件的增强
- 增加.trim语法糖,扩展antd中input/textarea缺少trim能力
- input.trim/textarea.trim作为内置类型
数据处理
数据自动注入
initialValue需要绑定每个字段,提供data配置,表单元素通过id自动绑注入initialValue
数据抹平
对于时间、日期类组件,antd需要moment类型。通过类型处理,自动转换数据为组件所需类型,开发者无需感知
表单级联
- 基础结构上,增加logic层,完成级联实现。级联的实现,不仅简单的展示、隐藏这种,可以做到元素的全更新(比如:类型、展示、事件绑定、校验规则等等)
- 根据开发者配置的test规则,匹配后,自动合并元素配置,完成级联逻辑的生效
- 支持string/[]/object类型多种场景配置
自定义组件注册
- 提供register方法,支持用户自定义组件的注册
- 3版本中使用hooks组件: 支持hooks类型组件的注册
自定义模板支持
支持自定义模板嵌入 支持非表单元素嵌入
个性化
- 组件继承:提供extends配置,扩展表单元素
- 增加setGlobalConfig,进行属性、参数、继承等全局配置
- 表单状态:增加status字段,标记视图编辑、查看态,完成1份json两份视图的展示
整体方案完成后,单元素配置如下图:
工作原理
项目运行流程
整体使用情况
当前方案在所属团队,经过2年多沉淀与打磨,已接入20+工作台,完成表单、视图的快速开发。
项目遇到问题
问题
开发者项目使用了babel-import插件来进行antd的异步加载,使用item-generator后,系统样式丢失。
原因
babel-import对antd跟踪第一层引用,不会再额外处理node_modules中组件的依赖。
解决方案
提供item-generator/lib/Style模块,对于按需引入antd的项目,提供依赖组件的样式供开发者引入。
后期规划
2021,整体的一个大方向规划是:可视化。
通过可视化方式拖拽、配置,实现可嵌套的栅格布局界面,完成开发、需求方的快速页面配置。
完整代码片段(以3版本为例)
` import React, { PureComponent } from 'react'; import { Form, Button, Row } from 'antd'; import ItemGenerator, { setGlobalConfig } from 'item-generator'; import City from './City';
// 设置全局配置 setGlobalConfig({ params: { showPleaseSel: false, // 不显示select的【请选择】选项 label: 'value', // 所有配置类数据的展示文本 value: 'id' // 所有配置类数据的值 }, colProps: { span: 12 // 全局表单布局,全局为2列布局 }, extends: { inputRequired: { item: { options: { rules: [ { required: true, message: '请输入' } ] } } }, selectRequired: { item: { options: { rules: [ { required: true, message: '请选择' } ] } } } } });
// 注册自定义组件 register('city', City);
// 注册hooks组件 register('hooks', Hooks, true);
class Test extends PureComponent { state = { status: 1, colable: true };
btnClicked = (status) => {
this.setState({
status
});
};
resetForm = () => {
const { form } = this.props;
form.resetFields();
};
config = [
{
id: 1,
value: '未成年人',
children: [
{
id: 10,
value: '0-10岁'
}
]
},
{
id: 2,
value: '成年人',
children: [
{
id: 20,
value: '16-60岁'
},
{
id: 21,
value: '60岁以上'
}
]
},
{
id: 3,
value: '未知'
}
];
query = () => {
const { form } = this.props;
console.log('表单数据:', form.getFieldsValue());
};
render() {
const { form } = this.props;
const { status, colable } = this.state;
const { config } = this;
const options = {
config: [
{
item: {
id: 'id',
label: 'ID',
type: 'hidden'
}
},
{
item: {
id: 'name',
label: 'input基础(级联)'
},
logic: 'nameNotRequired',
extends: 'inputRequired'
},
{
item: {
id: 'inputtrim',
label: 'input去空格(级联)'
},
logic: {
test: '{age} == 1',
item: {
options: {
rules: [
{
required: true
}
]
}
}
}
},
{
item: {
id: 'number',
label: '数字(级联)',
type: 'number'
},
logic: [
{
test: '{age} == 1',
show: true
},
{
test: '{ageMulit}.includes(1)',
item: {
options: {
rules: [
{
required: true
}
]
}
}
}
]
},
{
item: {
id: 'age',
type: 'select',
label: '基础Select(级联)',
data: config,
params: {
shouldOptionDisabled: (val) => val == 2,
showTooltip: true,
tooltip: 'value',
tooltipProps: {
placement: 'right'
},
showPleaseSel: true,
pleaseSelValue: -1
}
},
extends: 'selectRequired'
},
{
item: {
id: 'ageMulit',
type: 'select',
label: status ? 'Select多选必填' : 'Select多选必填独占一行',
data: config,
props: {
mode: 'multiple'
}
},
extends: 'selectRequired'
},
{
item: {
id: 'treeselect',
label: '树形Select',
type: 'treeselect',
data: config,
params: {
shouldOptionDisabled: (val) => val == 2
}
}
},
{
item: {
id: 'ageGroup',
type: 'select',
label: 'Select分组',
data: config,
params: {
optGroup: true
}
}
},
{
item: {
id: 'cascader',
type: 'cascader',
label: '级联选择',
data: config,
params: {
shouldOptionDisabled: (val) => val == 1
}
}
},
{
item: {
id: 'checkbox',
label: '复选框',
type: 'checkbox'
}
},
{
item: {
id: 'checkboxgroup',
label: '多选框',
type: 'checkboxgroup',
data: config,
params: {
shouldOptionDisabled: (val) => val == 1
}
}
},
{
item: {
id: 'radio',
label: '单选框',
type: 'radio'
}
},
{
item: {
id: 'radiogroup',
label: '单选框组合',
type: 'radiogroup',
params: {
shouldOptionDisabled: (val) => val == 1
},
data: config
}
},
{
item: {
id: 'radiogroupbutton',
label: '多单选按钮框',
type: 'radiogroupbutton',
params: {
shouldOptionDisabled: (val) => val == 1
},
data: config
}
},
{
item: {
id: 'datepicker',
type: 'datepicker',
label: '日期'
}
},
{
item: {
id: 'rangepicker',
type: 'rangepicker',
label: '区间'
}
},
{
item: {
id: 'weekpicker',
type: 'weekpicker',
label: '周'
}
},
{
item: {
id: 'monthpicker',
type: 'monthpicker',
label: '月份'
}
},
{
item: {
id: 'timepicker',
type: 'timepicker',
label: '时间'
}
},
{
item: {
id: 'switch',
label: 'Switch开关',
type: 'switch'
}
},
{
colProps: {
style: {
height: 64
}
},
item: {
id: 'slider',
label: '滑动输入条',
type: 'slider'
}
},
{
item: {
label: 'html类型',
type: 'html',
template:
'<div style="font-size: 14px;color: red"><p>我是DangerHtml测试</p></div>'
}
},
{
item: {
label: '非表单元素',
template: <div>我是非表单元素展示到表单中</div>,
formable: false
}
},
{
item: {
id: 'search1',
label: '搜索提示',
type: 'suggest',
params: {
label: 'name',
onSearch: (name) =>
get('/formsearch/users', { name }).then(({ data }) => data)
}
}
},
{
item: {
id: 'search2',
label: '搜索提示多选',
type: 'suggest',
props: {
mode: 'multiple'
},
params: {
label: 'region',
onSearch: (city) =>
get('/formsearch/citys', {
city
}).then(({ data }) => data)
}
}
},
{
item: {
id: 'textarea',
label: '文本框',
type: 'textarea'
}
},
{
item: {
id: 'hooks',
label: '自定义hooks组件',
type: 'hooks'
}
},
{
formItemProps: {
wrapperCol: {
lg: 20
},
labelCol: {
lg: 4
}
},
colProps: {
span: 24
},
item: {
id: 'textareatrim',
label: '文本框trim',
type: 'textarea.trim'
}
},
{
colProps: {
span: 24
},
formItemProps: {
labelCol: {
sm: 4
},
wrapperCol: {
sm: 20
}
},
item: {
label: '自定义注册组件',
type: 'city',
formable: false
}
}
],
status,
data: {
name: '测试账号',
age: 1,
ageMulit: [],
id: 2,
cascader: [2, 20]
},
colable,
colProps: {
span: 12
},
logic: {
nameNotRequired: [
{
test: '{age} == 1',
item: {
options: {
rules: [
{
required: false,
message: '请输入'
}
]
}
}
}
]
}
};
const getBtn = (text, props) => (
<Button
type="primary"
style={{
marginRight: 10
}}
{...props}
>
{text}
</Button>
);
return (
<Form
autoComplete="off"
labelCol={{
span: 6
}}
wrapperCol={{
span: 18
}}
style={{
padding: '20px 40px'
}}
>
<Row gutter={4}>
<ItemGenerator form={form} options={options} />
</Row>
<div
style={{
display: 'flex',
justifyContent: 'flex-end'
}}
>
{getBtn('查看状态', {
onClick: () => this.btnClicked(0)
})}
{getBtn('编辑状态', {
onClick: () => this.btnClicked(1)
})}
{getBtn('查询', {
onClick: this.query
})}
{getBtn('重置', {
onClick: this.resetForm
})}
{getBtn('切换布局', {
onClick: () =>
this.setState({
colable: !colable
})
})}
</div>
</Form>
);
}
}
export default Form.create()(Test);`
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!