可视化搭建工具技术探索之表单

avatar
@滴滴出行

作者:橙子

前言

说到页面可视化搭建,想必很多同学都有所了解,业内已有非常多文章介绍,具体可以查看底部传送门,本文仅从 如何搭建一个易用、可扩展的通用可视化搭建工具 出发,探索技术思路,以及在实际实践中思考,欢迎互相探讨。

关于相关工具,业内开源及商业化产品非常多,但是通用的、满足定制化业务的却很难找到,原因有很多:

  • 用户不同,相同功能的组件运营同学与研发同学诉求不同,运营希望简单,研发希望二次开发能力
  • 场景不同,配运营活动与配流程表单,使用的组件几乎完全不同。
  • 设计器要求不同,不同系统对设计器界面要求不同,面板能力也不同。
  • 开发者偏好不同,有人偏向react开发,有人偏向vue,使用组件库也不尽相同。
  • ...

由于以上某些原因,导致一些开源工具不能很好的在实际业务落地,很多时候就只能自己开发,或基于开源二次改造。

为什么要尝试做一个通用的可视化搭建工具呢?

可视化配置工具作为一种提效工具,如果只是为了满足自身业务就搞一套,从更大范围看,是提效了还是减效了?即使一个团队、一个部门可以做到通用,整个公司却不一定,就会遇到常被DISS的“重复造轮子”,解释起来基本就是有自身定制的需求,别的工具不能满足。因此也很难形成统一的可视化配置组件及规范。

虽然有很多定制化场景及偏好问题,但从技术层面来看,有很多相似的地方:

1、需要设计器,可添加、拖拽、配置组件
2、提供渲染能力
3、组件间可通信
4、表单场景可联动等

假设设计器能定制,不同组件库实现的可视化组件可在不同设计器中运行,通过底层一套schema或DSL规范约束。这样就能很大程度解决组件共享问题,从而大幅减少重复开发成本。实现一个设计器及定制组件并不难,难的是如何达成这样的规范,同时支持扩展。组件和设计器只是上层实现而已。

借用一句毛爷爷的话:

道路是曲折的,前途是光明的

下面具体来看自己在尝试实现过程的一些思考

一、划分

1、按场景分

以下为比较典型的业务可视化配置场景:

场景用户特点用途
运营活动运营同学数量多,定制化强、需快速上线一般配置运营活动、落地页、抽奖等
流程表单流程实施对表单能力要求高,表单内外联动、公式计算等配合流程设计,实现业务流转
业务报表产品、运营等以查询表单+可定制列的表格以及图表配置为代表对流程及业务结果展示
个性化页面普通用户配置应更简单,交互要求高个性化诉求。如用户主页、定制工作台等
中后台页面前端研发需要有代码扩展能力、专业性强前端提效。解决繁琐、重复开发

当然以上也只是可视化配置的几种典型场景,如果配置化能力足够强,或许基于此,解决前端大部分开发工作也不是不可能。

2、按用户专业性分

从设计器开发到最终使用,涉及不同角色的用户:

  1. 设计器开发者:保证设计器的独立与业务解耦,关注底层能力、设计器通用性、灵活性
  2. 组件开发者:通用组件、业务组件开发。关注组件用处、业务定制性等
  3. 配置人员:添加、拖拽配置、发布等。关注配置难度、灵活性、组件是否丰富。
  4. 最终用户:使用最终发布的页面。关注使用体验,打开是否快、功能是否正常等

从配置难度来看,可视化工具通常有以下几种:

  • NoCode:顾名思义,完全不需要编码能力,比如运营活动配置、用户个性化主页、流程表单、业务报表等。通常需要基于特定场景定制化组件
  • LowCode:大部分界面和功能可通过可视化方式配置,但是完整功能还需要借助少量代码完成。如定制的表单关联、表单的提交逻辑等。
  • ProCode:需具备专业前端代码能力,对应传统研发。特点是交互周期长,研发成本高

可以看下【可视化搭建工具与页面】

工具与页面实现关系

可以发现,LowCodeNoCodeProCode都能实现最终页面(蓝色)。ProCode能力是最强的,可以实现全部场景功能,同时还能实现LowCodeNoCode平台或工具本身

3、按典型交互分

  • 表单交互:涉及表单校验、联动、提交、值回显等
  • 展示页:较少或无需用户输入,以展示为主,部分个性化配置。如业务报表、用户个性化主页、运营活动等。

