
本文做者:董健华前端
1. 背景
云音乐 B 端业务场景很是多,B 端业务相对于 C 端业务产品生命周期更长并且更注重场景的梳理。不少时候开发 B 端业务都是拷贝以前的代码,这样增长了不少重复并且枯燥的工做量。git
中后台系统其实能够拆分红几个比较通用的场景:表单、表格、图表,其中表单涉及到联动、校验、布局等复杂场景,常常是开发者须要耗费精力去解决的点。github
对比传统的 Ant Design[1] 表单开发开发方式,咱们认为有如下问题:web
-
首先代码没法被序列化,并且对于一些非前端的开发者更习惯用 JSON
方式描述表单,由于足够简单 -
表单的校验并无和校验状态作结合 -
onChange
实现的联动方式在复杂的联动状况下代码会变得难以维护,容易产生不少链表式的逻辑 -
表单有许多互斥的状态能够整理,并且咱们也但愿用户能够很轻易的在这些状态间进行切换 -
对于一些比较经常使用并且通用的场景,例如:表单列表,也能够抽离出一套可行的方案
因此虽然传统的表单开发方式已经足够的灵活,可是我也依然认为表单还有优化的空间,在灵活与效率上作了些权衡。面试
外界也有比较成熟的表单解决方案,例如:Formliy[2] 、 FormRender[3] 。虽然解决了上面某几个点的问题,可是依然不够全面,咱们须要有本身 style
的方案。算法
因此为了提升中后台开发效率,让前端可以把时间投入到更有意义的事情里,咱们总结了一套面向复杂场景的表单解决方案。json
2. 技术方案
在技术方案上相当重要的一环就是Schema设计,框架架构等工做都是围绕这一环去实现的,因此我会沿袭这个思路给你们作介绍。api
2.1 Schema设计
表单方案基于 Ant Design
开发,经过 JSON
方式配置 Schema,可是并不是是 JSON Schema
,外界不少基于 JSON Schema
的配置方案,其实也有考虑过,不过 JSON Schema
写起来有点麻烦,因此对 JSON Schema
的转换只做为一项附加的能力。微信
案例以下面代码所示,最简单的表单字段只要配置 key
、type
和 ui.label
就能够了:架构
const schema = [
{
"key": "name",
"type": "Input",
"ui": {
"label": "姓名"
}
},
{
"key": "age",
"type": "InputNumber",
"ui": {
"label": "年龄"
},
"props": {
"placeholder": "请输入年龄"
}
},
{
"key": "gender",
"type": "Radio",
"value": "male",
"ui": {
"label": "性别"
},
"options": [
{
"name": "男",
"value": "male"
},
{
"name": "女",
"value": "female"
}
]
}
];
export default function () {
const formRef = useRef(null);
const onSubmit = () => {
formRef.current.submit().then((data: any) => {
console.log(data);
});
};
const onReset = () => {
formRef.current.reset();
};
return (
<>
<XForm
ref={formRef}
schema={schema}
labelCol={{ span: 6 }}
wrapperCol={{ span: 12 }}
/>
<div>
<Button type="primary" onClick={onSubmit}>提交</Button>
<Button onClick={onReset}>重置</Button>
</div>
</>
);
}
由于方案是基于 Ant Design
的 Form
组件设计的,因此为了保留 Ant Design
的一些特性,设计了 ui
和 props
两个字段分别对应 Form.Item
的 props
和组件的 props
。即便后续 Ant Design
表单增长了某些功能或者特性,这套表单方案也能作到无缝支持。
2.1.1 校验方式
既然表单是基于 Ant Design
实现的,那么校验也沿用了它的校验类库 async-validator[4],这个类库已经比较成熟并且强大,可以校验 Array
和 Object
等深层级的数据类型,知足复杂校验的需求,因此咱们直接在这个库的基础上作调整。
经过 rules
字段进行配置,除了 async-validator
原本就就有的特性外,还额外增长了 status
(校验状态)和 trigger
(触发条件)枚举以下:
-
status:校验状态 -
error(默认):错误 -
warning:警告 -
trigger:触发条件 -
submit(默认):提交时候触发 -
change:值变化时候触发判断 -
blur:失去焦点时候触发判断
基本使用方式以下:
{
"key": "name",
"type": "Input",
"ui": {
"label": "姓名"
},
"rules": [
{
"required": true,
"message": "姓名必填",
"trigger": "blur",
"status": "error"
}
]
}
2.1.2 联动方式
除了校验,联动也是比较经常使用的功能,传统的联动经过组件 onChange
方式实现,当联动逻辑比较复杂的时候,看代码就像搜索链表同样麻烦,因此这块设计了一种 反向监听
的方式,字段的全部变化都维护在字段配置自己,下降后期维护成本。
经过 listeners
字段进行配置,设计了 watch
(监听)、 condition
(条件)、set
(设置)三个字段组合实现联动功能。
watch
记录须要监听的字段,当监听字段有任何变化的时候,会触发 condition
条件的判断,只有条件判断经过才会接着触发 set
设置。
[
{
"key": "name",
"type": "Input"
},
{
"key": "gender",
"type": "Radio",
"value": "male",
"options": [
{
"name": "男",
"value": "male"
},
{
"name": "女",
"value": "female"
}
],
"listeners": [
{
"watch": [ "name" ],
"condition": "name.value === 'Marry'",
"set": {
"value": "female"
}
}
]
}
]
上述例子当名字为 Marry 的时候,性别默认调整成女。
2.1.3 表单状态
咱们发现有些联动场景是为了对字段作隐藏和显示的操做,为了方便用户切换状态,将4种互斥表单状态整理成一个 status
字段:
-
status:状态 -
edit(默认):编辑 -
disabled:禁用 -
preview:预览 -
hidden:隐藏
preview
状态并非组件自己具备的,可是预览的需求蛮多的,因而咱们作了拓展,为全部基本的表单组件预置了预览的状态。即便自定义组件也会默认展现字段值,若是须要自行处理的话也提供了方案。
使用方式以下:
[
{
"key": "edit",
"type": "Input",
"status": "edit",
"value": "编辑",
"ui": {
"label": "编辑"
}
},
{
"key": "disabled",
"type": "Input",
"status": "disabled",
"value": "禁用",
"ui": {
"label": "禁用"
}
},
{
"key": "preview",
"type": "Input",
"status": "preview",
"value": "预览",
"ui": {
"label": "预览"
}
},
{
"key": "hidden",
"type": "Input",
"status": "hidden",
"value": "隐藏",
"ui": {
"label": "隐藏"
}
}
]
效果图以下:

