一份配置轻松搞定表单渲染,配置式表单渲染器在袋鼠云的实现思路与实践

前段时间,袋鼠云离线开发产品接到改造数据同步表单的需求。

一方面,数据同步模块的代码可读性和可维护性较差,导致在数据同步模块开发新功能和定位问题的效率很低。另一方面,整体规划上,希望在对接新的数据源时,可以不再关心表单渲染相关问题,从数据源中心新建数据源一直到数据源在数据同步模块的应用,全链路的表单都可以通过配置化的方式解决。

本文就将以此为例,抛砖引玉,为大家详细介绍配置式表单渲染器实现的实践之路。

数据同步表单背景

数据同步模块整体上分为四个部分,数据来源表单、同步目标表单、字段映射组件和通道控制表单。

一份配置轻松搞定表单渲染,配置式表单渲染器在袋鼠云的实现思路与实践_第1张图片

其中前三个部分对应的代码非常混乱,代码量也很大,单个组件代码 5000+ 行,这里着重说一下数据来源表单和同步目标表单。

数据来源和同步目标表单的主要功能是收集数据源对应的配置信息,并且根据数据源类型的不同,对应需要渲染的表单项也不同。

目前袋鼠云离线开发产品 BatchWorks 数据同步功能的数据源多达50+种。在长时间的迭代过程中,日积月累出现了很多强行复用的代码,这些强行复用的代码内部又包含着大量的 if else 逻辑。另外,数据同步模块的表单内部有很多联动关系,比如:

· 某个表单项的值变化时,需要发起接口请求,请求的返回值被用作另一个表单项下拉框的数据

· 某个表单项的值变化时,需要去清空/重置其他一些表单项的值

· 某个表单项的值变化时,需要显示/隐藏某个表单项

· 某个表单项的值变化时,某个表单项的 label 文案、表单项组件(比如从 select 变成 input ) 等随之发生变化

这些表单项的联动处理逻辑在代码中混杂交叉,另外还要加上表单回显的特殊逻辑处理,表单的值收集到 redux 的特殊逻辑处理等。

需求分析

基于上述需求背景,表单渲染器的核心功能是输入一份配置,输出表单 UI 组件。

一份配置轻松搞定表单渲染,配置式表单渲染器在袋鼠云的实现思路与实践_第2张图片

基于上述数据同步表单背景,我们希望渲染器可以尽可能吸收掉表单内部的复杂度,也就是说在表单的配置中要能够描述上述的联动关系,那么可以大概得出表单的配置需要描述:

· 表单项的基础信息,比如字段名、label、表单组件、校验信息等

· 表单项数据之间的联动

· 表单项 UI 的联动(控制显示/隐藏)

· 表单项的值变化时需要触发的副作用(比如调用接口)

表单基础信息描述

这里配置格式使用 JSON 格式,用一个数组描述所有的表单项信息,UI 上表单项的渲染顺序即配置数组中表单项配置的顺序,表单组件使用 Ant Design Form。

对于表单项基础信息的描述配置,大多可以直接搬用 Ant Design Form Item 的 props,比如 label、rules、Tooltip 等属性,这里不多赘述。

比较特殊的是,需要在配置里描述表单项描述的 UI 组件,比如 Select、Input,那么这里使用 widget 字段去描述。另外,组件的描述除了组件名称,还需要描述组件的 props, 所以还需要一个 widgetProps 字段去描述组件的属性,比如 placeholder、disabled 等。

那么一个用于选择数据源的表单项应该这样描述:

{
  "fieldName": "sourceId",
  "label": "数据源",
  "rules": [
    {
      "required": true,
      "message": "请选择数据源!",
    },
  ],
  "widget": "Select",
  "widgetProps": {
    "placeholder": "请选择数据源",
    "options": [
      {
        "lable": "数据源1",
        "value": 1
      }
    ]
  },
}

当然可能会存在某些表单项的 UI 组件有自定义的情况,比如可编辑表格,代码编辑器等。这个时候就需要开发自定义表单组件了,然后把这些组件注入到 FormRenderer 中,伪代码如下所示:

import { Editor, EditableTable } from './customWigets'

export const getWidets = (widgetsName) => {
    switch(widgetsName) {
      case 'Editor': {
        return Editor
      }
      case 'EditableTable': {
        return EditableTable
      }
    }
} 

那么目前的结构如图所示:

一份配置轻松搞定表单渲染,配置式表单渲染器在袋鼠云的实现思路与实践_第3张图片

这份配置写到这里的时候,问题出现了:

· 无法在配置中描述 onChange、onSelect 等事件回调函数

