前言
由于本人所在的业务经常需要下发一些表单类型任务给门店,而所有表单都是前端基于formily进行配置的,所以经常会和运营小姐姐打交道,这是背景。
在一个普普通通的工作日,当我正在埋头写代码的时候,突然传来一声"钉"的消息声,打开消息一看,我们的运营小姐姐给我发了一句"在吗?",正当我在思考如何回复的时候,啪的一下,很快啊,小姐姐又给我发了一大堆消息,定睛一看,原来是来找我配置数个表单,此时消息已经变为已读了,没有办法,只能"手写"表单配置,等配好表单后时间已经来到了中午,又到了干饭的时间:)
这种情况在我们日常工作中经常发生,虽然不是每天如此,但一周来个两三次,还是非常折腾人的。尽管我们组内后来建立了一个轮班机制来处理这个问题,但这只是治标不治本,时间浪费仍然是一个实实在在的问题。
因此,我们决定开发一个表单搭建平台,让运营小姐姐能够自行去配置表单,无需开发介入 ,这样我们才能 偷懒 投入更多的时间去承接其他业务需求。
架构设计
在立项之初我们组内也讨论过是自研一个表单搭建平台好呢还是直接用官方的designable,最终我们选择了自研,原因有以下几点:
- designable 相对于我们的业务而言偏重,很多功能我们并不需要
- 针对内部业务需求我们会有较多定制化的功能,改造designable成本可能会更大
- 我们需要一个更适合运营使用的平台,designable在某些方面可能更适合有编程经验的开发人员
既然确定了方向,接下来就是着手设计一套技术方案去实现一个表单搭建平台:
- 支持可视化搭建配置,所见即所得
- 能支持内部组件库所有表单组件使用
- 尽可能降低学习成本,方便运营快速上手
可视化搭建
可视化搭建实现主要通过主站和编辑器之间建立通信管道,将用户需要执行的操作(新增组件、排序、删除等)转为数据信息传递给编辑器,编辑器内部再根据不同操作执行不同的数据处理,从而实现表单的动态化更新。
通信设计
我们希望主站能够和编辑器进行解耦,同时保证配置期间表单的样式和实际表单一致,实现所见即所得,故编辑器内的表单应该直接通过formily + 组件库进行渲染,所以我们选择编辑器使用iframe的形式,通过postMessage进行通信,不仅天然实现解耦隔离,主站也不需要引入formily及移动端组件库资源,同时也保证了表单样式的一致性。
js
// admin to editor
export function postMsgToChild(message) {
document.getElementById('frame')?.contentWindow?.postMessage(message, '*');
}
// editor to admin
export function postMsgToParent (message) {
window.parent.postMessage(
message,
'*'
);
}
//admin
postMsgToChild({
action: 'add',
data: {
componentName: 'xxx',
props: {}
}
})
// editor
window.addEventListener('message', (e) => {
const { action, data } = e.data
switch (action) {
// add/remove/copy/sort/changeProps/changeReactions
// 编辑器接收到对应的action调用不同的方法来实现对应数据操作
case 'add':
// todo
break;
}
})
编辑器设计
由于编辑器和主站进行了隔离,我们将可用组件以及配置放置在编辑器内(具体原因后面会提及),通过消息通信告知主站可用组件列表以及组件的所有可配置属性。
编辑器核心结构大致如下:
js
const [components, setComponents] = useState([]) // 用户添加的所有组件
const [curIndex, setCurIndex] = useState(0) // 当前选择的组件下标
// 添加
const add = (data) => {}
// 删除
const remove = (data) => {}
// 复制
const copy = (data) => {}
// 排序
const sort = (data) => {}
// 修改属性
const changeProps = () => {}
// 修改联动
const changeReactions = () => {}
// 将components转为jsonSchema 用于formily渲染表单
const components2JsonSchema = (data) => {}
我们只需在接收到主站的不同指令执行不同的方法去操作components,最终通过components2JsonSchema方法生成jsonSchema用于表单渲染即可,那我们一个组件会包含哪些东西呢?
假设我们现在有一个输入框组件,这个组件的配置应当包含以下内容:
组件配置
组件配置描述了组件支持的所有props属性的配置
js
const componentSchema = {
"name": "Input",
"description": "输入框",
// 组件可配置属性描述
"props": {
"type": "object",
"properties": {
"placeholder": {
"title": "placeholder",
"x-decorator": "FormItem",
"x-decorator-props": {
"tooltip": "占位符"
},
"x-component": "Input",
"x-component-props": {}
},
// 其他属性
......
}
}
}
表单属性
表单属性主要用于配置formily表单相关的属性:title、required、enum......
arduino
const formSchema = {
"title": "表单属性",
"type": "object",
"properties": {
"title": {
"title": "标题",
"type": "string",
"x-component": "Input",
"x-decorator": "FormItem"
},
// 其他属性
......
}
}
布局属性(decorator props)
布局属性主要用于配置FormItem的props属性
js
const decoratorSchema = {
"props": {
"type": "object",
"properties": {
"colon": {
"title": "colon",
"x-decorator": "FormItem",
"x-decorator-props": {
"tooltip": "是否显示label右侧冒号"
},
"x-component": "Switch"
},
// 其他属性
......
}
}
最终我们给到主站的一个完整组件的配置json大致如下:
js
const postMsg = {
type: 'props',
data: {
// 右侧属性面板表单渲染
schema: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'FormCollapse',
'x-component-props': {
formCollapse: '{{formCollapse}}',
},
properties: {
formSchema,
componentSchema,
decoratorSchema,
},
// 右侧属性面板表单value
props: {
formSchema: {},
componentSchema: {},
decoratorSchema: {}
}
},
},
}
这样当我们添加一个Input组件后,主站就可以通过schema渲染组件可用属性的配置面板,当修改属性配置后通过消息通信将修改的数据告知编辑器,编辑器再根据数据生成jsonSchema渲染表单
上面我们的确实现了一个组件的属性配置,但是这里又产生了几个问题:
- 组件库有这么多组件,每个都要写一份配置shcema?
- 组件库新增/修改组件,又要手动去修改配置文件?
显然这是不合理的,所以我们需要实现一个能够自动生成组件schema的能力,来避免人工频繁修改和维护组件配置。
自动生成组件配置
一开始我们希望组件库的维护同学能够在组件库内部导出一份组件属性配置,但这样只是将工作量进行了转移,并没有实际解决我们的问题,而且对于组件库也是一种变相的侵入,毕竟这份配置文件对于组件库而言并无意义,只是单纯提供我们使用而已。
最终我们选择使用 typescript-json-schema 、fast-typescript-to-jsonschema,通过将组件库导出的ts定义转为jsonSchema,解决了人工手动维护的问题,并且我们还对内部对于注释解析进行了一定的改造,通过识别指定注释前缀,生成不同的字段结构
ts
// ts 定义
export interface Test {
/**
* @title this is test title
* @description this is test desc
* @default multiple
*/
test: string;
/**
* @title this is aaa title
* @description this is aaa desc
* @default true
*/
aaa: boolean;
/**
* @default 1
*/
bbb: number;
}
/**
* 生成的json
* @title的注释作为属性标题
* @description的注释作为属性描述
* @default的注释作为属性默认值
*/
const json = {
"Test": {
"type": "object",
{
"test": {
"type": "string",
"description": "this is test desc",
"title": "this is test title",
"default": "multiple"
},
"aaa": {
"type": "boolean",
"description": "this is aaa desc",
"title": "this is aaa title",
"default": true
},
"bbb": {
"type": "number",
"default": 1
}
},
}
}
// 再将json 转为可用于formily渲染的jsonschema
const schema = {
name: "Test",
properties: {
"test": {
default: "multiple",
title: "this is test title",
type: "string",
"x-component": "Input",
"x-decorator": "FormItem",
"x-decorator-props": {
"tooltip": "this is test desc"
},
},
"aaa": {
default: true,
title: "this is aaa title",
type": "boolean",
"x-component": "Switch",
"x-decorator": "FormItem",
"x-decorator-props": {
"tooltip": "this is aaa desc"
}
},
"bbb": {
default: 1,
type: "number",
"x-component": "NumberPicker",
"x-decorator": "FormItem"
}
}
}
这样组件库只需调整下组件props的注释,每次组件库更新我们只需同步升级下编辑器内的组件库即可自动生成所有组件及组件的属性配置jsonshcema了!
逻辑联动
此时的表单还只是一具空有容貌(schema)的肉体,一个静态的表单是无法满足我们小姐姐的需求的,因此需要给表单注入灵魂(逻辑联动)。
formily提供的联动能力主要有两种:SchemaReactions、Effects。SchemaReactions一般用于相对比较简单的表单字段间的联动,而Effects支持更多复杂场景的业务逻辑处理。我们也针对这两种逻辑联动能力设计了不同的交互和实现方式。
表单字段联动(SchemaReactions)
我们知道formily的逻辑联动有主动联动、依赖联动等多种方式,同时支持联动的属性也有很多,但是我们的用户并非开发人员,我们需要在功能易用性和完整性上进行一定的取舍,因此我们最终决定统一使用依赖联动的方式,并只开放部分常用的属性支持逻辑联动,尽可能降低运营的学习和使用成本
接下来通过一个简单的例子来看下我们是如何去实现SchemaReactons的联动配置:
有以下一个场景,当选项一或选项二的值为"是"的时候,则展示图片组件
对应的Schema如下
css
// 初始 schema
{
"type": "object",
"properties": {
"select1": {
"title": "选项一",
"type": "string",
"x-decorator": "FormItem",
"x-component": "Radio.Group",
"enum": [
{ "label": "是", "value": "是" },
{ "label": "否", "value": "否" }
]
},
"select2": {
"title": "选项二",
"type": "string",
"x-decorator": "FormItem",
"x-component": "Radio.Group",
"enum": [
{ "label": "是", "value": "是" },
{ "label": "否", "value": "否" }
]
},
"image": {
"type": "string",
"x-component": "Image"
}
}
}
通过选中图片组件并设置对应的联动逻辑,配置界面大致如下:
上述配置实际生成的reactions配置属性大致如下:
js
// 存储的reactions配置
const reactions = {
image: {
display: { // 设置显隐
condition: 'or', // 条件关系
target: 'visible', // 目标结果
reactions: [
{
dependence: 'select1',
attr: 'value',
relation: '=',
value: '是',
},
{
dependence: 'select2',
attr: 'value',
relation: '=',
value: '是',
}
]
}
}
}
提交时只需将上述的配置属性转换成对应的SchemaReactions表达式即可:
js
// 转换后 schema
{
"type": "object",
"properties": {
"select1": {
"title": "选项一",
"type": "string",
"x-decorator": "FormItem",
"x-component": "Radio.Group",
"enum": [
{ "label": "是", "value": "是" },
{ "label": "否", "value": "否" }
]
},
"select2": {
"title": "选项二",
"type": "string",
"x-decorator": "FormItem",
"x-component": "Radio.Group",
"enum": [
{ "label": "是", "value": "是" },
{ "label": "否", "value": "否" }
],
},
"image": {
"type": "string",
"x-component": "Image",
"x-reactions": {
"fulfill": {
"state": {
"display": "{{($deps[0] === '是' || $deps[1] === '是') ? 'visible' : 'none'}}"
}
},
"dependencies": ["select1", "select2"]
}
}
}
}
业务逻辑联动(Effects)
我们目前需要使用Effects的场景大部分是流程类表单,即在不同的流程节点我们公用同一份表单配置,通过在不同的节点执行一个对应的hook方法,方法内部主要利用formily Effects提供的能力来实现对应的业务逻辑,以此来展示不同的表单字段、状态、样式等。
同样,我们也通过一个简单的例子来看下是如何去实现Effects的联动配置:
假如有这么一个需求:在流程节点A时需要隐藏select1、select2字段,此前的实现方式为通过在对应节点的hook编写如下代码:
ini
function __execute() {
formilyCoreApi.onFormMount(() => {
const fields = form.query('*(select1, select2)')
fields.forEach(field => field.setDisplay('none'))
})
};
__execute()
但是我们不可能让运营去写这么一块代码,同时不能也不应该让开发为每个需要执行类似逻辑的表单去编写代码,否则就失去了表单搭建的意义,我们给出的解决方案是通过以最小粒度将每个动作(比如上述的设置字段隐藏)转为一个个类似插件形式的方法,供用户组合使用来实现业务诉求,当现有插件不满足新的业务时,开发只需新开发一个插件即可。
用上述的字段隐藏插件来举例,一个插件主要由以下几个部分组成:
js
const hookPlugin = {
name: 'setNodesHidden', // 插件方法名
title: '字段隐藏设置', // 插件名称
description: '可选择需要的字段在不同流程节点进行隐藏', // 描述
version: '1.0.0', // 版本
// 用于描述用户选择hook插件后的操作视图
jsonSchema:{
"type": "object",
"properties": {
"schemaKeys": {
"type": "array",
"x-decorator": "FormItem",
"x-component": "SelectTable",
"required": true,
"x-component-props": {
"bordered": false,
"mode": "multiple"
},
"enum": [],
"properties": {
"label": {
"title": "需要隐藏的字段名",
"type": "string",
"x-component": "SelectTable.Column"
}
}
}
}
},
// 用于描述用户选择hook插件后的交互逻辑处理
effects: function (jsonSchema, formilyCoreApi, nodeId, formData) {
/**
jsonSchema: 为表单的jsonSchema,非插件配置界面的jsonSchema
formilyCoreApi: formily core 相关方法
nodeId: 流程节点id
formData: 插件配置界面数据
*/
formilyCoreApi.onFormMount((f) => {
// 如果存在formData 一般为用户编辑该插件
f.setValues({ schemaKeys: formData || [] });
const data = Object.keys(jsonSchema.properties).map((k) => {
const { title, customKey } = jsonSchema.properties[k];
return {
label: title || customKey,
key: k,
};
});
const schemaKeysField = f.query('schemaKeys').take();
schemaKeysField?.setDataSource(data);
});
formilyCoreApi.onFormSubmitValidateEnd((f) => {
// 这里可以手动干预提交的数据,提交的数据将作为hook function 的 params
f.values = f.values.schemaKeys;
});
},
// 表单实际执行的effects代码
hook: function setNodesHidden(keys, global) {
const { formilyCoreApi, form } = global;
formilyCoreApi.onFormMount(() => {
const fields = form.query(`*(${keys.join(',')})`);
fields.forEach((field) => field.setDisplay('none'));
});
},
}
这里的jsonSchema和effects是用于描述当用户选择一个插件后需要操作的交互,比如当我选择"字段隐藏设置"这个插件后会有一个配置界面来让用户选择需要隐藏的字段,这个配置界面就是通过这里的jsonSchema和effects去实现的。
最终当用户在节点A执行的hook方法实际代码如下:
scss
function __execute() {
setNodesHidden(["select1","select2"], this);
// 如果使用多个插件则会依次执行
......
};
__execute();
至此一个完整的表单就搭建好了,包含了jsonSchema、reactions、effects三块核心能力,当然围绕表单搭建我们还可以做很多其他服务业务的功能,这里就不展开叙述了。
结语
自表单搭建平台上线至今已有3个月的时间,我们内部的表单配置答疑群除去刚上线期间的一些问题解答和部分小功能迭代外,基本没有再接到需要开发帮忙配置表单的诉求,美中不足的就是少了许多和运营小姐姐互动的机会了
最后
📚 小茗文章推荐:
关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~