基于Antd 3/4的B端配置化表单解决方案

2,138 阅读7分钟

背景

B端中台项目开发中,表单的开发,是家常便饭的事儿。一般会涉及到大量的重复性工作:

  • 字段模板编写
  • 字段规则编写
  • 字段属性的定制与配置
  • 表单联动处理
  • 编辑(编辑、新增同时可以理解为编辑态)、查看态页面编写
  • 数据抹平适配处理 前端编写表单同时,复杂场景下还需要配置一些结构下发前端来解析、服务端也需要编写字段校验规则。

伴随业务变更、复杂化、场景动态化视图(现在负责的项目中,工单系统,常规下几百个表单项,复杂场景下1000+表单字段,没错1000+😅)、前后端开发同步不及时的情况,前后端配置、校验规则可能会存在不一致的问题。

因此,开发一套满足前后端均可配置的配置化表单方案,用来减少团队重复性工作,是必须执行的一个技术方案。

开发现状

现阶段,团队项目技术栈是React,主要使用Antd 3/ Antd 4来支持业务开发。

整体上,表单常规开发不出下面几种代码片段:

3版本方案原始代码

常规布局

image.png

多列布局

image.png

4版本方案原始代码

常规布局

image.png

多列布局

image.png

日常开发中,少量几个字段开发还可勉强code,出现几十、上百、上千后,整体代码大量得复制粘贴改一改,开发同学也从前端developer升级优秀的前端copier🤣。

设计、开发实现一套优雅、扩展性强得配置化的表单方案,是需要解决得问题。

目标

整体上,从完整的方案角度来看,需要梳理下以下实现:

常规实现

  • Antd基础类型的可配置化
  • Antd元素校验配置
  • 不同表单配置的属性处理(比如:valueProp不同字段的配置)
  • 不同表单元素类型展示UI出现错乱的问题
  • 元素布局方式

增强方案

  • 数据:数据自动注入、数据类型抹平
  • 类型增强:.trim能力(借鉴vue中.trim语法)、text类型、html、hidden类型等增强
  • 表单级联
  • 自定义模板、自定义组件注册
  • 非表单元素的嵌入
  • 服务端化下发的配置的解析(其中主要是,校验规则中的正则的处理)

个性化

  • 组件继承
  • 参数、属性全局配置
  • 布局、继承的全局配置
  • 状态:编辑态/查看态的适配
  • 接下来,奔着整体实现来设计我们的技术方案。

方案设计

Schema抽离

从原始的Antd 3/4原始开发代码角度出发,进行抽离。

1、对代码片段进行结构抽离:

image.png

2、模型形成

image.png

3、形成基础版本Schema

Antd 3

image.png

Antd 4

image.png

常规实现

  1. 整体上,常规类型均已内置。现在情况下,提供近30种表单类型
  2. 对于单checkbox、switch、upload等配置时,抹平valuePropName配置
  3. 解决不同表单元素类型展示UI出现错乱的问题
  4. 基础布局可配置

增强方案

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两份视图的展示

整体方案完成后,单元素配置如下图:

image.png

工作原理

项目运行流程

image.png

整体使用情况

当前方案在所属团队,经过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);`

源码

Antd3版本方案:github.com/jssoscar/it… Antd4版本方案:github.com/jssoscar/it… main-antd4 分支

NPM包

Antd3包:www.npmjs.com/package/ite… Antd4包:www.npmjs.com/package/ite…