动态表单(3)—— 复杂表单 DSL 设计(下)

1,632 阅读12分钟

上一篇文章中,我们讲到了 DSL 整体设计框架和表单渲染部分 DSL 设计。这篇文章中,我们会处理一些复杂场景,比如表单校验、表单联动、表单输入限制等。这部分需要大家有一定函数式编程基础,可能对不了解函数式编程的同学来说会有点困难。不过没关系,我们会尽量详细地去讲解这部分内容。

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

表单校验

对一个表单来说,校验用户输入正确性是必不可少的一部分。在设计表单校验 DSL 之前,我们需要考虑几个重要问题:

  1. 表单校验时机是什么?

我们可以在 Input 变化之后立刻校验(onChange),在 Input 失焦之后校验(onBlur),或者在用户点击 submit 按钮之后校验(onSubmit)。

  1. 如何为一个组件添加多条校验规则?

比如为某个组件添加「不能为空」和「输入长度必须在 5-15 之间」两条校验规则,需思考如何将这两条规则应用到组件上,比如两条规则必须都满足才能通过验证,或两条规则中任意满足一条即可通过验证,以及如何显示错误提示信息等。

  1. 如何处理「有条件的」校验规则?

所谓「有条件的」校验规则,是指校验规则会根据其他组件输入值变化而变化。比如有一个金额输入组件,如果用户选择性别为「男」,校验规则为「输入最小金额为 1000」,如果用户选择性别为「女」,校验规则则变成「输入最小金额为 500」。

  1. 如何处理异步校验?

所谓异步校验,就是需要通过 API 去完成数据验证。比如用户注册时,通常会填写手机验证码,当用户提交表单后 API 会去验证其正确性,如果错误就会通过 API 响应告知前端,并在前端显示错误提示信息。

  1. 校验规则是否应该全部动态配置?

虽然我们可以通过配置将表单校验规则描述出来,但是否所有校验规则都应该通过配置来描述呢?

表单校验时机

通常来说,一个表单中所有组件校验时机都是一样的。因此,校验时机可以作为一项表单配置。后续可以通过它在不同时间点触发表单验证。

interface Form {
  formId: string
  widgets: Widget[]
  title?: string;
  description?: string;
  actions: {
    client: {
      onSuccess: RedirectAction | AlertAction,
      onFail?: {},
    },
    server: {
      onSuccess?: {}
      onFail?: {}
    },
  },
  validateMode: "change" | "blur" | "submit" // 表单校验时机
}

表单校验规则配置

在解决多条验证规则问题之前,让我们先来思考单条验证规则应该如何配置。试想在没有配置之前,我们如何通过代码来解决这个问题?当用户输入某个值后,我们可以从表单中获取到这个值,然后可以将这个值传入预设的验证方法,如果验证成功则返回空字符串(表示没有任何错误信息),如果验证失败则返回 errorMessage。最后,将 errorMssage 展示到页面上即可。

其实在设计 DSL 我们也可以参考这种方式,用「函数式思维」来设计。通过柯里化和组合来实现复杂表单验证规则。如下:

const rules = [
  {
    rule: [ "all", ["gte", 5], ["lte", 15] ],
    errorMsg: "输入长度必须在 5-15 之间",
    when: [] // 只有当某些条件满足时才会应用这条验证规则,需要时可以添加
  }
]

// 大于等于某个数,其中 value 为某个 field 值(用户输入)
const gte = (num: number) => (value) => value >= num
// 小于等于某个数
const lte = (num: number) => (value) => value <=num

在上面的例子中,我们为组件增加了输入长度限制。接下来,让我们仔细分析一下上面的结构。

rules

rules 被设计成数组,因为一个 field 可能对应多条校验规则。errorMsg 用于配置错误信息。rule 稍微复杂一点,用于描述验证规则。

rule 类型为 Operator,后面统称为「表达式」。表达式结构为:函数名 + 参数。比如 ["gte", 5] 表示有一个名字为 gte 的函数,它接收 5 作为参数。

如果一个方法需要接收多个参数怎么办?比如我们创建了一个函数 lge,可以实现大于等于某个数且小于等于另一个数。这时,我们只需将第二个参数放到后面即可: ["lge", 5, 15] 。无论如何,表达式中的第一项都是函数名称,第二项直到最后一项都是传递到这个函数中的参数。

这样设计有什么好处呢?

  1. **简单易用。**我们只需要预定义一些常用验证方法就能够基本满足需求。比如 lte(小于等于)、lt(小于)、eq(等于)、required(输入不能为空) 等等。
  2. **易于扩展。**如果有一些特殊需求无法通过已有验证方法实现,那么只需要额外增加一个验证方法即可。
  3. **易于组合。**所有表达式遵循同样结构,非常易于组合。通过将简单方法组合起来,可以完成一些比较复杂校验。

