Please enable Javascript to view the contents

formily2.0 + sugar 实战总结

 ·  ☕ 11 分钟  ·  ✍️ 东 · 👀... 阅读

表单作为我们公司toB业务的页面是非常重要的一部分功能,随着用户增多,场景增多,表单的需求也越来越复杂且难以支持,比如在6月份的一次产品需求中,需要达到各个用户自己编辑表单中的联动的场景,目前我们的项目要做到这种针对用户级别的编辑配置还很有难度。

经过接近4个月的开发,我们终于实现了这个功能,用户可以自己在设置页面自己配置所需要的表单联动,针对任一字段的显示、隐藏、值下拉、赋值、不可编辑等制定自己需要的逻辑,不需要程序员逐个开发对应的联动效果。本文就对这次功能开发做一个详细的经验总结,后面的同学接触到这部分也可以少走些弯路。

我们为何要用formily2.0 + sugar

(备注: sugar 是我司自己的前端 React UI 组件库,类似于antd,未开源)

1、迫切需要表单状态管理工具,我们的sugar暂时没有

​ 因为历史原因,我们项目初始是用3.xx 版本的antd,后面随着公司发展和业务增长,有了自己的设计团队,研发了自己的前端 UI库,于是逐渐替换成自己的sugar组件,在表单组件状态这块一直没有足够的资源来开发,导致替换的同学一直在催表单何时能替换,实在要用新的组件,也只有自己组件内维护表单状态(值联动、校验等),开发量很大,对比antd中的Form.create 包装的组件后拥有的this.props.form 属性,自己维护组件状态还是要复杂得多;

2、需要以后支持更强大的功能,比如可以编辑联动

​ 公司迅速发展客户越来越多,所有的客户都用同一套表单越来越不可能,在同一个表单场景下,不同的客户有不同的需求,比如一个手机个人信息的场景,有的客户希望身份证是必填的,有的客户觉得可填可不填,这些零碎复杂、且随时可能会调整的逻辑,不可能在前端代码里实现,那程序员怎么都不够用,代码量也堪忧,需要有一个方案可以将联动作为设置的一部分,设置的结果可以应用在表单中,这个单靠antd 无法实现;

3、 fomily2.0 发布的时机刚好

​ 说来万幸,5月份调研formily的时候还是1.0版本,尝试着看看源码,但是因为文档不全还是非常晦涩,一期的方案没有注意到自己把formily/antd 引入了而且无法去除,因为他是这样引入SchemaMarkupField的:

1
2
3
4
5
6
7
8
9
import {
SchemaForm,
SchemaMarkupField as Field,
FormButtonGroup,
createFormActions,
FormEffectHooks,
Submit,
Reset
} from '@formily/antd'

​ 表单、表单项从@formily/antd 引入,也就是组件状态管理和antd 耦合,就在头疼之际,一个跟更紧急的需求来了,6月底做完回头看,formily2.0版本发布了,虽然还不是正式的,beta 版本的文档已经让人非常感动了!因为了有了更详细的文档,更加全面了解formily 这一条的设计思想之后,结合到项目里来就有了方向!运气也是有的!

二、 sugar 的前置改造

有了formily2.0,要达到支持用户编辑联动并且自动在页面生效的目的,需要有两步:

  1. 打开冰箱门
  2. 把大象放进去

跑偏了, 😂

  1. 将sugar中的表单组件调用connect方法,将你的组件无侵入接入 Formily,也可以通过提供的mapPropsmapReadPretty映射器来对组件内的属性和formily 中的状态做一个映射。比如你的组件中有一个是否支持编辑属性叫isEditable,formily中的状态可以没有这个属性,但是他的状态中有一个editable属性,你就可以通过这个参数调用来配置这两个属性的映射。没有connect过的组件,formily如何托管你的组件状态呢?

  2. 梳理代码中的表单逻辑,将旧代码通过formily 中的形式重构,当然,我们这次的项目中就是将antd 的代码梳理后替换成传入schema的形式:

    1
    
    <SchemaField schema={schemaData}></SchemaField>
    

在第一部分的改造工程中,主要是在我们的sugar项目中引入formily,我们用到的主要是@formily/core @formily/react 这两个库,因为业务中有很多的表单编辑态和阅读态的功能,所以这部分工作主要做的是组件connect以及组件阅读态的编写,在这个过程中遇到的问题在这里挑几个印象深刻的:

1 upload 组件

​ 因为早期写组件的时候没有想到会有connect formily的这一天,当时的组件 onChange 方法的参数是这样的onChange(file, fileList),formily 会通过劫持你组件的 onChange方法的第一个参数来获得此项表单的值存储在状态中,也就是file,但我们在提交表单的时候,上传项这一个肯定是要上传所有上传的文件fileList,已经发布的组件肯定不能改这个onChange(file, fileList),发愁的我就去翻@formily/and 的代码找灵感了,于是最后通过将传给组件的onChange方法包装一层来达到了这个目的😁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function useUploadProps({ ...props }: UploadProps) {
  const onChange = (file: UploadFile, fileList: UploadFile[]) => {
    // 为了绑定formily/core 这里需要使onChange的第一个参数是fileList
    // https://github.com/alibaba/formily/discussions/1938
    props.onChange?.(fileList, file);
  };
  return {
    ...props,
    onChange,
  };
}