以上按不同方式对可视化配置工具进行了分类,不一定非常准确,但基本都有所覆盖。从技术出发,结合特点和诉求,如何实现这样一个通用工具是一个值得探索的问题。

二、问题探索

以下暂且列了部分表单问题、自定义诉求、通用能力三个方面典型问题

  • 表单问题
  1. 表单校验,表单规则定义探索及如何自定义规则?
  2. 表单联动,表单内字段如何联动?表单内值变更或者触发事件,如何联动表单外?表单外事件如何联动表单内组件?
  • 自定义诉求
  1. 自定义组件,如何自定义一个普通组件?如何自定义容器组件?组件基础配置不满足时重新开发还是扩展配置?
  2. 自定义设计器,当设计器嵌入业务系统时,设计器应具备怎样的开放能力以实现低成本、无缝衔接?
  • 通用能力
  1. 国际化,设计器国际化、翻译预料管理
  2. 自定义样式
  3. PC端与H5同时配置

以上只是工具形式提供能力时可能遇见的几个典型问题,当然问题远远不止这些,升级到平台会涉及更多的问题。篇幅有限以下主要探索 表单场景 的典型问题,其他问题留给后续探索。

1、典型组件应该具备哪些部分

轮播图 组件为例,不同专业程度用户希望配置的属性不同。

  • 对于 NoCode 用户而言,可能只需要配置如下属性:轮播图个数、拖动添加图片、配置图片跳转链接、输入轮播时间间隔、选择轮播切换动画等
  • 对于 LowCode 用户而言,除了以上配置以外,还可以配置图片上传接口、请求方式、上传请求参数、接口返回转换脚本等,这样在更大程度上复用,同时使用难度也增加了。

虽然最终展示结果相同,但是配置却不同,这种情况下是否可以做成一个组件呢?个人觉得是可以的,比如把更多属性配置放在高级里,或者让组件之间可以继承等。

那么一个组件应该具备哪些部分呢?

从示例可以发现,首先需要有最终展示部分,其次需要有配置部分,还需要定义配置项。这里将最终展示的部分称View,配置部分称Setting,定义配置称为Schema。其关系大致如下:

组件内部关系

SettingView通过Schema关联起来,Schema实例化后为json数据可保存到服务端。Setting表单修改SchemaSchema变化影响View变化。

2、表单场景典型问题

1)、表单验证

表单规则定义探索及如何自定义规则?

从主流组件库来看,不考虑联动校验规则情况下,输入框比下拉框、单选、时间等组件的规则要复杂些。后者只需要做选择,一般增加是否必填规则即可,而前者除了必填,还有对字符做特别校验。通常 用户可输入的组件比提供选项选择的组件在规则上要复杂些

表单组件一般都至少有一条规则,如 必填,当然也有例外,比如 开关组件(switch),不管是true 还是 false 必填对其来说都没有意义

因此可在组件schema上可以定义required字段表示时候必填,如:

{
  "name": "firstName",
  "required": true,
  "errorMessage": "这是必填项"
}

对于只需要必填规则的组件来说,这样定义似乎并没有什么问题。然而很多时候一个组件往往有多个规则同时生效,如:希望该字段必填,能配置对应错误信息,同时还要求字符串长度有限制,对应过长或过短都能给相应的错误提示。用以上定义就不太好满足了,于是可以升级一下:

{
  "name": "firstName",
  "rules": [
    { "required": true, "message": "这是必填项" },
    { "min": 3, "message": "最小长度不能小于3" },
    { "max": 10, "message": "最大长度不能超过10" }
  ]
}

这样看起来清晰了很多,同时支持多条规则组合。这也是主流UI组件库都在用的表单校验 async-validatorrules字段应与 async-validator 在使用上保持一致,这样就可以利用第三方库做规则校验了,

因为表单基本都有一条必填规则,可以约定rules字段第一个规则为必填,其余规则根据实际情况由配置人员动态添加。

注意schema.rules中的每条规则字段类型与async-validator并非一一对应,原因是我们的schema将以json的形式保存到服务端或本地,所以一些特殊字段如自定义校验函数或正则等,就必须转成相应字符串了。

  • async-validator 字段规则描述:
{
  "type": "string",
  "validator": (rule, value) => value === 'test',
  "message": "请输入 test"
}
  • schema.rules中单条规则描述
{
  "type": "string",
  "validator": "(rule, value) => value === 'test'",
  "message": "请输入 test"
}

