动态表单
动态表单并非指在前端运行过程中可依赖某些业务逻辑发生表单项变化的表单,而是指包括这种变化在内的表单布局、表单数据管理、表单校验、表单交互、表单项联动逻辑等原本由前端编程完成的表单开发,转由后端通过 API 接口输出表单描述自动完成上述所有内容的表单开发形式。而接口输出的具体格式并不一定,大多数情况下,为了便于理解和兼容后端技术栈,通常是以 JSON 的个数进行输出,但也可通过其他格式的文本或 DSL 输出。这由动态表单协议进行规定。
传统表单是一个表单写一份前端的代码,代码全部由前端开发者完成(后端配合接口输出)。而动态表单则是一个表单对应一个 JSON(由后端输出),所有表单由一份代码(动态表单引擎)进行加载和渲染。动态表单相较与传统表单开发模式由如下优势:
- 客户端运行的代码量更少
- 每个表单的 JSON 按需加载
- 表单需求变化时,无需前端修改发版,只需编辑数据库中的 JSON(需要在产品管理后台获得编辑能力)
如下劣势:
- 需要解析 JSON,性能相对而言更差
- 对引擎的依赖大:面对某些特定的业务场景,如果引擎提供的能力无法覆盖,则仍然无法使用,或者仍然需要写大量特殊逻辑代码
动态表单技术生态
基于上下游关系,要完善这一生态,会同时涉及:DSL、Schema协议、可视化编辑器、组件库等。
对于不同的产品而言,动态表单的开发模式并不一定相同。有的场景下,一个表单从创建到上线整个过程不需要任何代码介入,只需要运营人员在管理平台拖拽布局发布就能完成。而有的场景下,表单对于原有的前端应用而言,只是接入方式的变化,但是表单所承载的页面本身,和以前的开发方式没有任何变化,还是要写页面本身的代码。
虽然在上图中,有“前端开发者”的身影,但是在这个生态中,他们是以“用户”的角色在使用动态表单作为他们的开发方式。而另一方面,创造这以生态的开发者,又很大情况下是前端开发者,不同的角色身份,在这一体系中,由于有着相同的称谓,容易产生误解,发生僭越。本文是站在创造这一生态的开发者的角度进行讲述,让读者能从本文的描述中,知悉创造这一生态,作为前端开发者需要如何去设计 Schema,如何去撰写引擎,如何去设计可视化拖拽编辑器,以及如何去设计 DSL 编辑器等。
但由于我们作为业务的开发者,我们大部分的角色是“用户”,使用动态表单引擎完成我们的开发。
例如,我们现在需要在我们的业务系统中的某一个模块下的某个节点上使用动态表单技术,我们大概的做法是,先在项目中引入动态表单引擎的包,然后再该业务节点处调用引擎,以组件化的形式,把动态表单插入到需要展示表单的位置,并且通知后端同学,我前端已经处理好了,你那边按照 Schema 协议把 JSON 吐给我就好了。这样,我们的开发就完成了。
而这段 JSON 从哪里来呢?难道要后端同学手写好之后存入到数据库,需要时再读取出来返回给前端?显然不是,这也太笨效率太低了。做法是,运营管理员在系统后台(或有权限的管理界面)通过比较低成本的方式进行编辑后,得到该 JSON 并保存到数据库中。而要让运营管理者拥有该能力,开发者需要引入可视化编辑器的库,并结合一些接口调用逻辑,将编辑器放置在对应的位置。
看上去并不难,但是,这里有个问题是,运营管理者在编辑器中进行编辑时,所使用的组件是什么级别的?原子级别?业务组件级别?很显然,对于运营者而言,他们并不太关注技术层面的东西,他们更关注所使用的这些组件是否简单、高效、准确搭建表单。所以,开发者们很可能还需要花大量的时间,提前去总结系统中的组件共性,提炼出一个可以覆盖大部分表单搭建场景的组件库,以让这种编辑首先可以覆盖几乎所有场景,其次可以让运营者们不需要拥有技术知识而能准确使用组件。
你看,这一例子是一个相对而言比较复杂的场景,动态表单一旦进入这种开发模式下,就会让开发者们花更多的时间在处理如何运用动态表单实现效果,而非直接开发表单本身的工作。所以说,这有好也有坏,但是,总体而言还是向好的,因为从业务系统本身的目标来讲,运营管理者们更理解业务本身的特性,由他们来决定表单的逻辑比由前端开发者们完成表单逻辑更加准确。同时,这也免除了开发者们和产品经理的沟通成本。
对于一个通用的动态表单方案,组件库也可以使用通用的,例如 antd 等。这也就意味着,生态建设者可以在上述核心模块基础上进行再次封装,打包成一个固定的通用方案,直接丢给任意用户都可以使用,那么,这种场景下,开发者们要做的工作就更少了。
Schema 协议
Schema 文件被引擎解析后,完成界面渲染,以及内置逻辑的初始化,是一个关键角色。一旦 Schema 设计完成,引擎开发完毕,那么就很难重新设计 Schema,甚至大的结构调整都是不允许的。因此,Schema 的设计至关重要,甚至,它的设计将直接影响引擎的性能或者直接影响引擎的实现方式。
动态语法是 Schema 设计中的关键。普通的 JSON 是很难表达一些动态信息的。比如 A 字段是否必填,是由 B 字段的值是否大于 0 来决定的。这一逻辑,在动态表单领域被称为“联动”。联动是动态表单设计中,最为关键的内容之一。如果一套动态表单方案,不支持字段的联动,或者联动的设计非常蹩脚,那么这套方案给使用者的感觉也会大打折扣。
动态表单引擎
所有的这一切,其最核心的点(对于生态建设者们而言)在于实现一套动态表单引擎,因为实现了这套引擎,才能真正让这一方案落地。而生态的其他方面,实际上都是引擎的衍生物。Schema 作为协议,和引擎是强绑定关系,引擎只认该 Schema 的文件。
就像作为 JS 引擎的 V8 解释执行 JS 代码一样,动态表单引擎动态解释 Schema 文件内容,并创建表单上下文和完成渲染。虽然 Schema 不是编程语言,但它确实具备特殊的结构,以让表单引擎正确识别意图和正确渲染效果。但和编程语言的解释执行完全不同的是,由于我采用 JSON 作为 Schema 文件格式,因此,我们不需要像语言解释器一样,做词法分析获得 AST。或者说,Schema JSON 本身就是 AST 了。这也是我为什么推荐 JSON 的原因。
节点
布局,无论是表单,还是非表单,本质上是一样的。如何产生布局?组件思路是最优解。整个表单的布局,是由一个个组件嵌套成树状结构而成。每一个节点,描述了组件相关信息,再由引擎解释这些信息,完成渲染。
一个节点应该具备哪些信息呢?有 3 个必须的信息:使用哪一个组件,组织渲染依赖信息,子组件信息。对应到 react 中,就是 type, props, children。在这些必要信息之外,我们往往还需要一些其他附属信息,以实现一些引擎想要的能力。
动态语法
在 Schema 中设计动态语法?听上去有点怪。动态语法是目的,是为解决前端特定的场景,对于前端应用而言,特别是表单而言,几乎不存在没有变化的内容。用户的输入,数据的变化,都会带来界面或逻辑的变动,因此,这种可变的能力,需要通过某种形式来表达,这个时候就需要动态语法。
模型
在设计时,考虑到表单的复杂性,对表单运行时做了分层。其中,数据层由模型完成,表单模型是对表单涉及字段、字段与字段关系、字段衍生等的抽象描述。
为什么要有一层模型层呢?除了从架构层面让设计方案得到分层带来的好处外,最重要的一点在于,表单这个场景极为特殊,它本身就是围绕业务对象进行处理的一个场景,而处理的内容,就是为对象字段填值。虽然对于用户而言,就是填写一个个值,但对于系统而言,用户填值却由种种约束,并非随意填写,也不是可以全部填写,用户填写过程,要受到系统约束,而这些约束,基本都是由业务方对业务字段及其联系的规定决定的。只要体会到这一点,把表单字段本身的逻辑从视图中剖离开的想法就会自然而生。而建立模型,则是最直接有效的办法。
在表单的 Schema 中,很容易发现,一个字段的信息,可能在多处使用。这也是为什么那些没有提炼模型层的表单方案,很难处理字段之间逻辑联系带来视图联系的根源。在那些方案中,一个输入项就是一个字段,那么其他输入项如果依赖这个字段,就不得不通过额外的系统去规定这个依赖产生的影响,比如 A 依赖 B,那么在描述时,就不得不描述为“当 B 发生变化时,A 要做哪些事情”。但是,假如将模型和视图分离,描述就变成了,先用模型描述 A 和 B 字段各自的逻辑,其中描述 A 时,同时描述了它对 B 的依赖。这个依赖关系,在模型中描述完成了,那么回到视图中,所要描述的就是“这个输入项使用 A 字段,另一个输入项使用 B 字段”,这么简单。而对依赖的描述,则是基于前文所提到的“基于计算的联动”的原理。
有了模型的描述,视图的描述会瘦下来,且更加专注于布局和交互。
加载和渲染
当我们从后端接口读取 JSON 文件之后,引擎会按照 Schema 协议加载和渲染该 JSON 的内容。那么,这个加载和渲染是怎样的一个过程呢?
实际上,引擎的整个流程如上所示并不复杂。由于 Schema 协议是固定的,所以,加载 JSON 后,只需要去读取对应的字段,建立对应的内容。
整个解析流程,从遍历 Schema JSON 开始,先读取顶级的属性 model, layout。利用 model 生成模型,并将模型实例化,放在内存中,作为表单的全局模型使用。layout 是一个顶级组件描述,组件包含 type, props, children 等信息,当然,还有其他的一些信息,基于模型实例、组件描述,创建一个作用域,用以实现动态语法绑定。节点上使用动态语法的属性值,都通过该作用域完成值的读取。接下来,就是利用渲染器对节点进行渲染,以 react 为例,在渲染器实例化时,需要传入所有用到的组件 components,然后去读取 components[type],即读取 JSON 描述中 type 对应的真实组件(原生的 html 组件不需要 components 中定义),基于作用域解析完成 props 信息,由此,就可以渲染出一个 react 组件。children 的渲染亦是如此,将 children 渲染完之后,作为上一层组件的子组件完成渲染。
多技术栈
不同项目组技术栈不同,有的是 vue,有的是 react,有没有办法兼容不同技术栈呢?当然。我把渲染器做出可插拔的,渲染器的入参是一样的,但是结果不同,react 就用 react 的渲染器,vue 就用 vue 的渲染器。
组件库
由于引擎中渲染器的可插拔,那么同一套代码使用不同组件库的可能性也随之而来。例如在 react,你需要使用 antd 作为组件库。那么对于 type: Input,你只需要在渲染器实例化时,传入
可视化编辑
基于 Schema 生成表单解决了终端用户的表单使用问题,同时,由于 Schema 本身是文本,所以,生成 Schema 可以不用靠手动写了,而是可以通过编辑器来完成。就像写代码需要 IDE 一样,我们写 Schema 也需要一个可视化的编辑器。这里的可视化编辑器是指通过具体的视觉效果,展现表单可能的样貌,通过拖拽和填写的方式,对表单做形式设计。
可视化编辑的目的,是让运营管理员们,可以通过简单的视觉界面完成表单的编排。左侧是组件区,通过拖拽,将一个组件拖放到中间预览区,再到右边的配置区对该组件进行各种参数配置。但是,很明显,xrender 这种编辑能力无法适应大部分业务场景。这种近乎原子的组件设计,对于真正的运营者们而言,根本无法开展工作,因为使用者需要思考,这个配置的实际效果,以及自己脑海中的效果无法进行配置。更重要的是,原子化组件虽然有通用性,任意业务场景都可以去配置,但是,配置的工作量实在太大了,没有什么意义。我推荐的,是一种业务组件的配置,根据自己的业务场景,把所有业务组件提炼出来,在每一个表单的可视化编辑界面,运营者们只需要做少量的工作去修改对应业务的参数。