有赞云-可配置表单的实践

一、背景

前端开发过程中,往往会遇到很多的表单。简单表单尚可,但复杂表单让人尤为头疼。

比如有一个用来提交 请假单申请 的表单。

第一个表单项是 Radio,为性别:分别是 两种选项。

第二个表单项是个 Select,为请假原因:分别为 事假年假调休病假 这四种选项。另外,当上一个性别选项,选择为 后,则额外增加一个 产假 的选项。

第三个表单项是个 Uploader,为图片上传组件,用于上传医院证明:该项仅在请假原因选择为 病假产假 后显示,其余情况不显示。

这个场景已经略微有点复杂度了。很多时候如果不好好设计,将写出不好维护的代码。

那如果更加复杂的场景呢?

有赞云业务中,这种场景非常常见,于是推出了 zan-form 来解决这个问题。

二、配置式

对于上面提到的场景,先思考下,用普通方式怎么写。用普通的方式写一遍后,会发现有点难受。为什么呢?

因为 jsx ,本质上是现实中的物理模型,适合像积木一样,去拼凑出各种各样的 UI。

它一旦加上复杂的判断逻辑后,就会很杂乱,需要花大量精力去整理和修饰。

再想一下 配置式,第一直觉是什么?是逻辑和规则。

表单跟一般的 UI 不一样,尤其复杂的表单,它特别重逻辑,但是视觉复杂度上并不高,不会存在特别多层级的嵌套。所以用配置式来写复杂表单,也许是一个更好的方案。

下面是用配置式表单解决上面提到的场景:

[
  {
    _component: "FormRadioGroupField",
    _name: "sex",
    data: [
      {
        text: "男",
        value: "male"
      },
      {
        text: "女",
        value: "female"
      }
    ]
  },
  {
    _component: "FormSelectField",
    _name: "qingjiaType",
    data: [
      {
        text: "事假",
        value: "shijia"
      },
      {
        text: "年假",
        value: "nianjia"
      },
      {
        text: "调休",
        value: "tiaoxiu"
      },
      {
        text: "病假",
        value: "bingjia"
      },
      {
        text: "产假",
        value: "chanjia"
      }
    ]
  },
  {
    _component: "ImageUploader",
    _name: "hospitalMaterial",
    _show: values => ["bingjia", "chanjia"].includes(values.qingjiaType),
    tokenUrl: "http://somecdn.youzan.com"
  }
];

在上面的配置代码中,_component_name 就不多说了,解释下 _show 方法。

_show 目前仅支持传入一个同步函数,其中入参为 values

vlaues 等于 zentForm.getFormValues() 所取得的值。

_show 的出参为一个布尔值,当 true 时,表示该组件显示;当为 false 时,表示该组件不显示。

总体来说,就是把一份配置文件,做为一个数组进行遍历。当遇到 _show 方法时,执行之,并根据其 返回值,决定是实例化的这个组件,还是直接返回 null

三、禁用 children

配置式组件,是否应该支持 children,这是一个有争议性的问题。

支持后有利有弊,但最终还是决定禁止 children 的使用。

原因是,一旦支持实现 children 后,整个配置就偏视觉,而非偏逻辑了。

所以,以下方式是不被允许的:

// 下面的写法,会报错。因为不被允许使用 children
{
  _component: 'FormRadioGroupField',
  _name: 'sex',
  children: [
    {
      _component: 'Radio',
      value: 'male',
      text: '男'
    },
    {
      _component: 'Radio',
      value: 'female',
      text: '女'
    }
  ]
},

当然,为了方便使用,已经对 zent 自带的 FormCheckboxGroupFieldFormRadioGroupField 这两个组件进行了封装。封装后用法跟 FormSelectField 类似,仅需要传入 [{ text: 'text', value: 'value' }] 即可。如下所示:

// 封装后的 FormRadioGroupField 用法
{
  _component: "FormRadioGroupField",
  _name: "sex",
  data: [
    {
      text: "男",
      value: "male"
    },
    {
      text: "女",
      value: "female"
    }
  ]
},

所以对自己写的表单组件,如果需要用到 children 属性,会遇到一些问题,需要做一些改造。

当然,也有其他方式可以绕过,就是后面小节会提到的 _slot

四、注册自定义表单组件

到目前为止,能够直接在配置文件中使用的组件,都是 zent.Form 下的组件。

那么对于自定义组件怎么处理呢?

当前在 zan-form 内部维护了一个 componentLib 对象。

在该对象中,key 是组件名,value 是 Component。当解析配置文件的时候,会根据 _component 字段,去找到对应的组件,并实例化它。

所以我们只需要想办法,在 componentLib 中放入我们自定义的组件就可以了。

zan-form 提供了 zanForm.register('ComponentName', MyComponent) 方法,可以在 componentLib 中注册我们自己的组件。

注意,第三节也提到过,如果自定义组件需要用到 children属性,需要对该组件进行改造,因为当前的配置式是不支持 children 的。

为了能在 zentForm 中更好地使用,自定义组件需要暴露 value 属性,以及支持 onChange 方法做为回调,并用 zent.Form.Filed 包裹。

五、插槽

某些场景下,纯粹的配置式无法满足需求,则需要用 插槽 来实现扩展了。

插槽 的使用如下:

// form.config.js
[
  {
    _slot: "ImageUploader"
  },
  {
    _slot: "MyFooter"
  }
];

// Form.jsx
import zanForm from "zan-form";  
import formConfig from "./form.config.js";

const Slot = zanForm.Slot;

class Form extends Component {  
  render = () => {
    return zanForm(formConfig, this)(
      <React.Fragment>
        <Slot id="ImageUploader">
          <ImageUploader>点击上传图片</ImageUploader>
        </Slot>
        <Slot id="MyFooter">
          <Footer>我是页脚</Footer>
        </Slot>
      </React.Fragment>
    );
  };
}

如何使用 插槽,总结起来就是两个步骤:

首先,在配置中,选择合适的点,预留一个插槽。

然后,在配置外的 zanForm 中,使用 Slot 定义一个 待插入组件,并赋予唯一 id。

注意,_slot 可以跟 _show 一起使用,但是其他的,例如 _fetch_data_format 等,一律不支持。

再说下 _slot 的实现原理,其实也很简单:

zan-form 内部维护有一个 slotMap 对象,以 Slot.id 做为 keySlot.children 做为 value。当遍历配置文件时,遇到 _slot,则去 slotMap 里面取对应的 Slot.children ,并填充进插槽即可。

六、与服务端的交互

在日常开发中,存在着大量与服务端交互的场景。

比如 FormSelectField 中的 data,需要从服务端获取。

一般的方式是,在 componentDidMount 中获取数据,并且通过 setState 塞入到 FormSelectFielddata 中去。

然而在配置文件中,这似乎很难实现。那么在配置式中,怎么样获取远程数据呢?

目前可以通过 _fetch_data 方法来返回一个 Promise 的方式来实现,如下所示:

// "店铺类型"的Select
{
  _component: 'FormSelectField',
  _name: 'shopType',
  _fetch_data: () => {
    return getShopType().then(items => {
      return items.map(item => {
        return {
          text: item.desc,
          value: item.type,
        };
      });
    });
  },
  data: [],
},

以下两点值得注意:

1、原组件(如 FormSelectField 组件)必须支持 data 属性。因为获取到的数据,会默认塞入到 data 中去。

2、_fetch_data 只会触发一次。

实现方式:

这个特性,目前是通过在【原组件】外面,又另外包裹了一层 DecoratorCoponent 实现的。

DecoratorComponent 触发 componentDidMount 的时候,就去调用 _fetch_data,并将 data 通过 props 传给【原组件】。

也就解释了上面提到的,为什么 _fetch_data 只会触发一次。

七、重启组件

存在一种场景,如下:

第一行表单项,是一个 FormSelectField,代表省份。

第二行表单项,也是一个 FormSelectField,代表城市。但是该项会根据省份 id,动态从服务端获取城市数据。也就是说,上一项省份改变时,该项城市列表也会改变。

根据上述第六节提到的,如果只用 _fetch_data,那么根据省份 id 获取城市列表数据,只会触发一次,不会再触发第二次。

所以提出了一个 重启 组件的概念。

重启组件的内部实现,实际上分为两个步骤:

第一步,在 DecoratorCoponent 中改变【原组件】的 key 来重启原组件。

第二步,重新触发自身的 _fetch_data 函数,重新将 data 通过 props 传给【原组件】。

如下所示:

[
  {
    _component: "FormSelectField",
    _name: "province",
    required: "请选择您所在省份",
    data: [{ text: "浙江省", value: 30 }, { text: "广东省", value: 31 }]
  },
  {
    _component: "FormSelectField",
    _name: "city",
    _fetch_data: values => {
      if (!values.province) {
        return Promise.resolve([]);
      } else {
        return fetchCityByProvince(values.province);
      }
    },
    _subscribe: (prevValues, values, restart) => {
      if (values.province !== prevValues.province) {
        restart();
      }
    },
    required: "请选择您所在城市",
    data: []
  }
];

重启,需先 订阅。使用 _subscribe 可订阅。

_subscribe 方法中接受三个入参,分别是上一个状态的 values,本状态的 values,以及一个用来重启组件的 restart 方法。

比较两次状态的 values.province,如果不同,则触发重启。

至于 _subscribe,其实是借助 DecoratorCoponent 中的 componentDidUpdate 实现的。

八、格式化组件

有时候,需要对组件做一些样式上的修改,那么在配置式中,怎么做呢?

目前提供了一个 _format 方法来实现这一点。

如下所示:

[
  {
    _component: "FormInputField",
    _name: "age",
    _format: ($component, values) => (
      <div>
        {$component}
        <span>岁</span>
      </div>
    ),
    label: "年龄"
  }
];

format 方法中,\$component 是组件实例,values 同 _show 方法的 values。它们做为参数传入,最后返回一个改动后的组件实例。

_format 方法的内部实现,跟 _show 类似。

九、表单回填

一些场景下,比如编辑的时候:需要当从服务端取回数据,并在表单上回显。

这种场景该怎么做呢?

zan-form 提供了一个zanForm.setValues(values, this) 方法,即可把数据回填回去。

本质上,setValues 方法,是对 zentForm.setFieldsValue() 的一个递归调用,最终使 zentForm.getFormValues() 的值趋向于稳定。

十、有赞云招人

有赞云大量 hc 招聘前端,有兴趣的可以加微信 socialHunter 咨询!

欢迎关注我们的公众号