通用 Form API 协议 - 完全版

319 阅读9分钟

背景

在《通用 Form API 协议 - 基础版》(以下简称,基础版)里挖了个坑:

最后,正如结尾的预告说的。本文实际上只完成了「表单配置化协议」的基础部分,只适用于非常简单的表单场景,虽然估计这些简单场景已经占到了实际情况的 60% 以上,但终究是不完美。再考虑到后续还要实现「列表配置化协议」,现在协议结构的包容性明显是不够的,所以接下来就是协议的升级了,敬请期待。

基础版中已经将表单最基本的能力设计出来了,但是非 Field 组件、局部状态管理等功能并没有实现。所以本文将参考 lowcode-engine 的协议(以下简称 LE),将表单视为一个「小页面」,补齐这些缺失的能力。

基础版协议结构:

interface FieldSchema {
  componentName: string;
  props: {
    fieldProps?: FieldProps,
    // ...other private props
  };
  children?: Array<FieldSchema>;
}

正文

分析思路:先看一下 LE 协议是如何描述组件的,看看是否能得到什么启发,发现什么规律。然后再针对性的处理非 Field 组件、初始化数据、生命周期等问题。

lowcode-engine 协议参考

开始之前,我们来看看 LE 是如何表示「普通组件」的:

参数说明类型支持变量默认值备注
id组件唯一标识String-可选, 组件 id 由引擎随机生成(UUID),并保证唯一性,消费方为上层应用平台,在组件发生移动等场景需保持 id 不变
componentName组件名称String-Div必填,首字母大写, 同 [componentsMap](#22-组件映射关系 a) 中的要求
props {}组件属性对象Props-{}必填, 详见 Props 结构描述
condition渲染条件Booleantrue选填,根据表达式结果判断是否渲染物料;支持变量表达式
loop循环数据Array-选填,默认不进行循环渲染;支持变量表达式
loopArgs循环迭代对象、索引名称[String, String][“item”, “index”]选填,仅支持字符串
children子组件Array选填,支持变量表达式

在这里,我们只需要关心加粗的 componentNamepropsconditionchildren 即可,其它属性暂时可以忽略,很简单,很容易理解吧。别急,这只是普通的组件,在 LE 中还有「容器组件」的概念,为了不把概念搞复杂,只挑一些重点的区别来帮助大家理解。

参数说明类型支持变量默认值备注
componentName组件名称枚举类型,包括 Page(代表页面容器)、Block(代表区块容器)、Component(代表低代码业务组件容器)-‘Div’必填,首字母大写
lifeCycles生命周期对象ComponentLifeCycles--详见 ComponentLifeCycles 对象描述
state容器初始数据Object-选填,支持变量表达式
dataSource {}数据源对象ComponentDataSource--选填,异步数据源, 详见 ComponentDataSource 对象描述

容器类组件只有 3 种:PageBlockCompnent,下面引用一下 LE 的定义:

  • 区块(Block) :通过低代码搭建的方式,将一系列业务组件、布局组件进行嵌套组合而成,不对外提供可配置的属性。可通过 区块容器组的包裹,实现区块内部具备有完整的样式、事件、生命周期管理、状态管理、数据流转机制。能独立存在和运行,可通过复制 schema 实现跨页面、跨应用的快速复用,保障功能和数据的正常。
  • 页面(Page) :由组件 + 区块组合而成。由页面容器组件包裹,可描述页面级的状态管理和公共函数。

关键词是:样式、事件、生命周期管理、状态管理、数据流转机制,这些属性在普通组件当中是没有的。如果从代码的角度去理解,普通组件就相当于函数组件,容器组件就相当于 React Class 组件。对于我们要实现的表单来说,起码最外层的 Form 组件是需要具备这些能力的,哪怕用的场景很少,所以 Form 一定是个「容器组件」。

注:从 FunctionComponent 的参数我们可以得到启示,参数除了 props 外,还有一个 context。如果放到我们表单的场景下,就是需要最外层 Form 向所有后代组件提供 form context 的能力。为了不影响主干思路,这里不过多展开。

小结

  1. Field 组件多为「普通组件」,只需要具备 componentNamepropsconditionchildren 属性即可;
  2. Form 组件应该是个「容器组件」,除了具备普通组件的属性,还要具备 lifeCyclesdataSourcestate 等属性;

非 Field 组件

看完了 LE 对于组件的定义,我们来看一下在表单里的「非 Field 组件」,即不需要赋予 Field 能力(onChange、value、validate 等)的组件,比如副标题、解释说明的文本、带 popover 的 icon、分割线等。它们多是作为非功能性组件存在的,只是展示,不需要被 Field 组件包裹,如何表示这种组件呢?其实很简单,增加一个 isField 属性就行了,如下:

interface FieldSchema {
  componentName: string;
  props: {
    fieldProps?: FieldProps;
    isField?: boolean;
    // ...other private props
  };
  children?: Array<FieldSchema>;
}

综合考虑易用性,可以总结为如下解析规则:

  1. 渲染时递归所有层级的子组件,如果遇到 isFieldtrue,则用 Field 组件包裹,并赋予其 Field 的各种能力;
  2. Form 的第一层 children 默认 isField 值为 true,第二层起默认为 false
  3. 若碰到容器组件,则递归停止,仅调用容器组件渲染逻辑

局部状态管理

非 Field 组件相对好处理,接下来我们看看局部状态如何做。其实 LE 的容器组件已经给了解决方案,容器 API 中已经有了局部状态 this.state 和修改局部状态的方法this.setState 了,所以直接复用其设计即可。但是,这里笔者还是有两个顾虑:

  1. 感觉用 this 这个关键词有一点别扭,毕竟前端曾经「苦 this 久矣」,所以总觉得会有更好一点的设计。比如配置数据不再是个 JS Object,而是一个函数 (ctx) => FieldSchema
  2. this.setState React 的痕迹太重了,其参数必须是一个对象,毕竟该规范设计的初衷是框架无关的。直接用 Vue 的 data.key = value 直接赋值的形式也不太好。个人认为 setData(key, value) 这种形式会通用点,相应的 state 也得改成 data。不过这个变量名总觉得有点太通用了,心里不太踏实,先用着试试吧;

注意,为了保证不破坏原协议,笔者的所有修改都是增量修改,不会讲 LE 协议原有的功能复写,这是最基本的原则。

生命周期

即 Lifecycles,下表为 Vue 与 React 的生命周期对比,加粗的为 LE 支持的:

Vue3Vue2.xReact15React16
setupbeforeCreate/createdconstructor
onBeforeMountbeforeMountUNSAFE_componentWillMount
onMountedmountedcomponentDidMountuseEffect
onBeforeUpdatebeforeUpdateUNSAFE_componentWillUpdate
onUpdatedupdatedcomponentDidUpdateuseEffect
onActivatedactivated
onDeactivateddeactivated
onBeforeUnmountbeforeUnmountcomponentWillUnmountuseEffect
onUnmountedunmounted
onErrorCapturederrorCapturedcomponentDidCatch
onRenderTrackedrenderTracked
onRenderTriggeredrenderTriggered
computedcomputedshouldComponentUpdateuseMemo
watchwatchUNSAFE_componentWillReceiveProps

这里仍然可以看到 LE 浓重的 React 痕迹。生命周期这块笔者列为低优实现的功能,而且其复杂度也比较高,所以暂时也选择沿用 LE 的方案。

异步数据源

即 dataSource,这个属性还是有较大的可能性用到,但是 LE 的设计实在是有点奇怪,笔者也是研究了很久才勉强理解。看一下 dataSource 属性的伪代码定义:

interface DataSource {
  list: Array<{
    id: string;
    dataHandler: Function;
    // ...others
  }>;
  dataHandler: (dataMap: Record<id, data>) => void;
}

我们试着理解一下。

  • 首先,list 意味着可能会有多个异步数据源,这个比较好理解;
  • 然后,list 的 item 基本上等于 request 请求的配置项。注意 id,很容易猜到它是当前请求的唯一标识,后面我们看它在哪用;
  • 再看外层与 list 平级的 dataHandler,从名字也可以猜出它是一个数据处理函数。注意看它的入参,是一个 Object 类型,这个 Object 的 key,就是上条提到的 id。所以其实我们最终得到的就是一个 key-value 的 Object;
  • 如何使用呢?在上文说 state 的时候,可以在文档中发现有一个 this.dataSourceMap 的属性,没错,还是通过上下文来访问的。

(2022.07.12 按:暂时还没想好怎么设计,先按照原协议做吧)

兜了这么大的圈子,还是因为 JSON 格式的局限性导致了配置格式复杂度的增加,如果用 JS Object 表示,会简化很多:

// 为了与 LE 区分,特意在结尾加了 "s"
type DataSources = Record<string, Promise<any>>

没错,就这么简单,什么 dataHandler,request options,统统写在 Promise 和它的 then 里面去。当然,使用的话为了不混淆,或者说不影响原协议,暂定为用 this.dataSources 读取吧。

表单上下文

statedataSource 的部分可以看出,上下文是必不可少的。特别的,其实表单拥有很多能力,或者叫功能,它们理论上可以被表单内部任意的组件调用,比如 form.submitform.setFieldValueform.isValidatiing 等。这个 Form 上下文就相当于「表单状态管理工具的实例」,比如 React Hook Form 的 useForm(useFormContext) 的返回值,Final Form 中 createForm 的返回值。从文档中我们可以知道它们有多复杂。不过笔者打算直接就复用了,目前来看大概率是使用 Final Form。

另一个问题就是如何访问了,一种方案是直接使用 LE 中的 this.$(ref) 访问,但是怕误用了。所以还是选择再增加一个 this.form 属性吧。

总结

当把 Form 视作一个「小页面」,并参考 LE 的规范时,很多问题都能找到解决方案,甚至之前没想到的问题,LE 的规范也给补上了。

笔者只是在 LE 的基础上做了两件事:

  1. 利用 JS Object 实现 LE 协议的简化,以提升易用性;
  2. 针对 Form 的特点,增量定制了一些属性;

总之设计的前提是:保留足够信息完整性,让修改后的协议具备随时自动化转译成 LE 规范的能力

完整版协议 TS 伪代码如下:

type FieldConfig = FieldSchema | ((ctx: FormCtx) => FieldSchema);

interface FieldSchema {
  componentName: string;
  props: {
    fieldProps?: FieldProps,
    isField?: boolean,
    // ...other private props
  };
  children?: Array<FieldSchema>;
  lifeCycles?: Record<string, Function>;
  // dataSources?: Record<string, Promise<any>>;
}

interface FormCtx {
  // dataSources: Record<string, any>;
  form: FinalFormInstance;
  data: Record<string, any>;
  setData: (key: string, value: any) => void;
  // ...extends LE context API
}

“问题不可能在产生问题的意识水平上得到解决。”

"Problems cannot be solved at the same level of awareness that created them."——Albert Einstein