export const FormUpload = connect(
  (props: UploadProps) => {
    let param: UploadProps = useUploadProps(props);
    return <Upload {...param} />;
  },
  mapProps({
    value: 'fileList',
  }),
  mapReadPretty(PreviewText.Upload)
);

经过这个问题,我也意识到了一个组件输入输出幂等性的含义,一个组件应该是输入什么类型的就吐出什么类型的数据,这个问题一直到后面都在坑我😕,也同时在小组中跟正在写组件的小伙伴强调了这个原则的重要性,每个表单组件必须有valueonChange入参,而且必须保证onChange的第一个参数是组件代表的表单状态,以后直接提交的时候拿来用。

2 Spacing 组件在schema中报错的问题

​ 为了支持表单的简单布局,我想的是用我们sugar中的spacing 组件,但是spacing 组件是通过在每个子元素后面追加分隔符来达到分割的目的,而SchemaField 渲染元素的时候会包一层Fragment,所以最终放弃了这个方法,改用gap + flex 补全布局能力 ,也就是在SchemaField组件外包裹div给到flex布局,组件项传入组件宽度属性来达到简单布局的要求,只能退而求有一点布局功能了。。。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
       // schemaData
       ···
       username: {
          type: 'string',
          title: '用户名',
          required: true,
          'x-decorator': 'FormItem',
          'x-decorator-props': {
            size: 'md',
            style: {
              width: '45%',
            }
          },
          'x-component': 'FormInput',
        },

        ...

<FormProvider form={form}>
   <div style={{display: 'flex', flexWrap: 'wrap', gap: '8px'}}>
     <SchemaField schema={schemaData}>
     </SchemaField>
   </div>
</FormProvider>

三 、在业务中重构原来的表单

​ 好了,前置工作已经做好了,接下来就是如何重构的问题了👊

​ 在需要重构的这个表单场景中,所有的表单项内容都是后端接口通过一个fieldList来动态渲染的,包括下拉选项的options,表单项左侧的label ,然后前后端约定好字段类型,根据这个类型,前端来渲染出对应的组件,比如约定type 为1 对应的就是字符串类型,对应的组件就是Input,type为2 对应的就是日期类型,对应的组件就是DatePicker,各个类型下,组件不同,传入组件的参数亦不相同,因为一些业务逻辑,组件本身或许也是根据antd组件再次封装一层的逻辑,层层封装导致代码如今已经比较臃肿且难以维护了。

​ 那么如今怎么用formily来做呢?因为有编辑联动的部分,最终用户编辑的联动逻辑需要转化成Schema中的x-reaction ,为了代码尽量简洁,用了formily之后的前端开发应该是把注意力放在业务上,所以决定根据原来的业务逻辑生成对应的Schema,传入到SchemaField中就可,原来庞大的层层嵌套的代码如今可以缩成这一段:

1
2
3
 <FormProvider form={form}>
    <SchemaField schema={schemaData}></SchemaField>
  </FormProvider>

​ 如今我们的工作重心就转移到了如何生成这个Schema 了,让formily帮你解决表单状态的管理,你只需要关注初始值,自定义的校验参数,以及formily吐出来的即将传给接口的数据就可以,不用自己去写一个个组件的onChange函数,不用自己写很多form.setFieldsValue之类的antd 状态管理操作了。当然这块的业务逻辑也并不简单,在将代码逻辑梳理出来,写好Schema 生成函数之后我发现我的代码量也不少😅

​ 最大的区别是此时我的代码中主要都是根据业务逻辑生成schema的初始值(包括联动字段),不会有很多form实例的操作,在这个过程中也有一些比较印象深刻的坑,在这里简单说两个:

1 复合业务组件的问题

在表单中正常的结构都是一个表单字段对应一个选项值: 比如这个form的value结构:

1
2
3
4
5
{
  "input1": "张三",
   "select": 3,
   "date": '2021-10-26',
}
    但是在我们的业务中,有一些字段对应的是多个值,比如电话号码组件由区号(select)和电话号码(input)两个表单组件组成,地址组件由城市(cascader)和 详细地址(input)两个表单组件组成,等等这种情况:

image-20211026211149555

在提交表单值的时候他们作为一个整体表单项来提交:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "address": [
    {
      "code": "130000",
      "name": "河北省"
    },
    {
      "code": "130300",
      "name": "秦皇岛市"
    },
    {
      "code": "130304",
      "name": "北戴河区"
    },
    "这里是详细地址"
  ],
  "tel": {
    "country_code": "+86",
    "value": "18266666666"
  },
}