因此,设计器底层需要对表单规则提供解析模块(Rule)。这个只是实现规则层面,对配置层面的话,让配置人员写这些代码实在有些勉强,而提供可视化的方式选择或简单填写就很有必要,如下图:

自定义扩展规则

常用规则可以内置到设计器底层。实际业务中,往往会有自定义的复杂规则,或者异步校验等,那么:

如何能配置规则的同时,还能根据不同业务场景扩展规则呢?

这里就要求设计器对表单规则有扩展能力。一种可能是在配置的时候,直接通过脚本实现规则,仅适用于前端开发。第二种是组件开发同学,提前开发好规则,然后创建设计器时扩展规则,最后在配置规则时选择即可。这里讨论第二种实现。

  • 实现手机号规则示例
// ./PhoneRule.js
export default class PhoneRule {
  static get type () {
    return 'phone'
  }
  static get name () {
    return '手机号'  // 用于可视化显示
  }
  constructor (rule = {}) {
    const defaultRule = {
      type: 'pattern',
      pattern: '',
      message: '手机号不正确'
    }
    this.origin = Object.assign({}, defaultRule, rule)
    this.rule = {
      type: 'pattern',
      trigger: 'blur',
      pattern: /^1[3-9]\d{9}$/g,
      message: ''
    }
    this.update(this.origin)
  }

  update (rule) {
    if (rule) {
      this.rule.message = rule.message
      Object.assign(this.origin, rule)
    }
  }
}

  • 应用规则及传入规则示例
import { Rule } from 'epage-core'
import PhoneRule from './PhoneRule.js'

Rule.set({ PhoneRule })
// 应用规则:PhoneRule的type静态属性对应phone
helper.setValidators(widgets, { input: ['phone'] })
// 传入规则
new Epage({
  Rule,
  // ...
})
  • 最终配置input组件时,可以看见增加了 手机号 规则

自定义扩展规则

2)、表单联动

这里先给一个个人理解的联动定义

表单联动一般是指 一个或多个表单字段值或属性 发生 变化,使其他 一个或多个表单字段值或属性 变化的交互。

这里有几个关键点:一个或多个表单字段值或属性变化

i、联动示例

表单联动

比如可以为以下任意联动关系:

  • 一对一:
No.影响字段关系被影响字段属性
1城市属于中国-->学校可选学校
  • 一对多:
No.影响字段关系被影响字段属性
1城市属于中国-->学校可选学校
专业可选专业
  • 多对一:
No.影响字段关系被影响字段属性
1城市属于中国-->学校可选学校
2在校人数大于1万

12之间可能是 也可能是 的关系

  • 多对多:
No.影响字段关系被影响字段属性
1城市属于中国-->学校可选学校
2在校人数大于1万专业可选专业

12之间可能是 也可能是 的关系

注意:

  • 多个影响字段之间可能是也可能是关系
  • 影响字段可以等于属于等多关系与值建立条件
  • 被影响字段之间一般不存在的关系
  • 多级关联,如 a字段影响b字段,b字段影响c字段等,可通过多个两级关联配置

以上是基于影响字段角度考虑关联。当然也可以从被影响字段的角度考虑关联,在一些时候更直观,如:

{
  "widget": "input",
  "name": "c",
  "hidden": "$a.hidden === false && $b.hiden === true"
}

以上schema描述会有以下不好的地方:

1、会让hidden本来为boolean类型,却变成了字符串表达式。
2、如果hidden本来就是字符串类型的字段,又怎么区分是具体值还是表达式呢?当然也可以在扩展字段
3、不同字段属性逻辑比较分散,不方便统一管理

以上示例联动中,影响字段通过改变自身的表单值来触发联动逻辑。这里的值可以是等于关系,也可以是包含小于等关系,取决于值类型。如:

  • 布尔可以是等于不等于
  • 字符串可以是等于不等于包含不包含
  • 数字可以是等于不等于大于小于大于等于小于不等于

由于不同表单组件值类型可能不同,所以可以作为静态属性定义到组件的Schema上,如:

class InputSchema extends FormSchema {}

Object.assign(InputSchema, {
  logic: {
    value: ['=', '!=', '<>', '><'] // [等于, 不等于, 包含, 不包含]
  }
})

如果把 表单字段 当做一个对象,表单值(value)当做一个特殊属性,还有一些普通属性,如显隐(hidden)、禁用(disabled)等,就会发现联动就是属性与属性之间逻辑绑定。如何做到监听value变化以及普通属性变化呢?

