背景
在《通用 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 | 渲染条件 | Boolean | ✅ | true | 选填,根据表达式结果判断是否渲染物料;支持变量表达式 |
| loop | 循环数据 | Array | ✅ | - | 选填,默认不进行循环渲染;支持变量表达式 |
| loopArgs | 循环迭代对象、索引名称 | [String, String] | [“item”, “index”] | 选填,仅支持字符串 | |
| children | 子组件 | Array | 选填,支持变量表达式 |
在这里,我们只需要关心加粗的 componentName、props、condition、children 即可,其它属性暂时可以忽略,很简单,很容易理解吧。别急,这只是普通的组件,在 LE 中还有「容器组件」的概念,为了不把概念搞复杂,只挑一些重点的区别来帮助大家理解。
| 参数 | 说明 | 类型 | 支持变量 | 默认值 | 备注 | |
|---|---|---|---|---|---|---|
| componentName | 组件名称 | 枚举类型,包括 Page(代表页面容器)、Block(代表区块容器)、Component(代表低代码业务组件容器) | - | ‘Div’ | 必填,首字母大写 | |
| lifeCycles | 生命周期对象 | ComponentLifeCycles | - | - | 详见 ComponentLifeCycles 对象描述 | |
| state | 容器初始数据 | Object | ✅ | - | 选填,支持变量表达式 | |
| dataSource {} | 数据源对象 | ComponentDataSource | - | - | 选填,异步数据源, 详见 ComponentDataSource 对象描述 |
容器类组件只有 3 种:Page、Block、Compnent,下面引用一下 LE 的定义:
- 区块(Block) :通过低代码搭建的方式,将一系列业务组件、布局组件进行嵌套组合而成,不对外提供可配置的属性。可通过 区块容器组的包裹,实现区块内部具备有完整的样式、事件、生命周期管理、状态管理、数据流转机制。能独立存在和运行,可通过复制 schema 实现跨页面、跨应用的快速复用,保障功能和数据的正常。
- 页面(Page) :由组件 + 区块组合而成。由页面容器组件包裹,可描述页面级的状态管理和公共函数。
关键词是:样式、事件、生命周期管理、状态管理、数据流转机制,这些属性在普通组件当中是没有的。如果从代码的角度去理解,普通组件就相当于函数组件,容器组件就相当于 React Class 组件。对于我们要实现的表单来说,起码最外层的 Form 组件是需要具备这些能力的,哪怕用的场景很少,所以 Form 一定是个「容器组件」。
注:从 FunctionComponent 的参数我们可以得到启示,参数除了 props 外,还有一个 context。如果放到我们表单的场景下,就是需要最外层 Form 向所有后代组件提供
form context的能力。为了不影响主干思路,这里不过多展开。
小结
- Field 组件多为「普通组件」,只需要具备
componentName、props、condition、children属性即可; - Form 组件应该是个「容器组件」,除了具备普通组件的属性,还要具备
lifeCycles、dataSource、state等属性;
非 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>;
}
综合考虑易用性,可以总结为如下解析规则:
- 渲染时递归所有层级的子组件,如果遇到
isField为 true,则用Field组件包裹,并赋予其 Field 的各种能力; - Form 的第一层 children 默认
isField值为 true,第二层起默认为 false; 若碰到容器组件,则递归停止,仅调用容器组件渲染逻辑;
局部状态管理
非 Field 组件相对好处理,接下来我们看看局部状态如何做。其实 LE 的容器组件已经给了解决方案,容器 API 中已经有了局部状态 this.state 和修改局部状态的方法this.setState 了,所以直接复用其设计即可。但是,这里笔者还是有两个顾虑:
- 感觉用
this这个关键词有一点别扭,毕竟前端曾经「苦 this 久矣」,所以总觉得会有更好一点的设计。比如配置数据不再是个 JS Object,而是一个函数(ctx) => FieldSchema; this.setStateReact 的痕迹太重了,其参数必须是一个对象,毕竟该规范设计的初衷是框架无关的。直接用 Vue 的data.key = value直接赋值的形式也不太好。个人认为setData(key, value)这种形式会通用点,相应的 state 也得改成 data。不过这个变量名总觉得有点太通用了,心里不太踏实,先用着试试吧;
注意,为了保证不破坏原协议,笔者的所有修改都是增量修改,不会讲 LE 协议原有的功能复写,这是最基本的原则。
生命周期
即 Lifecycles,下表为 Vue 与 React 的生命周期对比,加粗的为 LE 支持的:
| Vue3 | Vue2.x | React15 | React16 |
|---|---|---|---|
| setup | beforeCreate/created | constructor | |
| onBeforeMount | beforeMount | UNSAFE_componentWillMount | |
| onMounted | mounted | componentDidMount | useEffect |
| onBeforeUpdate | beforeUpdate | UNSAFE_componentWillUpdate | |
| onUpdated | updated | componentDidUpdate | useEffect |
| onActivated | activated | ||
| onDeactivated | deactivated | ||
| onBeforeUnmount | beforeUnmount | componentWillUnmount | useEffect |
| onUnmounted | unmounted | ||
| onErrorCaptured | errorCaptured | componentDidCatch | |
| onRenderTracked | renderTracked | ||
| onRenderTriggered | renderTriggered | ||
| computed | computed | shouldComponentUpdate | useMemo |
| watch | watch | UNSAFE_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 读取吧。
表单上下文
从 state 和 dataSource 的部分可以看出,上下文是必不可少的。特别的,其实表单拥有很多能力,或者叫功能,它们理论上可以被表单内部任意的组件调用,比如 form.submit、form.setFieldValue、form.isValidatiing 等。这个 Form 上下文就相当于「表单状态管理工具的实例」,比如 React Hook Form 的 useForm(useFormContext) 的返回值,Final Form 中 createForm 的返回值。从文档中我们可以知道它们有多复杂。不过笔者打算直接就复用了,目前来看大概率是使用 Final Form。
另一个问题就是如何访问了,一种方案是直接使用 LE 中的 this.$(ref) 访问,但是怕误用了。所以还是选择再增加一个 this.form 属性吧。
总结
当把 Form 视作一个「小页面」,并参考 LE 的规范时,很多问题都能找到解决方案,甚至之前没想到的问题,LE 的规范也给补上了。
笔者只是在 LE 的基础上做了两件事:
- 利用 JS Object 实现 LE 协议的简化,以提升易用性;
- 针对 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