2.1.4 Options设置
许多选择组件使用 options
字段设置选项,选项有时候经过异步接口获取。考虑到异步接口的状况,设计了 4 套方案 :
-
options
为Array
的状况
{
"key": "type",
"type": "Select",
"options": [
{
"name": "蔬菜",
"value": "vegetables"
},
{
"name": "水果",
"value": "fruit"
}
]
}
-
options
为string
的状况,即接口连接
{
"key": "type",
"type": "Select",
"options": "//api.test.com/getList"
}
-
options
为object
的状况,action
为接口连接,nameProperty
配置name
字段,valueProperty
配置value
字段,path
为获取选项路径,watch
配置监听字段
{
"key": "type",
"type": "Select",
"options": {
"action": "//api.test.com/getList?name=${name.value}",
"nameProperty": "label",
"valueProperty": "value",
"path": "data.list",
"watch": [ "name" ]
}
}
-
action
为function
的状况
{
"key": "type",
"type": "Select",
"options": {
"action": (field, form) => {
return fetch('//api.test.com/getList')
.then(res => res.json());
},
"watch": [ "name" ]
}
}
2.1.5 表单列表
表单列表是一种组合类型的表单,一般有 Table
和 Card
两种场景,具备增长和删除功能。
这种类型的表单值是以 Array
的形式返回的,因此设计了 Array
组件,根据 props.type
对 Table
和 Card
形态进行切换(貌似这种状况很少),children
配置子表单,使用方式以下:
{
"key": "array",
"type": "Array",
"ui": {
"label": "表单列表"
},
"props": {
"type": "Card"
},
"children": [
{
"key": "name",
"type": "Input",
"ui": {
"label": "姓名"
}
},
{
"key": "age",
"type": "InputNumber",
"ui": {
"label": "年龄"
}
},
{
"key": "gender",
"type": "Radio",
"ui": {
"label": "性别"
},
"options": [
{
"name": "男",
"value": "male"
},
{
"name": "女",
"value": "female"
}
]
}
]
}
效果图以下:


2.2 框架架构

围绕Schema设计思路,咱们采用了基于分布式管理方案,将核心层和渲染层分离,字段信息维护在核心层,渲染层只负责渲染的工做,作到数据和界面代码的分离结构。
核心层与渲染层之间经过 Sub/Pub
方式进行通信,渲染层经过监听核心层定义的一系列 Event
事件对界面做出调整。
这种数据状态的改变驱动界面的变化已经不是什么新鲜事了,在大多数框架中被普遍使用,其中优点有:
-
方面各个字段之间数据与状态共享 -
经过对事件的控制,可以合理的优化渲染次数,提升性能 -
可以适配多框架的状况,只需复用一套核心层代码
核心层主要由 Form
、Field
、ListenerManager
、Validator
、optionManager
几部分组成以下图所示:

