拖了大半年,今天终于开始动笔了。好吧,我承认我很坑(手动狗头),经常挖坑不填(摆烂.jpg)。非常感谢大家对我的敦促,终于又有动力把这个坑填上了。
上篇文章中,我们已经分析了动态表单的适用场景。接下来,我们会跟大家一起,从零开始实现一个动态表单引擎。
如何实现复杂动态表单?
如何实现复杂动态表单?这个问题有点大,我们可以把它先简单拆分一下:
- 动态表单需要支持哪些功能? 动态表单也是表单,因此要想实现一个完备的动态表单,基本表单功能必不可少,比如表单校验、表单联动、输入限制等。
- 如何用数据去描述表单,并支撑表单功能? 这是动态表单中最核心的部分。所有实现都会基于这个数据结构来完成,可以说只要设计好这个数据结构就成功一半了。如何通过数据结构驱动 UI 展示和交互,是我们需要着重考虑的问题。
- 如何让数据和 UI 相互映射? 有了前面的基础,这一部分就比较简单了,只需要通过获取的数据,将 UI 呈现出来,或者把 UI 转化成相应的数据结构即可。
总之,这些问题都离不开 DSL 设计和表单渲染。因此,后续文章也会从这两部分出发,去讲解复杂动态表单的实现。这篇文章中我们会着重介绍 DSL 设计。
动态表单的三个阶段
通常来说,动态表单可以分为创建、渲染和提交三个阶段。
表单创建
动态表单配置通常比较复杂,手写这些配置很困难,不仅容易写错也会让配置管理变得十分困难。而且,大部分情况下,动态表单配置需要用户根据自己的需求来生成。因此,为用户提供一个页面用于生成动态表单是更好的选择,用户可以通过它来定制表单。比如:
这一步就是把 UI 映射成表单配置项,并提交给后端服务用于创建表单:
表单渲染
将获取到的表单数据项渲染成 UI,和跟上一步类似,也是数据和 UI 映射的问题:
表单提交
前端将用户输入数据提交给后端,后端根据 formId 获取到表单配置,然后从表单配置中读取到验证规则,对用户输入进行验证,最后根据验证的不同结果去执行不同的行为,比如验证成功之后将数据转发给某个业务服务进行处理,并将处理结果返回给前端。前端也会根据不同 Response 类型进行不同处理,比如表单提交成功之后跳转到某个页面等。所有的分支逻辑都需要预先在表单配置中定义好:
JSON Schema or 自定义 DSL?
实现动态表单最重要的一个部分就是「如何使用数据去描述表单?」。JSON Schema 作为一套国际标准,在此基础上进行设计,能够让使用者节省一定的学习成本。但是,使用 JSON Schema 也免不了需要自定义一些字段,而这些自定义部分仍然会带来额外成本。相较于 JSON Schema,自己设计一套 DSL(Domain Specific Language),能够带来更多灵活性,但同时也给设计增加了难度和挑战。
| 表达能力 | 灵活性 | 简洁程度 | 学习成本 | 设计难度 | 可读性 | |
|---|---|---|---|---|---|---|
| JSON Schema | 中等 | 较低 | 较低 | 中等 | 较低 | 中等 |
| DSL | 较高 | 较高 | 较高 | 中等 | 较高 | 因人而异 |
DSL 是指领域特定语言,是专门针对特定应用领域的计算机语言。我们为 Form 打造的数据结构也属于一种 DSL。
总之,这两个方案都各有利弊,可以根据项目实际情况进行选择。本文会使用自定义 DSL 来设计动态表单。
DSL 整体框架
前面我们已经提到,数据是动态表单的基础。因此,设计一个合理的 DSL 尤为重要。接下来我们会先创建一个 DSL 架子,然后根据业务需求,逐步完善这个 DSL 以支撑更多表单功能。
interface FormConfig {
formId: string
widgets: Widget[]
title?: string;
description?: string;
actions: {
client: {
onSubmit: {
apiUrl: string,
},
onSubmitSuccess: RedirectAction | AlertAction,
onSubmitFail?: {},
},
server: {
onValidateSuccess?: {}
onValidateFail?: {}
},
}
}
interface Action {
type: string
}
interface RedirectAction extends Action {
type: "redirect"
url?: string
}
interface AlertAction extends Action {
type: "alert"
message?: string
}
interface Widget {
}
可以看出,一个简单的动态表单 DSL 主要由三个部分组成:唯一标识、渲染配置和关键钩子。其中唯一标识(formID)用于标识是哪个 Form。渲染配置主要用于表单渲染,比如表单标题、描述、表单组件列表(widgets)等。最后,通过一些预设好的钩子方法,来配置在特殊条件下会触发什么行为,比如在表单提交时需要请求哪个 API,表单提交成功之后客户端需要跳转到某个页面等。
formId: form 的唯一标识
widgets: form 的组件列表,用于描述 Form 的组成部分,比如 Input 输入框、日期选择器等
title: form 的标题部分,用于后续进行渲染
description: form 的描述部分,用于后续进行渲染
actions: 分为两个部分,其中 client 部分提供给客户端使用,用于定义当客户端应用在提交表单、接收到表单提交成功/失败的响应之后应该如何处理,而 server 部分提供给服务端使用,用于定义服务端在校验表单提交数据成功/失败之后应该如何处理。这部分的数据结构可以根据需求进行设计,比如客户端在表单提交成功之后需要跳转到某个页面,那么我们在 onSubmitSuccess 中定义 RedirectAction,标明跳转的 URL。
表单渲染 DSL
我们有非常多表单组件,比如普通输入框、单选框、多选框、日期选择器、日期范围选择器、计数器、开关控制等等。每个组件功能不一样,因此渲染时所需数据也不一样,比如对于多选框和单选框,除了通用字段之外,我们还需要提供 options,用于指定下拉选项列表。再比如计数器,我们需要指定可输入数字最小值和最大值。
面对如此多场景,我们要如何设计 DSL 呢?
- 提取通用部分
- 对组件进行分类
- 处理组件特殊参数
提取组件通用部分
对于一个表单组件来说,有些配置项是每个组件都会有的。就像人都有两个眼睛一个鼻子,一个表单组件也必定会有 name 和 widget 这两个属性。我们先把这些通用配置项抽离出来:
interface BasicWidget {
name: string // 组件的唯一标识
widget: "text" | "select" | "number" // 组件类型,用于渲染对应组件。这里只定义了三个,可以根据需求定义更多类型。
label?: string // 组件标签名
description?:string // 组件的描述内容
defaultValue?: any // 组件的默认值
}
对组件进行分类
前面我们提到过,表单组件非常多,因此渲染不同组件所需配置项也有很大差异,如果不对组件进行分类,那么所有组件配置项都会混在一起,无法分清哪个组件有哪些配置项, 使 DSL 变得混乱且难以理解。因此,对组件进行分类很重要。
虽然表单组件非常多,但是对于同一类组件来说,它们所需要的配置和验证方式是一样的。比如,不管是单选、多选还是搜索选择,它们都需要提供选择列表,因此描述列表的参数 options 对这类组件是必需的。
那么,组件应该根据什么来分类呢?我们可以通过「输入值类型」进行区分。比如,不管是单选、多选还是搜索选择,对于表单来说最终输入都是某个选项/选项列表。再比如,不管是 NumberInput、Slider 滑动条还是 Rate 打分组件,用户最终输入的都是某个数字。常见输入值类型有六种:string、number、date、option(选项)、boolean 和 array。
根据输入值类型对常用表单组件进行分类,我们可以将得出:StringWidget、NumberWidget、DateWidget、SelectWidget、BooleanWidget、FieldArrayWidget 和 FieldGroupWidget 七大类。
StringWidget
常见 StringWidget 有:文本输入框、TextArea 等。
这类组件通常会接收用户输入,并对用户输入进行校验或限制(比如只允许输入 xxx-xx-x 格式)等。因此,这类组件需要考虑表单校验、输入长度限制等问题。关于表单验证和输入限制,我们会在后面章节统一讲解。
interface StringWidget extends BasicWidget {
type: "string" // 组件类型
placeholder?: string // placeholder
validations?: Validation[] // 校验规则
maxLength?: number // 组件允许输入字符最大长度,超过则无法输入
minLength?: number // 组件允许输入字符最小长度,小于则无法删除字符
allowClear?: boolean // 是否显示清除按钮
addonBefore?: string // 前置 addon
addonAfter?: string // 后置 addon
}
在 DSL 设计中,凡是涉及到用户输入的组件,都需要考虑校验和输入限制问题。
NumberWidget
常见的 NumberWidget 有:数字输入框、Slider 滑动条和 Rate 打分组件。
这类组件输入都是数字,因此需要对数字范围进行限制。如果是 Input 这样可输入的组件,还需要限制输入只能为数字。
interface NumberWidget extends BasicWidget {
type: "number" // 组件类型
placeholder?: string // placeholder
validations?: Validation[] // 校验规则
max?: number // 允许输入的最大数字
min?: number // 允许输入的最小数字
}
BooleanWidget
常见的 BooleanWidget 有:Switch 开关、Radio 选择器、CheckBox 等。
这类组件通常也是提供选择而非输入功能,因此对于前端来说无需考虑校验问题。并且,不需要增加任何额外配置,因为表单选项是固定的。
interface BooleanInput extends BasicInput {
type: "boolean"
}
DateWidget
常见的 DateWidget 有:日期选择器、日期范围选择器、日期时间选择器等。
这类组件都跟时间相关,由于很多时间组件只接收 Date 对象,因此可能会涉及到 Date 对象和标准时间字符串之间的转换。除此之外,还需考虑时间范围限制,比如开始时间不能早于今天,结束时间不能超过今天之后 15 天等,由于限制并不是一个时间点(比如 min: 2010-10-10T00:00:00),这种「动态性」也需要通过 DSL 描述出来。另外需要注意的是,日期范围组件由开始日期和结束日期两个部分组成,因此 defaultValue、placeholder 和 Validation 都需要设计成 array。
interface DateWidget extends BasicWidget {
type: "date" // 组件类型
defaultValue: string | string[] // 默认值
placeholder?: string | string[] // placeholder
validations?: Validation[] | Validation[][] // 校验规则
max?: string | Operator // 时间范围不允许超过 max 指定时间,其中 string 是一个标准日期字符串,operator 用于描述动态时间
min?: string | Operator // 时间范围不允许小于 min 指定时间,其中 string 是一个标准日期字符串,operator 用于描述动态时间
}
关于 Operator 是如何解决动态日期问题,我们会在之后进行讲解,这里只需要知道 max 和 min 可以用标准时间字符串和动态时间来表示即可。
SelectWidget
常见的 SelectWidget 有:Input 选择框、radio 选择框、checkbox 选择框、search 搜索选择框等。
这类组件通常提供选择而非输入功能,因此对于前端来说无需考虑表单校验(Search 这类搜索选择框除外,因为用户可以输入),后端需要对这类组件做特殊校验,比如「提交的值必须在选择列表之内」,这个验证可以根据组件类型特殊处理,无需增加额外配置。
interface SelectWidget extends BasicWidget {
type: "option" // 组件类型
placeholder?: string // placeholder
options: Option[] // 选项列表
validations?: Validation[] // 校验规则
}
interface Option {
label: string // 名称,用于展示
value: string // 值,用于提交
}
FieldArray
FieldArray 是一类较复杂的组件,它里面每一项都会包含多个表单控件。除此之外,它还提供新增和删除功能,点击新增按钮后会「克隆」一组新的表单控件,点击删除按钮后会删除某一组表单控件。
这类组件每一项都包含多个表单控件,因此需要设计一个嵌套结构。另外,还需要控制整个数组长度,也就是新增和删除数量。
interface FieldArrayWidget extends BasicWidget {
type: "array"
max: number
min: number
items: Widget[]
validations: Validation[]
}
FieldGroup
与 FieldArray 类似,FieldGroup 也不是单纯用来描述某一个表单组件,而是一组表单组件。比如,我们可以将一个 Form 分成多组,每组可以包含多个 Widget 组件:
将 FieldGroup 作为一种特殊组件类型,和前面设计保持同样结构,这样的好处是我们可以任意组合它们。一个表单中既可以包含 FieldGroup,也可以出现单个组件。
interface FieldGroupWidget extends Widget {
type: "group"
items: {
title: string
items: Widget[]
}
}
处理组件特殊参数
将组件进行分类之后,剩下的设计就变得简单许多了。对于那些组件需要但是又不是很通用的配置,我们可以全部放到 extra 里面。但是这类特殊参数不到万不得已尽量少添加,以免后面难以管理。
interface ExtraProps {
// ...
}
完整结构
分类后我们可以完善出 widget 类型:
export interface FormSpec {
formId: string;
widgets: Widget[];
title?: string;
description?: string;
actions: {
client: {
onSubmit: {
apiUrl: string;
};
onSubmitSuccess: RedirectAction | AlertAction;
onSubmitFail: RedirectAction | AlertAction;
};
server: {};
};
submit: {
confirmText: string;
};
}
export type Widget =
| StringWidget
| NumberWidget
| DateWidget
| SelectWidget
| BooleanWidget
| FieldArrayWidget
| FieldGroupWidget;
interface Action {
type: string;
}
export interface RedirectAction extends Action {
type: "redirect";
url: string;
}
export interface AlertAction extends Action {
type: "alert";
message?: string;
}
interface BasicWidget {
name?: string;
widget?: string;
label?: string;
description?: string;
defaultValue?: any;
props?: any; // extra props will pass to component
visible?: Operator | boolean;
}
export interface StringWidget extends BasicWidget {
name: string;
type: "string";
widget: "text" | "textarea";
placeholder?: string;
rules?: Rule[];
maxLength?: number;
minLength?: number;
allowClear?: boolean;
addonBefore?: string;
addonAfter?: string;
}
interface NumberWidget extends BasicWidget {
name: string;
type: "number";
widget: "number" | "currency";
placeholder?: string;
rules?: Rule[];
max?: number;
min?: number;
}
interface BooleanWidget extends BasicWidget {
name: string;
type: "boolean";
widget: "switch" | "toggle" | "checkbox";
rules?: null;
}
interface DateWidget extends BasicWidget {
name: string;
type: "date";
widget: "datepicker" | "rangePicker";
defaultValue: string | string[];
placeholder?: string | string[];
rules?: Rule[] | Rule[][];
max?: string | Operator;
min?: string | Operator;
}
interface SelectWidget extends BasicWidget {
name: string;
type: "option";
widget: "select" | "optgroup" | "multiSelect";
placeholder?: string;
options: Option[];
rules?: Rule[];
}
interface Option {
id: string;
label: string;
value: string;
}
interface FieldArrayWidget extends BasicWidget {
name: string;
type: "array";
max: number;
min: number;
items: Widget[];
rules: Rule[];
}
interface FieldGroupWidget extends BasicWidget {
type: "group";
section: {
title: string;
widgets: Widget[];
};
rules?: null;
}
type Arg = Operator | string | number | boolean;
export type Operator = [item1: string, ...otherItems: Arg[]];
export type FormValue = Record<string, any>;
export type FieldValue = any;
export interface Rule {
rule: Operator;
when?: Operator;
errorMsg?: string;
}
type OperatorCore = (value: FieldValue, formValue: FormValue) => boolean | FieldValue;
export type Operators = { [key: string]: (...arg: any[]) => OperatorCore };
小结
在这篇文章中,我们先介绍了动态表单的三个阶段:表单创建、表单渲染和表单提交。接着,我们介绍了为什么选择 DSL 来实现动态表单,以及 DSL 整体设计框架。最后,我们详细介绍了表单渲染 DSL 设计,通过提取通用部分和组件分类,去完成表单渲染。
到这里,我们已经能够通过 DSL 将整个表单 UI 描述出来。接下来,我们会讲到一些复杂场景,如何为表单添加校验规则、表单联动、表单输入限制等。