不夸张地说,表达式是整个表单「动态性」的基础。不仅可以应用在表单校验部分,还可以应用在其他地方,比如表单联动、表单限制等等。还记得我们在前面讲 DateInput 时提到的问题吗?如何限制开始时间不能早于今天,且不能晚于今天之后 15 天?有了表达式之后,这个问题就迎刃而解了,我们只需要新增两个表达式即可:

const config = {
  min: ["gteToday"],
  max: ["lteTodayAfterNDay", 15]
}

// 选择日期不早于今天
const gteToday = () => (value: Date) => value.getTime() >= new Date()
// 选择日期不晚于今天之后 X 天
const lteTodayAfterNDay = (num: number) => (value: Date) => ...

有条件的校验规则

关于「有条件的」校验规则,我们也可以通过表达式来完成。通过将 when 配置成表达式,让这条验证规则只在某些条件下才会生效。如下:

const config = {
  widgets: [
    {
      name: "gender",
      title: "Gender",
    },
    {
      name: "amount",
      title: "Amount",
      rules: [
        {
          rule: ["gte", 1000],
          errorMsg: "输入最小金额为 1000",
          when: ["eq", ["get", "gender"], "male"],
        },
        {
          rule: ["gte", 500],
          errorMsg: "输入最小金额为 500",
          when: ["eq", ["get", "gender"], "female"],
        }
      ]
    }
  ]
}

const eq = (a: string, b: string) => () => a === b;

我们通过 get 方法取到了另一个名字为 gender 的组件的值,然后将其作为入参传递给 eq 方法,eq 方法最终返回一个 boolean 值用作后续条件判断。

当有多条校验规则时,之前的设计都是必需全部满足才算校验通过,那么如何实现任意一条校验规则满足即可通过验证?还是利用表达式来完成,新增一个 any 方法:

{
  rule: ["any", ["gte", 10], ["lte", 0]],
  errorMsg: "输入数字必须大于 10 或者小于 0",
}

const any = (...args: any[]) => () => args.some((isValid) => !!isValid);

这部分只涉及 DSL 设计,如何解析表达式并应用到组件,会在之后章节进行讲解。

异步校验

异步校验会涉及 API 调用,当用户点击提交按钮之后,会发送 API,通过 API 响应我们可以知道哪个字段发生了错误以及它的错误信息是什么。我们的工作就是将错误信息显示出来。

一种比较偷懒的办法,就是将表单校验错误信息当成普通错误信息来处理,通过 Toast 弹窗将错误信息展示出来。这样我们不需要将错误信息和表单组件一一对应。

而另一个种做法,就是将错误信息展示到组件下面: image.png 这样我们就必须将错误信息设置回表单。前面我们已经讲过,在 form 上会有一些钩子方法 actions,因此通过定义 onFail 钩子方法,就能够描述失败之后的应对方案。同时,由于不是所有 API 错误都是校验失败错误,有时是因为调用其他服务失败,因此需要后端在返回 API 响应时加以区分。这样就能做到只有当 API 校验失败时,才会触发相应行为。

interface Form {
  actions: {
    client: {
      onFail?: {
        handler: "ShowError", // 定义验证失败后的处理方案
        errors: [ // 定义错误信息列表
          { username: "用户名必须为 8 位数" },
          { age: "年龄不能小于 20 岁" }
        ]
      },
    }
  }
}

其他场景

前面我们提到一个问题,是否所有校验规则都应该通过配置来描述?其实有些组件天生就带有校验规则,比如 Email 输入组件,自然需要校验用户输入是否为 Email 格式。如果通过配置来描述 Email 校验规则,我们势必会写一个非常复杂的正则,并且每个 Email 组件都需要配置它,十分繁琐。因此,这种情况下将 Email 校验规则和错误信息放到 Email 组件内部是一个比较好的选择。除此之外,如果 Email 组件还有其他校验规则,仍然可以放到配置中,只是需要将其和其他校验规则合并起来(合并顺序可以根据需求决定)。

另外,还有一个问得比较多的问题,是否所有校验规则都应该通过 rules 来配置?比如除了 rules 之外,我们是否可以在 widget 上添加 required、minLength、maxLength 等相对简单的属性来描述校验规则?我的建议是不要这么做。首先,将校验规则分散在多个地方,不仅给实现增加了复杂度,而且还让 DSL 变得更难以管理。其次,两个地方都可以配置校验规则,那它们的优先级怎么确定?如何定义错误信息?

归根结底,之所有有人想通过简单属性来描述验证规则,是因为手动配置 rules 相对比较复杂。可动态表单配置并不是我们手写的(也不应该手写),而是通过 UI 生成的,因此无需考虑配置简单还是复杂,保证配置集中更为重要。

表单联动

