动态表单(2)—— 复杂表单 DSL 设计(上)

2,713 阅读13分钟

拖了大半年,今天终于开始动笔了。好吧,我承认我很坑(手动狗头),经常挖坑不填(摆烂.jpg)。非常感谢大家对我的敦促,终于又有动力把这个坑填上了。

上篇文章中,我们已经分析了动态表单的适用场景。接下来,我们会跟大家一起,从零开始实现一个动态表单引擎。

本文所对应的 GitHub 源码地址:d-form

如何实现复杂动态表单?

如何实现复杂动态表单?这个问题有点大,我们可以把它先简单拆分一下:

  1. 动态表单需要支持哪些功能? 动态表单也是表单,因此要想实现一个完备的动态表单,基本表单功能必不可少,比如表单校验、表单联动、输入限制等。
  2. 如何用数据去描述表单,并支撑表单功能? 这是动态表单中最核心的部分。所有实现都会基于这个数据结构来完成,可以说只要设计好这个数据结构就成功一半了。如何通过数据结构驱动 UI 展示和交互,是我们需要着重考虑的问题。
  3. 如何让数据和 UI 相互映射? 有了前面的基础,这一部分就比较简单了,只需要通过获取的数据,将 UI 呈现出来,或者把 UI 转化成相应的数据结构即可。

总之,这些问题都离不开 DSL 设计和表单渲染。因此,后续文章也会从这两部分出发,去讲解复杂动态表单的实现。这篇文章中我们会着重介绍 DSL 设计。

动态表单的三个阶段

通常来说,动态表单可以分为创建、渲染和提交三个阶段。

表单创建

动态表单配置通常比较复杂,手写这些配置很困难,不仅容易写错也会让配置管理变得十分困难。而且,大部分情况下,动态表单配置需要用户根据自己的需求来生成。因此,为用户提供一个页面用于生成动态表单是更好的选择,用户可以通过它来定制表单。比如: image.png 这一步就是把 UI 映射成表单配置项,并提交给后端服务用于创建表单: image.png

表单渲染

将获取到的表单数据项渲染成 UI,和跟上一步类似,也是数据和 UI 映射的问题: image.png

表单提交

前端将用户输入数据提交给后端,后端根据 formId 获取到表单配置,然后从表单配置中读取到验证规则,对用户输入进行验证,最后根据验证的不同结果去执行不同的行为,比如验证成功之后将数据转发给某个业务服务进行处理,并将处理结果返回给前端。前端也会根据不同 Response 类型进行不同处理,比如表单提交成功之后跳转到某个页面等。所有的分支逻辑都需要预先在表单配置中定义好: image.png

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 呢?

  1. 提取通用部分
  2. 对组件进行分类
  3. 处理组件特殊参数

提取组件通用部分

对于一个表单组件来说,有些配置项是每个组件都会有的。就像人都有两个眼睛一个鼻子,一个表单组件也必定会有 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 等。 image.png 这类组件通常会接收用户输入,并对用户输入进行校验或限制(比如只允许输入 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 打分组件。 image.png 这类组件输入都是数字,因此需要对数字范围进行限制。如果是 Input 这样可输入的组件,还需要限制输入只能为数字。

interface NumberWidget extends BasicWidget {
  type: "number"  // 组件类型
  placeholder?: string // placeholder
  validations?: Validation[] // 校验规则
  max?: number // 允许输入的最大数字
  min?: number // 允许输入的最小数字
}

BooleanWidget

常见的 BooleanWidget 有:Switch 开关、Radio 选择器、CheckBox 等。 image.png 这类组件通常也是提供选择而非输入功能,因此对于前端来说无需考虑校验问题。并且,不需要增加任何额外配置,因为表单选项是固定的。

interface BooleanInput extends BasicInput {
  type: "boolean"
}

DateWidget

常见的 DateWidget 有:日期选择器、日期范围选择器、日期时间选择器等。 image.png 这类组件都跟时间相关,由于很多时间组件只接收 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 搜索选择框等。 image.png 这类组件通常提供选择而非输入功能,因此对于前端来说无需考虑表单校验(Search 这类搜索选择框除外,因为用户可以输入),后端需要对这类组件做特殊校验,比如「提交的值必须在选择列表之内」,这个验证可以根据组件类型特殊处理,无需增加额外配置。

interface SelectWidget extends BasicWidget {
  type: "option"  // 组件类型
  placeholder?: string // placeholder
  options: Option[] // 选项列表
  validations?: Validation[] // 校验规则
}

interface Option {
  label: string // 名称,用于展示
  value: string // 值,用于提交
}

FieldArray

FieldArray 是一类较复杂的组件,它里面每一项都会包含多个表单控件。除此之外,它还提供新增和删除功能,点击新增按钮后会「克隆」一组新的表单控件,点击删除按钮后会删除某一组表单控件。 image.png 这类组件每一项都包含多个表单控件,因此需要设计一个嵌套结构。另外,还需要控制整个数组长度,也就是新增和删除数量。

interface FieldArrayWidget extends BasicWidget {
  type: "array"
  max: number
  min: number
  items: Widget[]
  validations: Validation[]
}

FieldGroup

与 FieldArray 类似,FieldGroup 也不是单纯用来描述某一个表单组件,而是一组表单组件。比如,我们可以将一个 Form 分成多组,每组可以包含多个 Widget 组件: image.png 将 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 描述出来。接下来,我们会讲到一些复杂场景,如何为表单添加校验规则、表单联动、表单输入限制等。

参考: x-render.gitee.io/