· 相比于 jsx 强大的表达能力,JSON 中只能表达基本的数据结构,而没办法直接表达逻辑

· Select 下拉框的数据可能来源于接口,这种情况在业务中相当常见,这里也没办法表达

· 不能自定义表单校验器,无法支持复杂的 Tootip 提示,比如带有 a 标签的 Tootip

上述问题产生的根本原因,实际上是 JSON 与 jsx 之间表达能力的差距。但是从另一个角度来讲,正因为 JSON 的表达能力和灵活性不如 jsx,所以在用来描述 UI 时,JSON 更不容易导致混乱。

我们先思考如何表达 Select 下拉框的数据来源于接口,这里可以拆解为两个部分,数据获取和取得接口的返回值并在配置项中表达。

数据获取

实际上,Select 下拉框中的数据也并不一定来源于接口,也可能是来源于其他业务数据,所以在配置项描述数据获取时,不应该关心数据的来源。

很显然,数据获取逻辑需要用 js 描述 ,这里我们抽象出一个 Service 的概念,用于描述/声明数据获取逻辑,Service 的声明使用 js,在 JSON 配置中,只需要去描述 Service 的调用逻辑即可。对于 JSON 配置来说, Service 调用需要三个要素:

· Service 的标识/名称,表示哪一个 Service 被触发

· Service 的触发时机

· Service 返回的数据如何存储

● Service 的触发时机

Service 的触发一般来说是由于用户的交互引起的,当然也存在在表单项组件挂载时就需要触发的情况,那么调用时机大概就是以下几种:

· onMount

· onChange

· onSearch

· onFocus

· onBlur

● Service 返回的数据如何存储

这里 Service 返回的数据存储需要能被 UI 获取到,那么需要将返回的数据都维护在 FormRender 内部,这里将存储数据的地方命名为 extraData。那么我们描述 Service 返回的数据的存储,可以使用一个 fieldInExtraData 的字段,描述当前 Service 返回的数据被存储在 extraData 的那个字段中,取值时:extraData[fieldInExtraData]。

那么在表单项配置中描述 Service,如下所示:

{
  "serviceName": "getSourceList",
  "triggers": ["onMount", "onSearch"],
  "fieldInExtraData": "schemaList"
}

● Service 的声明

对于 Service 本身来说,要做的事情就是获取并处理数据然后返回,当然 Service 本身可能需要接受一些参数,比如当前 Form 收集到的数据、Service 是被哪个字段触发的、触发时机是什么等等,那么 Service 的格式如下所示:

const getSourceList = ({ formData, extraData, trigger, triggerFieldName }) => {
  return Promise((resolve) => {
    resolve(...)
  })
}

由于 Service 可能是异步的,所以这里 Service 都返回一个 Promise,然后将所有的 Service 都注入到 FormRenderer 中,FormRenderer 根据表单项配置中声明的调用时机去调用 Service,整个数据获取的链路就完成了。

获取 Service 返回值并在配置项中表达

上文中提到,Service 的返回的数据都被存储在 FormRenderer 内部的 extraData 中,一般情况下如果使用 jsx 当然能很容易地取到对应的值,但是在 JSON 中,是没办法表达的。但是我们可以借鉴 jsx 的插值表达式和 vue 的插值表达式。

{user.name}

在 jsx 中,如果在一对标签内部写了一串字符串,对应的会有两种解析策略,第一种是直接识别为字符串,第二种如果识别到花括号,则将其视为 js 表达式。 同理,在 JSON 配置中也可以使用这种方式去取值。

{
  "fieldName": "sourceId",
  "label": "数据源",
  "widget": "Select",
  "widgetProps": {
    "placeholder": "请选择数据源",
    "options": "{{ extraData.sourceList }}"
  },
  "triggerServices": [
    {
      "serviceName": "getSourceList",
      "triggers": ["onMount", "onSearch"],
      "fieldInExtraData": "sourceList"
    }
  ]
}

● 函数表达式

上例中,使用一对花括号声明函数表达式,表面上是借鉴了 jsx 的插值表达式,但是其实两者有很大的区别。jsx 的插值表达式是在编译阶段就转化成了 js 表达式。而在 JSON 中的这种自定义的函数表达式要在运行时转换,上述的函数表达式只能被转换为函数执行。即:

"{{ extraData.schemaList  }}"
// 转化为
const valueGetter = new Function('extraData', 'return extraData.schemaList')

出于安全问题考虑,表达式还需要被放在一个类似沙箱的环境中执行,避免表达式内部修改全局环境变量。创建简易沙箱使用 proxy + with + symbol.unscopables 的方式,这里不展开讲解了。最终函数表达式的应用大概是如下形式:

function Comp () {
  return