通用 Form API 协议 - 基础版

1,907 阅读10分钟

背景

笔者的目标非常明确,就是「提高中后台系统的开发效率」,目前经历了 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 渲染出来,供人使用。

有了以上认知,一个解决思路就出现了:

  1. 首先,协议是语言无关的,所以无论前端项目用的 React、Vue 甚至后端直出的模板,都可以应用;
  2. 再者,协议的实现可以用多种方式提效,比如低代码产品使用的方式是 可拖拽画布。说到底,协议就是一个大 JSON,我们还可以用表单、代码片段甚至插件来高效的生成它。 但是!上来就搞这么大的动作几乎是不可能成功的。所以理论要想落地,还是要有一定规划和里程碑的,通常不变形地落地才是工程的最大难点,于是来到了第三阶段。

第三阶段,也就是现阶段。路要一步一步走,饭要一口一口吃。经过慎重思考,笔者决定要走的第一步,就是先提升表单开发的效率。表单和列表占据了中后台系统绝大部分的内容,所以如果提升了表单的开发效率,实际上对于整体开发效率的提升还是有一定效果的。于是笔者进行了 表单状态管理 的调研,分析了现在主流表单状态管理框架的设计思路后,结合低代码协议的思路,整理成了本文。旨在制定一个 框架无关的、未来可以方便的扩展成低代码协议表单协议,来指导表单组件的封装,从而全范围提效。

目标

  1. 统一所有前端技术栈(React、Vue、甚至 RN)下的表单开发方式,只需要编写符合协议的 JS Object 配置,传入封装好的表单组件即可,即配置化
  2. 制定出表单组件的 API 以及具体格式,以承接符合协议的配置,指导组件的封装;

正文

分析

笔者在《Form 组件 API 对比 - AntD vs Element vs Naive》中总结到:

  • 组件分 2 级FormField(或 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 就行,再细一点就是 useFormuseField 之类的入参。但是,调研的三个工具 API 的设计还是有比较大的区别的,所以笔者会在设计时取交集,作为最重要的 API,其他的会视情况进行取舍和聚合。

Form Props

参数说明类型默认值
initialValues表单默认值,只有初始化以及重置时生效object-
onSubmit提交表单的回调事件function(values)-
validateTriggers统一设置字段触发验证的时机Array<"onChange" | "onBlur">[ "onChange" ]
formEventsonSubmit 外的其他回调事件,详见下文FormEvents-
labelOptionslabel 样式相关配置,详见下文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

参数说明类型默认值
visiblelabel 标签是否可见booleantrue
suffixlabel 标签后缀string":"
placementlabel 标签位置"left" | "top""left"
requiredMark表示「必选」的 * 位置"left" | "right" | "hidden""right"
stylelabel 标签的样式CSSProperties-

FormLayout

参数说明类型默认值
align垂直对齐方式"top" | "middle" | "bottom""top"
gutter栅格间隔,单位 pxnumber0
justify水平排列方式"start" | "end" | "center" | "space-around" | "space-between" | "space-evenly""start"
offset栅格左侧的间隔格数,间隔内不可以有栅格number0
pull栅格向左移动格数number0
push栅格向右移动格数number0
size组件尺寸"mini" | "small" | "medium" | "large""medium"
span栅格占位格数number24
wrap是否自动换行booleantrue

ValidateMessageOptions

参数说明类型默认值
visible验证提示是否可见booleantrue
placement验证提示位置"right" | "bottom""bottom"
validateFirst只显示第一条验证提示booleanfalse
stylevalidate message 的样式CSSProperties-

Field Props

参数说明类型默认值
name字段标识,具有唯一性string-
rules校验规则Array<Rule>-
validateTriggers统一设置字段触发验证的时机Array<"onChange" | "onBlur">["onChange"]
labelOptionslabel 样式相关配置LabelOptions-
layoutOptions布局相关配置Omit<FormLayout, "gutter" | "wrap">-
validateMessageOptions验证提示相关配置Omit<ValidateMessageOptions, "validateFirst">-
trigger设置收集字段值变更的时机string"onChange"
valueOptions对于 value 的预处理,详见下文ValueOptions-
fieldEvents表单域其他回调事件FieldEvents-
dependencesstring[]-

ValueOptions

参数说明类型默认值
formatOutput对组件输出后的 value 进行处理function(value): any-
parseInput传入组件之前,对 value 的预处理逻辑function(value): any-
propName子节点的值的属性,如 Switch 的是 "checked"string"value"

FieldEvents

参数说明类型默认值
before/afterChangechange 之前/后的回调函数function(value, values)-
before/afterBlurblur 之前/后的回调函数function(value, values)-
before/afterFocusfocus 之前/后的回调函数function(value, values)-
before/afterSubmitsubmit 之前/后的回调函数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 即可。这似乎不是特别难,只是细节会多一些,有了协议在,只需要按照协议来实现就行了。

但是!请注意!这里结束还很远,甚至只是刚刚开始!我为什么这么说呢?

完整版协议预告

我们来看一个例子: image.png 初看布局特别复杂,这个问题其实比较好解决,上述的协议已经可以靠 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