随着各个企业数字化转型的不断推进,越来越多的企业开始重视业务流程自动化,其中表单处理是一个重要和复杂的环节。传统的表单处理方式通常需要开发人员编写大量的代码,而“动态表单”可以在无需开发人员介入的情况下,帮助企业快速地生成符合业务需求的表单页面,提高业务效率和减少出错率,成为数字化转型中的一种重要技术方案。
然而动态表单系统虽然看起来“很美”,但是要实现一个高效易用的动态表单系统也不是一件容易的事情:表单如何实现动态渲染,如何让非技术人员可配置表单,表单之间复杂的联动关系如何处理?如何校验表单数据的合法性等都是在设计动态表单系统中不可忽视的问题。
笔者在工作中长期开发和维护公司核心的动态表单项目,也对社区里知名的项目做过较为深入的研究,笔者发现虽然各个动态表单的系统实现略有差别,但是核心的设计思路确是大同小异,且有章可循的。
本文将从较为抽象的角度探讨如何设计一个通用的动态表单系统,尽量为大家梳理出一条清晰的设计脉络,希望能为设计和学习类似项目的人提供帮助。
动态表单是什么?
在设计动态表单系统之前,需要先明确动态表单的概念。
动态表单与其他类似的低/无代码平台的核心都可以用下面这句话来概括
给我一份数据,我帮你渲染出你想要的页面,实现相关的功能
在这句话中也就蕴含了实现动态表单系统最关键的两个要素
即数据和渲染。
这里的数据,可以归纳为描述表单的 DSL,而渲染则可以归纳为表单引擎。
于是我们要设计一个动态表单系统,其实也就变成了如何设计描述表单的 DSL 以及针对 DSL 进行实际渲染的表单引擎。
我们首先来考虑 DSL 的设计
表单的 DSL
表单 DSL 设计的重点是三个方面,如何描述表单的 UI 样式?如何描述表单的数据结构?如何描述表单项之间的关系?
描述 UI 和描述表单项之间的关系相对都比较好理解,那为什么还要描述数据呢?
描述数据 - JSON Schema
对于表单系统来说,它的核心其实就是为了处理数据而存在的,来看看下面这个例子:
这份表单在业务上的价值就是维护下面这份 JSON 数据:
{
"name": "xiong",
"age": 12,
"sex": "male"
}
数据在不同业务中可能有不同要求,比如在某个实际的业务中
- name 这个字段只能是字符串类型,20个字符以内,并且该项必填
- age 这个字段只能是 number 类型
- sex 这个字段的值是只能是“male” 或者 "female"
上面这些信息就是对数据的描述,只不过是人类语言的描述,那如果我们想要用计算机的语言来描述的话,那就得用到JSON-Schema 了,它是一种用于描述和验证 JSON 数据结构的协议。我们可以用 JSON Schema 的形式来转换人类语言
{
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 20
},
"age": {
"type": "number"
},
"sex": {
"type": "string",
"enum": [
"male",
"female"
]
}
},
"required": [
"name"
]
}
通过上述的描述,我们很清楚的让计算机知道我们维护一个对象数据,其包含 name、age 和 sex 属性。name 属性是一个字符串类型,字符长度是 20 且是一个必填字短,age 是一个 number 类型,sex 属性也是一个字符串类型,它的值必须是两个预设的枚举值"male" 和 "female" 中的一个。
通过 JSON Schema, 我们可以很好的用计算机语言来描述表单的数据。
描述样式 - UI Schema
设计动态表单的 DSL 除了要描述表单的数据结构之外,还需要描述表单的 UI 样式,还是拿上面这个表单举例,姓名这个表单项的交互样式理论上既可以用 input
输入框表示,也可以用 textarea
多行输入表示,当表单项的值为空时可以有不同的 placeholder
文案。又比如性别这个表单项,它既可以是 radio
,也可以是 select
下拉选择框,即可以是可选择状态,也可以是禁用状态。
那想要描述这些 UI 状态应该怎么办呢?目前社区没有特别固定的解法,普遍的做法是将一些通用的样式配置定义出来,然后制定一些描述规范来描述这些配置,如 formily 中会使用 x-*
来表示 UI 样式, react-json-schema-form (以下简称 rjsf )则是使用 ui:xxx
的方式。
我们用 rjsf 的规则来描述上面说的表单样式如下
{
"name": {
"ui:placeholder": "some placeholder text",
"ui:widget": "input"
},
"gender": {
"ui:disable": false,
"ui:widget": "select"
},
"age": {
"ui:widget": "input-number"
}
}
- ui:placeholder:表示占位文案
- ui:widget: 表示实际渲染组件
- ui:disable: 表示该项是否禁用
rjsf 描述 UI 的方式是将 UI schema 和 json shcema 完全隔离。
// JSON Schema
{
"type": "object",
...
...
}
// UI Schema
{
"name": {
"ui:placeholder": "some placeholder text",
"ui:widget": "input"
},
...
...
}
formily 的方式和 rjsf 略有不同,formily 会将 UI 相关的描述融合到 json schema 中,在 json schema 中做扩展
{
"type":"object",
"properties":{
"name":{
"type":"string",
"maxLength":20,
"x-component":"Input",
"x-component-props":{
"placeholder":"请输入"
}
},
...
},
"required":[
"name"
]
}
两种方式各有所长,rjsf 在关注点分离方面做的蛮好,但就像 formily 说的一样,这里的关注点分离会带来一定的切换和学习成本,另外在做配置的时候,希望一个表单项的配置不是分开的,所以基于这些考虑,个人觉得在 JSON Schema 之上来扩展 UI Schema 的方案会略好一些。
描述关系 - 联动
之所以要描述表单项之间的关系,则是因为在一个表单中,表单项之间的联动操作实在太过于常见了。
表单的联动操作简单理解就是某个表单项的变化会引起另外表单项的变化。比如表单项的某个值会控制另外一个表单项的显隐。
联动逻辑在计算机语言中可以用条件判断语句表示:
if (condition) {
do1()
} else {
do2()
}
但是这种描述方式不够结构化,做解析的时候比较麻烦,Formily 描述的方式会更清晰一些
{
"x-reactions":[
{
"when":"{{$self.value == '123'}}",
"target":"target",
"fulfill":{
"state":{
"visible":true
}
},
"otherwise":{
"state":{
"visible":false
}
}
}
]
}
when
表示联动条件,target
表示作用对象,fulfill
表示满足条件做什么事情,otherwise
则表示没满足做什么事情。整套描述逻辑非常清晰,可以方便地在表单引擎解析schema时获取相关条件并进行后续处理。
使用混合 JSON Schema 和 UI Schema 以及联动协议的方式来描述表单,是表单 DSL 设计的整个核心。现在我们有了数据,接下来需要设计一个兼具扩展性和灵活性的表单引擎。
表单引擎
表单引擎需要包含哪些功能呢?回答这个问题之前,我们来回顾一下常见表单的生命周期,一个表单的生命周期一般都是表单渲染,操作表单,最后提交表单。
我们在设计表单引擎功能的时候也需要进行对应,将 DSL 渲染成实际的表单页面,同时需要支持完成表单的一些操作(联动),进行数据提交(校验)。
表单渲染 - 从 DSL 到表单
在这一阶段重点考虑将 DSL 转换为表单页面展示的问题,因为表单 DSL 主要由 JSON Schema 构成,因为它是一套递归的数据结构,所以 DSL 转换成真实渲染的表单实际上是一个组件逐级递归渲染的过程。
{
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 20
},
"age": {
"type": "number"
},
"sex": {
"type": "string",
"enum": [
"male",
"female"
]
},
"contacts": {<---- 非叶子节点,则继续渲染
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 20
},
"age": {
"type": "number"
},
"sex": {
"type": "string",
"enum": [
"male",
"female"
]
}
}
}
}
},
"required": [
"name"
]
}
数据类型和渲染组件之间存在对应关系,比如 number
类型可以对应 input number
类型的组件,string
类型可以对应 input
类型的组件。如果碰到有 children
的情况,则进行递归渲染。
以上是一个简单的 DSL 渲染成真实表单的例子,在实际的应用中,通常会在组件库和 DSL 之间加一层代理层(可能不太准确,或者说胶水层),来处理表单渲染的通用逻辑(如处理 ui schema ,展示隐藏对齐、对齐方式等),并将组件库和渲染过程解耦。
最终渲染过程如下图
渲染入口是 SchemaField 组件,因为 DSL 是一个递归型的数据结构,所以 SchemaField 从顶层的 Schema 节点开始解析,如果碰到非 object 和 array 类型则直接渲染具体的 UI 组件,如果是 object 或者 array 类型,则会遍历 properties 继续用 SchemaField 渲染子级 Schema 节点。
表单操作 - 联动
在表单操作阶段,需要考虑的核心问题是表单的联动操作:
关于联动的解释在前面 DSL 的部分已经给大家介绍过了,要实现联动需要确定何时满足联动条件以及触发什么相应的行为,以及未满足条件需要执行什么行为。
{
"x-reactions":[
{
"when":"{{$self.value == '123'}}",
"target":"target",
"fulfill":{
"state":{
"visible":true
}
},
"otherwise":{
"state":{
"visible":false
}
}
}
]
}
联动关系和相关的行为我们在上面的 DSL 已经定义好了, 那剩下的问题就变成了怎么监听条件值的变化,以及怎么样更改目标值?
(1)监听条件值的变化
要实现监听,我们很容易想到响应式机制,如 vue 体系里基于 proxy 的响应式机制,react 中基于 fiber 的响应式机制,angular 中的脏检查机制,mobx 等,你当然可以使用以上任何的方法来实现对值的监听,如果你使用 vue/react,可以使用框架提供的响应式监听机制去监听值的变化。如果你想要框架无关,可以使用一些像 mobx 这样通用的方式,将需要监听的值转换成 Observable 的数据即可。
(2)修改目标值
在实现了监听之后,我们接下来需要在监听到变化之后,对目标进行相应的修改。这部分最重要的就是建立目标值和条件值之间的关系,目标值订阅联动条件,一旦条件值发生变化,就触发回调来修改目标值。
我们可以借鉴 mobx 关于 Reactions 理念的图来梳理一下整个表单的联动过程
- 在表单 DSL 中描述联动关系,定义联动条件,目标值,及条件发生/未发生的动作。
- 表单引擎先将所有的表单值转换成 Observable 数据。(所有的联动条件都和表单值有关)
- 在表单引擎解析 DSL 的过程中,将 DSL 中描述的联动关系,组合成上图中 1 的 Function,并订阅在第 2 步已转换成 Observable 的表单值。
- 订阅的表单值发生变化,触发 Function,修改目标值。
表单提交 - 校验
表单提交阶段的核心场景是表单校验,那如何实现表单数据的校验呢?这个我们在 DSL 部分已经说过了,需要借助 JSON Schema 的能力。
使用 JSON Schema 能很好地支持大部分校验需求,下面来看一个例子:
有一个基础的表单需要收集用户的姓名、年龄和性别,期望用户最终提交的数据如下
{
"name": "bear",
"age": 12,
"sex": "male"
}
其中各项的要求如下:
- name:值要求是字符串类型,20个字符以内,并且该项必填
- age: 值要求是 number 类型
- sex: 值要求是 male、female 中的一个
下面我们来看看 JSON Schema 如何帮助我们实现以上的数据校验的
{
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 20
},
"age": {
"type": "number"
},
"sex": {
"type": "string",
"enum": [
"male",
"female"
]
}
},
"required": [
"name"
]
}
先简单介绍下 json schema 各个属性的含义
- type: 指定 schema 节点对应的数据类型
- properties: 属性的描述和限制条件
- required: 必填的属性
- maxLength: 最大长度
- enum: 值的枚举,值只能是枚举中的一个
我们可以将上面例子放到 JSON Schema 在线校验的网站上查看结果,如果值为
{
"name": "bear",
"age": 12,
"sex": "male"
}
校验结果如下
JSON Schema 校验正确
如果将 age 的数据改为 "12",则校验结果如下
JSON Schema 校验错误
keyword : type, message : must be number
如果将 sex 的值改为 "male1",则校验结果如下
JSON Schema 校验错误 keyword : enum, message : must be equal to one of the allowed values
如果不传 name 的值,则校验结果如下
JSON Schema 校验错误 keyword : required, message : must have required property 'name'
借助 JSON Schema 的能力我们可以完成大部分数据校验,当然除了上述介绍的功能外,JSON Schema 还有更多强大的功能,如支持正则校验,递归,重用等,大家如果感兴趣可以看看 Understanding JSON Schema 这篇文章。但是有一些场景 JSON Schema 的能力还不能完全覆盖到,比如有时候我们需要校验两次密码的输入是否输入一致?
这时候我们就需要提供一套自定义的校验方式,伪代码如下
'ui-validator': `{{(value, formData)=> {
if (!value) return ''
return value !== formData.pwd2 ? '两次密码不一致' : ''
}}}`,
通过 JSON Schema 提供的校验能力和自定义校验能力的组合,基本上就能覆盖所有表单校验的场景了。
表单编辑器
再来说下表单编辑器,这部分相对好理解,表单编辑器的作用是通过可视化界面生成 JSON Schema。
来看下我司表单编辑器的页面,左侧的每个组件(不管是基础组件,还是业务组件)背后都是一段 JSON Schema 片段,右侧的编辑区域则是修改 JSON Schema 里对应属性的值,中间则用来编排整个表单对应的JSON Schema,同时也是一个所见即所得的预览区。
可视化的目的是将表单的建设能力交给非技术人员,减轻开发人员的负担。在建设表单编辑器时,应该根据实际业务提炼出用到的业务组件,使非技术人员在配置表单时更加简单,减轻心智负担。
分层架构
最后我们讨论一下整个表单引擎的系统架构,从设计上来说,我们希望整个系统的设计是灵活和可扩展的,表单的核心逻辑(如联动、校验等)和视图层无关,表单的渲染也和组件库是解耦的。
基于此,我们采用分层架构的方式来设计表单, 将整个表单系统分成表单核心领域层 + 渲染层 + 组件层
这样做的好处有
- 如果需要实现跨技术栈的动态表单(如小程序动态表单、vue 动态表单、react 动态表单),可以最大程度的重用表单核心逻辑,不同组件库的逻辑也类似。
- 如果未来要做框架级别的迁移,无需大部分重构代码
分层架构带来的问题是核心层和其他层之间的通信方式。通过采用 reactive 机制,我们可以实现核心层和其他层之间的解耦,从而让表单核心逻辑与视图无关。
组件层和渲染层的解耦逻辑相对比较好理解,可以理解为实现一个 适配器模式
当最终渲染表单项的时候,只要统一渲染器入参,将要渲染的组件作为参数传入 decorator,就能很轻松实现组件的解耦了,下面是相关伪代码:
return h(
field.decoratorType,
{
class: {},
style,
attrs,
attrs
}
)
关于这部分内容的详细设计,我觉得目前开源社区里 formily 的分层是做的最好的,感兴趣的同学们可以点击 查看它们的实现细节。
总结
设计一个动态表单系统的核心在于描述表单的 DSL 和表单引擎的设计。为了描述数据、UI 及表单项之间的联动关系,我们推荐在 JSON Schema 的基础上扩展 UI、联动的协议。采用分层架构设计表单引擎,将表单核心领域、UI 渲染、及组件层各自独立,以实现各层之间的解耦。表单渲染可以使用组件逐级递归的方式,表单项之间的联动处理可以使用响应式机制,表单数据的校验可以使用 JSON Schema 加上自定义校验的逻辑。综上所述,一个兼具扩展性和灵活性的动态表单系统应该是基于以上思想和实现策略的。
如果您对相关问题感兴趣,欢迎在评论区留言进行讨论。