​ 遇到这种情况,就需要将这些复合附件封装成一个整体的表单组件传入,需要有状态对应的value和onChange 入参,**注意!这里一定要写好注释关于你的状态是什么格式的!组件吐出的值格式也要是相同的!牢记输入输出幂等!**不然别人瞎传初始值进来就会出bug😭 类似于上面的第二步UI组件的前置改造,也要调用connect方法,将你的组件接入 Formily,由此才能将你的组件状态托管到Formily中,这个情况中,遇到的问题也会要求定制化一些,比如这里电话组件中的必填校验,就需要只填区号不填电话号码的时候认为这一项是没有填的,地址组件就是只要填了任意一个就认为通过必填校验,这一步可以在mapProps 的时候加上一些业务逻辑,比如电话组件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { connect, mapProps } from '@formily/react';
import FormTelephone from '@/components/form/FormTelephone/FormTelephone'; // 自定义电话组件

export const FormilyFormTelephone = connect(
  FormTelephone,
  mapProps(
    {
      dataSource: 'options',
    },
    (props, field) => {
      // 必填的时候没有填电话有校验提示
      if (field?.value) {
        if (!field.value?.value && field.required) {
          field.selfErrors = ['请输入'];
        } else {
          field.selfErrors = [];
        }
      }
      return {
        ...props,
      };
    }
  )
);

export default FormilyFormTelephone;

​ 灵活的自定义组件可以解决更多的业务组件问题,比如我们有一个自定义渲染select选项的组件,其中的options数量巨大,是不可能一次性将所有的option项都拿来,主要还是引导用户自主搜索,这种情况直接用UI库中的select组件就无法达到要求,就还是自己封装了组件,定义好组件内部的状态,onChange 函数并在组件状态变化的时候调用,onChange第一个参数是组件状态,组件内部select的onChange 中还会去调用接口查询下拉选项,达到这个目的。大概是这样一个结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function ({value, onchange, ...}) {
 const innnerChange = () => {
   onchange(value)

 }

 const onSearch = (searchText) => {
   // 调用接口查询
 }

 return (
   <Select onChange={innnerChange} onSearch={onSearch} />
 )
}

2 各种字段类型兼容的问题

​ 首先我们需要想清楚,凡事有利有弊,我们在赞叹formily高效管理表单状态的同时,也要明白我们从此也是”戴着手铐脚镣跳舞“了,表单场景复杂性其中一个就是体现在数据格式因为业务逻辑而十分复杂,而formily中注入的组件,我们强调很多遍”输入输出幂等“,所以在表单组件的输入值,输出值格式不同,就会比较棘手。

​ 最典型的是我们此次为了减少后端工作量,表单上传接口期望不需要做各种改动,那么在表单上传数据的时候,formily吐出的value不符合接口预期就需要我们手动去拼接,比如下拉单选的value是:

1
data1: 1

但接口统一要求的是:

1
value : [{id: 1, name: '选项1'}]

​ 这就要求我们再数据上传之前有一道前端处理工序: 根据id 和 options拿到复合规范的值;

​ 有的options是 {id,name1} 有的是 {id,name2} 有的是{label,value},这些都需要一些手法去兼容,当然,最终理想应该是 除了特殊业务组件,其他一概直接将form.value上传即可,因为后端主要有唯一的表单项fieldName,和value 就可以达到存储表单值的效果。

​ 因为一些历史原因,这里的表单选中的默认值也必须和组件入参一致,比如id 有的传来了字符串,有的传来了number,这里都是在自测期间发现的一些数据兼容问题;

​ 又比如,自定义组件规定默认值的形式是 [{id: 1, name: ‘选项1’}],如果你传入1作为默认值,提交form时就会不生效,因为这个1就会成为该表单项对应的formily值,这里有个小插曲,我尝试给组件兼容能力,也就是说,你传入1我在组件内部去options里找到这一项[{ value: 1, label: ‘123’ }]赋予formily的值,然后这样就引起了死循环😢, 我是这样尝试的:

1
2
3
4
5
useEffect(() => {
    let currentValue = getValueFromDefault(); // 组件内部去options里找到这一项赋予formily的值
    setValue(currentValue); // 组件内部状态 这里我定义的是[{ value: 5, label: '123' }]格式
    // onChange(currentValue);  不可行
  }, [selectorValue, getValueFromDefault, options]);

​ 想来应该是因为onChange 的时候formily劫持这个函数给formily状态赋值,然后赋值后更新页面重新渲染,然后就又走到useEffect 了,就死循环了。。。 所以这里我加了说明,formily引用此组件的时候不可以传入规定的[{ value: 5, label: ‘123’ }]格式其他形式的初始值,如果只是自己用,直接拿组件状态还是可以兼容的,直接拿到组件内部状态currentValue就可以了。

总之,一定要搞清楚每个表单项,后端需要的是什么值,组件内部状态是什么值。

四 尾声

​ 此次的项目周期较长,因为一些场景的限制,或许有些问题还没有被发现,引入formily 和重构一部分只是漫长的第一步,未来或许还有更多的问题等待解决,比如组件的预览态就只用到了一个组件的,自定义复合组件的预览态因为不需要也没有开发,后面的路还很漫长,期待formily为业务的表单开发增光添彩!

分享

目录