表单联动是指两个/多个组件之间相互关联,用户在某个表单组件中的输入,会决定另一个组件的展示与否,或着根据不同值展示不同组件。比如: image.png

我们只需要增加一个 visible 属性,用于控制组件显隐状态即可。而这个 visible 也通过表达式来实现,因为我们需要获取到其他组件输入值。让我们来看下面这个例子:

[
  {
    name: "gender"
  },
  {
    name: "hobby",
    visible: ["eq", ["get", "gender"], "female"]
  },
  {
    name: "job",
    visible: ["eq", ["get", "gender"], "male"]
  },
]

const eq = (a: string, b: string) => () => a === b;
const get = (name: string) => (value: any, formValue: any) => formValue?.name;

在这个例子中,我们有三个表单组件:gender 用于选择性别,hobby 用于输入爱好,job 用于输入职业。如果用户选择女性展示爱好输入框,选择男性则展示职业输入框。

为了实现表单联动功能,我们需要对表达式进行改造。不知道大家是否还记得,原来表达式返回函数中,我们只接收了一个参数: value(即当前组件的值),无法获取其他组件的值,也就无法实现表单联动。在开发过程中,我们通常会使用一些 form library,通过这些 library 我们能获取到整个表单的值。因此,我们可以将整个表单的值作为函数的第二个参数传入,方便获取表单中其他组件的值。

const get = (name: string) => (value: any, formValue: any) => formValue?.name;

为了保持结构统一,每个表达式返回函数都会接收 value 和 formValue 两个入参。

表单输入限制

表单输入限制也就是限制用户输入某些字符,比如 Number Input,用户无法在它里面输入非数字的字符。有些人搞不清楚表单输入限制和表单校验的区别,这里简单说一下,输入限制就是「禁止用户输入」,而表单校验是指「允许用户输入,但如果输入不合法会显示错误信息」。

有些输入限制是跟组件类型强相关的,比如 Number 组件、电话号码输入组件等,不建议放在 DSL 中。因为这种限制通常来说都会涉及到正则表达式,放在配置中很难维护。跟组件本身绑定到一起会是一个更好的选择。比如,在实现 Number 组件时,我们可以将「输入只能为数字」这一限制一并实现到组件中。

对于另一类输入限制,它们可能不为某种组件特有,比如字符长度限制 maxLength、minLength。这些配置可以考虑放到 DSL 中,根据需求动态调整。

数据转换 Parse/Normalize

数据转换一个最常见的例子就是金额输入,用户在输入框中输入 10000,为了更好地展示金额,通常我们在输入框中将金额用逗号分割,即显示 10,000。而后端数据需要 10000 这个数字,而不是以逗号分割的字符。因此,还要考虑数据在展示和存储时的转换问题。

跟上面类似,如果跟组件类型强相关的数据转换,还是放到组件中处理,否则放到 DSL。这个场景中,金额转换是跟 Currency 组件强相关的,因此不需要增加额外的 DSL 字段。

特定交互

还有一类跟组件类型强相关的交互:根据组件不同类型弹出不同键盘。比如电话号码输入组件弹出电话键盘, Number 输入组件弹出数字键盘,Email 组件弹出优化后的虚拟键盘,普通 Input 弹出标准输入键盘等。

对于这种特殊场景,虽然它们都是 Input 组件(只不过类型不同),但是将其拆分成多个不同组件会是一个更好的选择。我们可以将其拆分为 EmailInput、NumberInput、TelInput、BasicInput 等不同类型,设置不同的 inputmode 即可实现上面的功能。

可能有的人会问,我能不能只定义一个 Input 组件,然后通过 DSL 配置 type、inputMode 来完成上面的需求?我们不推荐这么做,因为从业务角度来讲,每一种组件都有自己的使命,不管是输入限制还是 inputMode 都是跟组件类型强相关的。

通过配置,你的确可以实现上面的需求,但是你也可以为 type 为 number 的组件设置电话键盘,这种灵活性恰恰是我们不期望的。因此,定义一个组件,将和这个组件类型强相关的东西都放到组件中,是我们设计动态表单的一个原则。

小结

在本篇文章中,我们介绍了着重介绍了表达式的结构和实现,并以此为基础讲解了表单校验、表单联动、表单输入限制和特定交互下的 DSL 设计。

在文章中,我们也分析了哪些配置应该放到 DSL 中,哪些配置应该放到组件中。总的来说,如果跟组件类型强相关的,比如输入限制、特定交互等都最好放到组件中,这样可以避免 DSL 无限膨胀,同时避免 DSL 变得过于复杂,让组件功能更加完善。而那些跟组件类型没有太大关系且非常动态的部分,则放到 DSL 中进行配置。

到这里我们的 DSL 设计基本上就完成了,已经能够覆盖大部分场景。在下篇文章中,我们互讲解如何通过 DSL 将表单渲染出来。