value之所以认为是特殊属性主要原因:

该属性的变化会触发onchange事件。对应hiddendisabled等普通属性却没有,理论上也应该有onhiddenondisabled相应事件。

如果把 表单字段 所有属性定义成响应式,任意属性变化时就能很方便通知到。也可以自己实现订阅发布方式,来修改表单属性。

表单联动示意2

  • 一种是表单字段属性符合某种条件后,联动其他表单字段属性变化,这里称值联动
  • 另一种是表单组件发生了某个事件(如onchange),联动其他表单字段属性变化,这里称事件联动

从一定程度讲二者方式都能解决部分相同功能的联动,如A组件value值发生变化,也可以认为是A组件发生onchange事件

ii、联动实现

以下以开发 epage 部分实现为例分析(暂未实现多对一、多对多关联逻辑)

首先,逻辑定义

定义schema上应该保存的逻辑结构。具体逻辑定在单个组件的Schema上还是最外层Schema都可以,这里定义到统一的地方,方便管理。

主要定义 影响组件被影响组件:包括联动类型、影响表单组件值符合某种条件、被影响表单组件哪些属性联动、影响表单触发的什么事件等

{
  // schema 其他字段
  logics: [
    {
      "key": "kB1mKTnek", // 影响组件key
      "type": "value",    // 关联类型,值联动 或 事件联动
      "action": "=",      // 值联动是相等关系,这里定义不同符号,应该提供符号解析能力
      "value": "show",    // 具体值
      "effects": [        // 被影响组件列表
        {
          "key": "kASJAJwRB", // 被影响组件key
          "properties": [
            { "key": "hidden", "value": true },   // 被影响组件隐藏
            { "key": "disabled", "value": true }  // 被影响组件禁用,还应可以为其他属性
          ]
        }
      ]
    }
  ]
}

其次,逻辑解析

  • 逻辑管理

基于以上分析,应具备值逻辑事件逻辑。在渲染或预览时执行生效

import EventLogic from './EventLogic'
import ValueLogic from './ValueLogic'

class Logic {
  // 检查值逻辑配置是否合法,是否有重复逻辑等
  // 返回 { patches, scripts },对应比较结果和可能的自定义脚本
  diffValueLogics(){}
  // 同上
  diffEventLogics(){}
  // 根据以上比较结果,最终修改组件Schema属性
  applyPatches(){}
  // 检查被影响组件是否有效等
  checkEffect(){}
}
  • 值逻辑部分实现示例

对以上生成的 逻辑关系 进行解析。如值联动中 action字段就有很多比较关系(=(等于)、!=(不等于)、>(大于)、<(小于)、<>(包含)等),以=为例:

class ValueLogic{
  constructor () {
    this.map = {
      '=': {
        key: '=',
        value: '等于',
        // left、right为用户输入值都为字符串,valueType为应该的数据类型
        // 但左右值类型与valueType不一致时,根据情况进行转换后比较
        validator: (left, right, { valueType }) => {
          const booleanMap = { true: true, false: false }
          let leftValue = left
          let rightValue = right

          if (valueType === 'number') {
            leftValue = parseFloat(left)
            rightValue = parseFloat(right)

            return (isNaN(leftValue) || isNaN(leftValue)) ? false : leftValue === rightValue
          } else if (valueType === 'boolean') {
            if (right in booleanMap) {
              rightValue = booleanMap[right]
            }
          }
          return leftValue === rightValue
        }
      },
      // ...
    }
  }
}

为了让设计器更具有通用性,逻辑关系定义及解析也应支持组件开发者扩展。

具体值逻辑或事件逻辑的一些实现可以参考 epage#Logic

三、总结

做一个可视化配置工具并不难,但是既要保证通用,又能保证扩展性,同时统一标准一起共建却不容易。需要建立一套统一SchemaDSL,不同开发者能认同,可根据需要扩展定制,进而达到快速实现业务交付目标。

传送门

关于作者团队

滴滴效能平台前端团队EFE,感召于通过技术持续提升组织效能的组织使命,致力于打造技术领先的前端技术团队,深耕于性能监控、质量监控、低代码配置、文档协作、微前端、webIDE等多个领域,技术方向广阔,探索空间和成长空间极大。

我们是一个充满激情和有梦想的团队,期待您的加入。感兴趣的可联系 dumingtan@didiglobal.com