背景
笔者的目标非常明确,就是「提高中后台系统的开发效率」,目前经历了 3 个阶段(背景有点长,但是很重要):
第一阶段,由于业务需要,先着手进行了 开箱即用的工具 - BI 的调研,最终得到的结论是首选 DataEase,次选 Metabase。但是 BI 只是中后台系统的一部分,这点成果还远远不够,于是进入了第二阶段。
第二阶段,进行了 低代码漫谈 系列调研,希望能够通过现有的低代码平台大幅提升开发效率。但是,整体引入一个低代码平台,对于现有的开发流程来说不太现实,最大的问题就是现有的项目代码不好处理。笔者之前有一段低代码产品的开发经历,所以还是有一定认知深度的,于是开始进行更深一步的研究,希望能够从更抽象的底层寻求更合适的解决方案。
研究发现,百度的 amis 和阿里的 lowcode-engine 文档非常完善,后者甚至出了一本白皮书。在细心研习了一番之后有了比较大的收获和启发(amis 核心概念浅析、lowcode-engine 协议浅析),笔者意识到:
低代码产品最重要的技术核心是协议。
如何理解这句话呢?表面看起来,低代码产品是通过可视化拖拽操作生成 APP。这个过程中最核心的一步就是:拖拽画布输出的产物(数据),输入到 APP 生成器,最终生成 APP。这个「产物」非常重要,通常需要网络传输,所以都会选择 JSON 格式,而这个 JSON 携带并表达了整个 APP 的信息。
大家知道,一个 APP 是非常复杂的,科学合理的设计好这个 JSON 是非常难的,而这个 JSON 的格式就是所谓的「协议」。为了更直观,我们举一个 lowcode-engine 的 Demo 例子:
import ReactRenderer from '@ali/lowcode-react-renderer';
import ReactDOM from 'react-dom';
import { Button } from '@alifd/next';
/* 符合协议格式的 schema */
const schema = {
componentName: 'Page',
props: {},
children: [
{
componentName: 'Button',
props: {
type: 'primary',
style: {
color: '#2077ff'
},
},
children: '确定',
},
],
};
const components = {
Button,
};
/* 传入 ReactRenderer 就能渲染出 APP */
ReactDOM.render((
<ReactRenderer
schema={schema}
components={components}
/>
), document.getElementById('root'));
在 Demo 中,只需要传入一个树状结构(符合协议)的 schema 和一个组件列表,ReactRender
就可以将整个 APP 渲染出来,供人使用。
有了以上认知,一个解决思路就出现了:
- 首先,协议是语言无关的,所以无论前端项目用的 React、Vue 甚至后端直出的模板,都可以应用;
- 再者,协议的实现可以用多种方式提效,比如低代码产品使用的方式是 可拖拽画布。说到底,协议就是一个大 JSON,我们还可以用表单、代码片段甚至插件来高效的生成它。
但是!上来就搞这么大的动作几乎是不可能成功的。所以理论要想落地,还是要有一定规划和里程碑的,通常不变形地落地才是工程的最大难点,于是来到了第三阶段。
第三阶段,也就是现阶段。路要一步一步走,饭要一口一口吃。经过慎重思考,笔者决定要走的第一步,就是先提升表单开发的效率。表单和列表占据了中后台系统绝大部分的内容,所以如果提升了表单的开发效率,实际上对于整体开发效率的提升还是有一定效果的。于是笔者进行了 表单状态管理 的调研,分析了现在主流表单状态管理框架的设计思路后,结合低代码协议的思路,整理成了本文。旨在制定一个 框架无关的、未来可以方便的扩展成低代码协议 的表单协议,来指导表单组件的封装,从而全范围提效。
目标
- 统一所有前端技术栈(React、Vue、甚至 RN)下的表单开发方式,只需要编写符合协议的 JS Object 配置,传入封装好的表单组件即可,即配置化;
- 制定出表单组件的 API 以及具体格式,以承接符合协议的配置,指导组件的封装;
正文
分析
笔者在《Form 组件 API 对比 - AntD vs Element vs Naive》中总结到:
- 组件分 2 级:Form 和 Field(或 Item),二者的 API 分类比较类似;
- API 分 2 类:UI 样式类和功能类;
- UI 样式类,基本上处理好 layout、label、validateMessage 三方面就够了,其它复杂的布局,就交给自定义组件来兜底;
- 功能类,基本上都可以归到表单状态管理的范畴里。如果用过 React Hook Form、Formik、Final Form 之类的表单状态管理工具,就会发现几乎全部的功能类 API 都能对上;
UI 类:因为笔者的目的是配置化,那么配置信息的易读性就更加重要,所以笔者会尽量将同一类 API,集合到一个对象中。比如 label-width、label-align 等,都聚合到 label 对象当中,大概如下:
interface FormLabel {
suffix?: string; // ":"
visible?: boolean; // true
placement?: "left" | "top"; // "left"
requiredMark?: "left" | "right" | "hidden"; // "right"
style?: CSSProperties;
}
功能类:理论上参考表单状态管理工具的 API 就行,再细一点就是 useForm
、useField
之类的入参。但是,调研的三个工具 API 的设计还是有比较大的区别的,所以笔者会在设计时取交集,作为最重要的 API,其他的会视情况进行取舍和聚合。
Form Props
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
initialValues | 表单默认值,只有初始化以及重置时生效 | object | - |
onSubmit | 提交表单的回调事件 | function(values) |
- |
validateTriggers | 统一设置字段触发验证的时机 | Array<"onChange" | "onBlur"> | [ "onChange" ] |
formEvents | 除 onSubmit 外的其他回调事件,详见下文 |
FormEvents |
- |
labelOptions | label 样式相关配置,详见下文 |
LabelOptions |
- |
layoutOptions | 布局相关配置,详见下文 | FormLayout |
- |
validateMessageOptions | 验证提示相关配置,详见下文 | ValidateMessageOptions |
- |
FormEvents
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
onValuesChange | 字段值更新时触发回调事件 | function(changedValues, allValues) | - |
beforeValidate | 触发校验之前的回调事件,返回 false 则停止后续逻辑 | function(values): boolean | - |
afterValidate | 触发校验之后的回调事件 | function(values) | - |
beforeSubmit | 表单提交之前的回调事件,返回 false 则停止后续逻辑 | function(values): boolean | - |
afterSubmit | 表单提交之后的回调事件 | function(values) | - |
LabelOptions
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
visible | label 标签是否可见 | boolean | true |
suffix | label 标签后缀 | string | ":" |
placement | label 标签位置 | "left" | "top" | "left" |
requiredMark | 表示「必选」的 * 位置 | "left" | "right" | "hidden" | "right" |
style | label 标签的样式 | CSSProperties |
- |
FormLayout
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
align | 垂直对齐方式 | "top" | "middle" | "bottom" | "top" |
gutter | 栅格间隔,单位 px |
number | 0 |
justify | 水平排列方式 | "start" | "end" | "center" | "space-around" | "space-between" | "space-evenly" | "start" |
offset | 栅格左侧的间隔格数,间隔内不可以有栅格 | number | 0 |
pull | 栅格向左移动格数 | number | 0 |
push | 栅格向右移动格数 | number | 0 |
size | 组件尺寸 | "mini" | "small" | "medium" | "large" | "medium" |
span | 栅格占位格数 | number | 24 |
wrap | 是否自动换行 | boolean | true |
ValidateMessageOptions
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
visible | 验证提示是否可见 | boolean | true |
placement | 验证提示位置 | "right" | "bottom" | "bottom" |
validateFirst | 只显示第一条验证提示 | boolean | false |
style | validate message 的样式 | CSSProperties |
- |
Field Props
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
name | 字段标识,具有唯一性 | string | - |
rules | 校验规则 | Array<Rule> | - |
validateTriggers | 统一设置字段触发验证的时机 | Array<"onChange" | "onBlur"> | ["onChange"] |
labelOptions | label 样式相关配置 |
LabelOptions |
- |
layoutOptions | 布局相关配置 | Omit<FormLayout, "gutter" | "wrap"> |
- |
validateMessageOptions | 验证提示相关配置 | Omit<ValidateMessageOptions, "validateFirst"> |
- |
trigger | 设置收集字段值变更的时机 | string | "onChange" |
valueOptions | 对于 value 的预处理,详见下文 |
ValueOptions |
- |
fieldEvents | 表单域其他回调事件 | FieldEvents |
- |
dependences | string[] | - |
ValueOptions
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
formatOutput | 对组件输出后的 value 进行处理 |
function(value): any | - |
parseInput | 传入组件之前,对 value 的预处理逻辑 |
function(value): any | - |
propName | 子节点的值的属性,如 Switch 的是 "checked" | string | "value" |
FieldEvents
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
before/afterChange | change 之前/后的回调函数 | function(value, values) | - |
before/afterBlur | blur 之前/后的回调函数 | function(value, values) | - |
before/afterFocus | focus 之前/后的回调函数 | function(value, values) | - |
before/afterSubmit | submit 之前/后的回调函数 | function(value, values) | - |
协议
注意,因为 Form 也有可能作为 Field,即子表单的情况,所以协议必须是一个「完全递归」的结构,参考 lowcode-engine 的协议 结构,定义如下:
interface FieldSchema {
componentName: string;
props: {
fieldProps?: FieldProps,
// ...other private props
};
children?: Array<FieldSchema>;
}
// example:
const schema = {
componentName: "FormRender",
props: {
labelOptions: {
suffix: "",
placement: "top",
},
onSubmit(values) {
console.log(values);
},
},
children: [
{
componentName: "Input",
props: {
fieldProps: {
name: "username",
rules: [{ required: true }, { min: 5 }],
},
},
},
{
componentName: "Input",
props: {
fieldProps: {
name: "password",
rules: [{ min: 8 }, { pattern: /[0-9a-zA-Z]{0,8}/ }],
},
type: "password",
placeholder: "Please input set a password",
},
},
],
};
<FormRender schema={schema} />
所以,只要实现 FormRender
组件,能够递归动态解析 schema 即可。这似乎不是特别难,只是细节会多一些,有了协议在,只需要按照协议来实现就行了。
但是!请注意!这里结束还很远,甚至只是刚刚开始!我为什么这么说呢?
完整版协议预告
我们来看一个例子: 初看布局特别复杂,这个问题其实比较好解决,上述的协议已经可以靠
layoutOptions
的配置来解决了。实际上最难处理的是一些非 Field 的自定义组件,比如 Sub Title,Notice Icon,甚至还有一个 Tabs 组件。这些组件都是不需要 fieldProps
属性的,相当于表单状态管理不关心的组件。它们要怎么用协议表示?
另外,还可能有展开/收起的模块。我们当然可以自定义一个这样的「容器组件」,但是无疑成本会比较大。而且还有不可穷尽的其他「小交互」需求,这些都涉及到了局部状态(比如 state.expand)管理,这个局部状态实际上与表单状态也是无关的。这个功能协议怎么表示?
我们稍微抽象一下,其实 Form 可以看成一个小型的页面,理论上里面有可能出现任何布局和元素,不仅仅只有 Field 元素。这意味着如果想要实现完美的 Form 配置化,其复难度与实现页面的配置化差不多了,而实现了页面配置化也基本等于实现了低代码了,看来事情比笔者最初想象的要复杂啊。
不过既然涉及到页面配置化了,那么 lowcode-engine 的协议 也就派上用场了,由于内容过于复杂,所以会另起一篇。
总结
表单之所以复杂,主要取决于两个维度:UI 和状态管理。前者参考业内目前比较流行的 UI 框架,后者参考业内比较流行的表单状态管理框架,再考虑到易用性,就产生了本文的协议。
另外,由于暂时不需要跨端或进行网络传输,所以协议采用了 JS Object
的语法表示,可以承载表达式、函数甚至组件等信息。如果用 JSON
实现这些,协议的结构会复杂很多。不过可以保证的是,即使有一天需要把现在的协议转化成 JSON,也是信息完备的,用 AST 的工具就可以完成自动转换。
最后,正如结尾的预告说的。本文实际上只完成了「表单配置化协议」的基础部分,只适用于非常简单的表单场景,虽然估计这些简单场景已经占到了实际情况的 60% 以上,但终究是不完美。再考虑到后续还要实现「列表配置化协议」,现在协议结构的包容性明显是不够的,所以接下来就是协议的升级了,敬请期待。
“谨记,你是在寻找最好的答案,而不是你自己能得出的最好答案。”——Ray Dalio