其中 Form
是表单原型,下面承载了不少 Field
字段原型,由 ListenerManager
统一管理联动方面的功能,Field
下具备 Validator
和 OptionManager
分别管理校验和 options
选项功能
2.2.1 校验实现
主要仍是经过 async-validator
类库实现,可是依然没法知足多校验状态和多触发条件的状况,因此在这个基础上作了些拓展,封装成一个 Validator
类。
Validator
只有一个 Validator.validate
方法,传递一个 trigger
参数,实例化 Validator
时候会去解析 rules
字段,根据 trigger
进行分类并建立对应的 async-validator
实例。
2.2.2 联动实现
ListenerManager
具备 ListenerManager.add
方法和 ListenerManager.trigger
方法,分别用于解析并添加 listeners
字段以及 Field
字段发生变化时触发联动效果。
具体流程是在初始化 Field
时,会将 listeners
字段经过 listenerManager.add
方法解析信息,根据 watch
中的 key
值进行分类并保存在其中,当 Field
信息发生变化的时候会经过 ListenerManager.trigger
触发联动,判断 condition
条件是否知足,若是知足即触发 set
内容。
2.2.3 表单列表实现
表单列表实际上是由多个 XForm
实例构成,每个自增项都是一个 XForm
实例,因此联动只能在同一行上进行,不能跨行联动。
当点击添加按钮的时候,会根据 children
提供的 Schema
模板建立一个 XForm
实例:

2.2.4 布局实现
除了 Ant Design
的 Form 提供的三种布局方式(horizontal、vertical、inline),还须要提供一种更灵活的布局方式来知足更加复杂的状况。
布局真是一个很头疼的问题,特别是 Schema
在相似 JSON
的结构下实现复杂的布局很容易致使 Schema
嵌套层级深,这种是咱们不肯意看到的。
最初方案是经过网格布局实现,经过设置 Form
的 row.count
或者 col.count
参数计算出网格的行数和列数再对字段进行分布,这种方式只适用于每行列数都一致的状况,可是这种方式难以知足每行列数不一致的状况:

因此从新设计了一个 ui.groupname
的字段,同一个 groupname
的字段都会被一个 div
包裹住,而且 div
的 className
即 groupname
,用户要实现复杂的布局能够本身写样式去实现,这样的方案虽然简陋,可是实用。
3. 细节设计
3.1 忽略特定字段值
有些场景须要忽略 status
为 hidden
的字段的值,因此设计了一个 ignoreValues
字段,字段配置有下面几种状况:
-
hidden:忽略状态为 hidden 的状况 -
preview:忽略状态为 preview 的状况 -
disabled:忽略状态为 disabled 的状况 -
null:忽略值为 null 的状况 -
undefined:忽略值为 undefined 的状况 -
falseLike:忽略值 == false 的状况
经过配置 ignoreValues
字段,提交后返回的 values
就会忽略相应的字段:
<XForm schema={schema} ignoreValues={['hidden', 'null']}/>
3.2 字段解构与重组
字段解构是指把一个字段的值拆成多个字段,字段重组是指把多个字段组合成一个字段,这块的具体功能还未实现,可是已经有了初步的想法。
字段解构例子以下,主要是经过 key
对字段进行拆分,最终返回 values
包含 startTime
和 endTime
两个字段:
{
"key": "[startTime, endTime]",
"type": "RangePicker",
"ui": {
"label": "时间选择"
}
}
发现许多场景须要由多个字段组合成一个字段,这种状况大多须要写自定义组件否则就是后期须要对数据进行处理,为了简化这一过程因此设计了字段重组的功能。经过 Combine
组件将多个字段重组成一个字段:
{
"key": "time",
"type": "Combine",
"ui": {
"label": "时间选择"
},
"props": {
"shape": "{startTime, endTime, type}"
},
"children": [
{
"key": "startTime",
"type": "DatePicker"
},
{
"key": "endTime",
"type": "DatePicker"
},
{
"key": "type",
"type": "Select",
"options": [
{
"name": "发行时间",
"value": "publishTime"
},
{
"name": "上线时间",
"value": "onlineTime"
}
]
}
]
}
4. 结尾
完善表单这款产品的过程也是一个博采众长的过程,咱们调研了业界竞品结合自身业务需求,开发出了这款产品。上面供你们参考,很是遗憾的是咱们产品还未开源,相信会在合适的时候跟你们见面。
5. 相关资料
-
Formily [5] -
FormRender [6]
参考资料
Ant Design: https://ant.design/
[2]Formliy: https://formilyjs.org/
[3]FormRender: https://alibaba.github.io/form-render
[4]async-validator: https://github.com/yiminghe/async-validator
[5]Formily: http://formilyjs.org/
[6]FormRender: https://alibaba.github.io/form-render/
[7]网易云音乐大前端团队: https://github.com/x-orpheus
推荐阅读 网易严选跨框架组件开发实践
本文分享自微信公众号 - 前端之露(gh_ef72c6726e70)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。