再也不想写表单了

1

产品:小哥哥在么?我想临时加个小小小小的需求~~

小明:不在。

产品:很简单的哈,就在原来的页面上加个小功能……

产品:帮帮忙,求你了哥哥,我请你吃星爸爸~~

小明:先说吧。。要是改动大的话,请海底捞都没用。。

产品:不会的!就是在我们的活动后台页面上,想加个活动预告的功能,大概这个样子……

需求1

产品:是不是很简单哈~

小明:哦。还行吧


于是小明打开了项目,找到了相应的页面,Oh shit!

以前的代码谁写的,写完就跑路了么!还好现在加的「活动预告」功能跟其他部分不相干,可以单独抽离出去。小明感觉机智如我,很快就写好了,简直是空手薅羊毛。

小明:加好了 🌝 (来杯抹茶拿铁)

2

产品:小哥哥在么?昨天加的小需求后来业务说需要有个「立即生效」的按钮呢 😂

产品:实在对对对对对不起 >_< 我在原来的图上稍微加了点,求哥哥帮忙改一下下~~~

需求2

产品:是这样的,「立即生效」选「是」时,选日期的就不要了,选「否」时才有「生效日期」

……

产品:哥 😳 你看到了吗?

小明:你说完了吗?全都说完我再一起改

产品:说完了!谢谢 O(∩_∩)O


由于小明先见之明,一开始就把「活动预告」部分抽离出去了,所以根本不用 care 其他代码,也很快就改好了。

3

产品:小哥哥……我先给您跪下了 😹😹

产品:又跟业务聊了,他们自己一开始都没理清,现在捋清楚了

需求3

产品:1. 活动类型为「拉新」与「冲单」时,活动还支持「按条件」的生效方式,条件支持「活动人数」「活动天数」「峰值」 (三者为且的关系)

产品:2. 活动类型为「回馈」时,活动只支持「立即生效」和「按时间生效」两种方式

产品:不会再改了!!不然我就请吃海底捞!

小明:再见。


1. 日常

在日常工作中,经常出现上面故事中的影子,公司的业务不会停下,需求必然要跟着业务而不断演化,一个看似简单的页面表单也会变得越来越臃肿。

项目交接了N手,各人都按各自的风格写代码;产品也交接了好几手,各按各的套路提需求。根本原因是代码的维护成本与业务不断变化之间的矛盾。

正如上面故事中的例子,再往后发展,有可能「活跃人数」的条件需要支持多个区间,甚至出现「生效条件」与表单中的其他项发生耦合,需求千变万化,怎么预知得了。

究其原因,就是当表单中出现联动的需求,或者跨行之间发生制约关系时,表单代码的复杂度就会上升。随着业务需求的演变,如果代码处理的不好,会变得越来越难维护。


2. 配置型表单

表单本质上是什么?表单用来承载业务需求的交互逻辑,表单的最终目的是提交一些特定格式的数据。

那么从其目的出发,从数据的角度,表单就是一堆 key 与 value 的映射,key 就是接口调用时的参数,value 就是 key 所对应的表单控件的用户输入值。

理想状态下,可以用如下的 JSON 结构来定义一个表单。

JSON 数组中的每项都对应表单中的一项,type 即表单控件的类型,key 即表单提交时的数据参数。如上图,表单提交时会发送以下数据

  {
    act_type: '',
    instant: '',
    range: []
  }

一个 key 到底会输出什么类型的数据,取决于它对应的表单控件是什么。

2.1 应对联动需求

上面说到表单中的联动是罪恶的根源,在 JSON 配置中能否处理好这个关系呢?我们先翻下业务项目中的代码,是否经常出现这样随意的代码。

表单业务代码片段

以 Vue 为例,v-if 条件渲染确实是处理联动的办法,但满屏的 v-if 以及枚举值的 magic number,动不动就几百行的表单,模板中又耦合着逻辑,维护起来真是个又脏又累的活儿。

根据实际需求,整理以下常见联动的套路。

  • 第1类:A 为特定值时,B 不显示;或 A 为特定值时,B 才显示。
  • 第2类:A 为特定值时,B 只能为特定范围内的值 (或不能为某些值)。
  • 第3类:A 为特定值时,B 也只能为特定值。

借鉴 v-if 的思想,在配置型表单的 JSON 中也可以尝试条件渲染。

  [
    {
      title: '活动类型',
      key: 'act_type',
      type: 'radio'
    },
    {
      title: '立即生效',
      key: 'instant',
      type: 'radio'
    },
    {
      title: '生效日期',
      key: 'range',
      type: 'dates',
      ifRender(form) {
        return form.instant == false;
      }
    }
  ]

配置中的 ifRender 是一个函数,入参即当前表单的数据状态,可以在函数中判断渲染的条件,以达到与 v-if 等价的效果。通过这种方式,就可以实现第1类联动。

2.2 应对动态取值范围

在上面的 JSON 配置中,其实省略了一点,就是单选控件的选项,它属于控件内部的属性,因此可以统一定义一个 props 字段来表示组件内部的属性。下面完整定义了产品最初需求中的「活动类型」这一行。

  [
    {
      title: '活动类型',
      key: 'act_type',
      type: 'radio',
      props: {
        options: { 1: '拉新', 2: '冲单', 3: '回馈' }
      }
    },
  ]

有了这个基础后,对于第2类联动,A 为特定值时,B 只能为特定范围内的值 (或不能为某些值),就迎刃而解了。很容易想到,让 props 支持函数,入参同样是当前表单的数据状态。

对于产品需求3中,类型为「回馈」时,活动只支持立即生效和按时间生效两种方式,就可以通过以下 JSON 配置来定义清楚。

  [
    {
      title: '活动类型',
      key: 'act_type',
      type: 'radio',
      props: {
        options: { 1: '拉新', 2: '冲单', 3: '回馈' }
    },
    {
      title: '生效方式',
      key: 'effect_type',
      type: 'radio',
      props(form) {
        const map = { 1: '立即', 2: '按时间', 3: '按条件' };
        if (form.act_type === 3) {
          delete map[3];
        }
        return { options: map };
      }
    }
  ]

当然你可能会嫌弃 magic number,那就定义个枚举常量好了。总的来说这样定义的表单联动逻辑,集中又清晰。

2.3 应对限制特定值

细心的你可以会注意到,第3类联动还没法实现,A 为特定值时,B 也只能为特定值。

这种情况,我们先来看看原先业务代码中会怎么写。一般两种套路:

  1. watch 表单状态中的 A 字段,当它为特定值时,将 B 字段也赋成特定值。
  2. 监听 A 字段所对应表单控件的 change 事件,当它 change 成特定值时,将 B 字段也赋成特定值。

可以想象,这两种代码给 B 赋值时与 B 的声明处都隔的比较远,而且每个人都有自己的习惯,有的人喜欢写在模板里,有的人喜欢写在逻辑里,有的人可能喜欢单独抽个函数。

在上面的配置型表单中,同样可以做这个事情,可以通过给 props 增加个 value 字段用来特指该表单控件的数据状态。

  [
    {
      title: '活动类型',
      key: 'act_type',
      type: 'radio',
      props: {
        options: { 1: '拉新', 2: '冲单', 3: '回馈' }
      }
    },
    {
      title: '生效方式',
      key: 'effect_type',
      type: 'radio',
      props(form) {
        const value;
        const map = { 1: '立即', 2: '按时间', 3: '按条件' };
        if (form.act_type === 3) {
          value = 1;
        }
        return { 
          value: value,
          options: map 
        };
      }
    }
  ]

当然,除了通过 props 来限制特定值外,也可以设计个 watch 观察者回调函数来对表单数据状态中的某个字段来赋值,取决于这套 JSON 表单配置怎么设计。

2.4 小结

通过 JSON 配置来定义一个表单是可行的,相较于以往的业务代码,省去了模板,不会出现模板与逻辑混在一起的情况。对于表单中令人讨厌的联动需求,列举了3类常见的联动套路,并且尝试了通过 JSON 配置来满足联动需求的可行性。

3. 实现原理

既然说配置型表单省去了模板,模板其实被屏蔽了,配置中的每个表单控件的 titletype 足以表达模板了。title 即表单一行中的 label,而 type 则映射到具体的组件,是 checkbox 还是 select。

3.1 使用姿势

上面啰嗦了这么多配置型表单的 JSON 示例,我们希望封装一个表单,只需传入 JSON 配置。以 Vue 为例,希望可以这样指定表单。

  <json-form v-model="formModel" :config="formItems"/>

其中 formItems 就是上文介绍的 JSON 配置,是一个数组,数组中的每项代表一个表单控件。

3.2 映射到组件

从表单配置中的 type 映射到一个真正的组件,是这套表单方案的核心。

为了降低基础组件的开发成本,我们可以先选择一套成熟的组件库,比如 Element,通过简单的 type 与组件 tag 的映射,就能实现组件映射。

  export default {
    // 默认输入类型
    text: {
      // 对应到 Element 中的组件 tag
      component: 'el-input',
      // 传递给 Element 组件的默认 props
      props: {
        clearable: true
      }
    },
    // 省略...
  }

如上,我们定义了一个映射表,key 即表单配置中的 type,例如 type: 'text' 则会映射到 Element 中的 el-input 组件,并且这里给出的 props 会作为默认属性传递给 Element 中的相应组件。这样,就以一种低成本的方式实现了从表单配置 type 到真正的表单组件之间的映射。

3.3 Form 封装

只有上面的映射表,还不够组成完整的表单,因为一个表单会包含多个组件,并且在上文介绍的几类联动也需要在表单层来实现。

好在 Vue 中有个神奇的动态组件,可以很方便的帮助我们生成映射后的组件。

  <el-form :model="formModel">
    <el-form-item
      v-for="(input, i) in formItems"
      v-if="input._ifRender"
      :key="input.key + '_' + i"
      :label="input.title"
    >
      <component
        :is="input.tag"
        v-model="form[input.key]"
        v-bind="input.props || {}"
      />
    </el-form-item>
  </el-form>

如上代码所示,formInputs 是从表单接收到的 JSON config 转化而来,它会根据组件映射表得到了相应的组件 tag 和 props,并且处理条件渲染的配置 ifRender 函数。

  this.formInputs = (this.config || []).map(item => computeFormItem(item, this.formModel))

可以看到核心就是这个 computeFormItem 函数,它将表单配置进行转换,并且以当前的表单数据 model 作为入参。

  function computeFormItem(config, form) {
    // 返回结构体
    const item = { ...config };
  
    // 表单控件的类型
    let type = item.type || 'text';
  
    // 对应到组件映射表
    let def = ElementMapping[type];
  
    item.tag = def.component;
    item.props = Object.assign({}, def.props, item.props);
  
    // 获取动态 props
    if (isFunction(item.getProps)) {
      Object.assign(item.props, item.getProps(form));
    }
  
    // 条件渲染
    item._ifRender = isFunction(item.ifRender) ? !!item.ifRender(form) : true;
  
    // 防止表单提交时存在多余 key
    if (!item._ifRender) {
      delete form[item.key];
    }
  
    // form-item 配置
    return item;
  };

它首先根据配置类型,读取组件映射表,得到真正的组件 tag,然后合并配置中的 props、映射表中组件的默认 props,以及依赖表单 model 的动态 props (用于实现上文中第2类联动)。最后处理条件渲染,执行配置中的 ifRender 函数,得到一个布尔值,作为表单模板中 v-if 的判断。


4. 告别表单

上文说配置型表单省去了模板,因为模板已经被标准化,并且被封装在了 Form 中,即下面的 <json-form>

  <json-form v-model="formModel" :config="formItems"/>

对使用者来说只需要写好 JSON 配置就可以了。由此带来的好处显而易见,那就是表单需求中的业务逻辑只需要关注数据层面,即某个表单控件对应的数据 key 是什么,以及它对应的组件类型。

并且当处理表单联动需求时,只需在配置中写上一些函数,这样逻辑更集中,因为你不需要在原先的页面模板以及代码逻辑中寻找有哪些耦合了。

更远地来说,配置型表单可以轻松应对底层的变化。比如现在 Element 组件库需要升级大版本了,必然有不兼容的坑,你就不必在所有页面中翻来覆去地寻找到底哪些属性要改。只需要搞定封装后的 Form,在不兼容的 props 之间做一层映射,业务代码部分是无需改动的。

读到这里,是不是以为会有广告?是为了安利某个轮子呢 😇

总结

然而并没有……

本文从实际的需求出发,讨论了配置型表单的可行性,并整理了常见的表单联动场景。通过配置型的表单可以降低业务代码的开发和维护成本。

已经在公司的后台业务系统中尝试了数十个页面,还有很多配置项及实现细节没有列出,比如扩展自定义组件、渲染自定义模板、表单生命周期以及表单 API 的设计等。

结合真实的业务场景,使用配置来组织表单甚至组织整个页面,是一条路子。它降低了代码的复杂度,却无形中提高了理解配置的复杂度。因为业务场景千变万化,众口难调,你必须在其中抽离出相似的东西,将其设计成配置。配置项既要考虑通用,又不能限太死,必要时要能提供可自定义和扩展的方式。

配置的设计是最难的,也最容易纠结,真正去实现这么一套表单配置也是挺有意思的呢!


注:头图来自 unsplash.com

发布于 2018-11-08

文章被以下专栏收录

    只看代码的话,上 https://github.com/ElemeFe 。这一群人,关心的不是「如何写前端」而是「如何很好地运行一个 ( web ) APP」;这一群人,会在监控屏上加上弹幕,会让实习生自主招聘,会设计、编写、监控整个 APP 的生命周期;这一群人,玩的时候... 更卖力,就像从来没来过那般卖力,卖力地热爱生活。所以这些创作大多基于 ❤️