Formily取经

最近的工作涉及到了很多表单,也在不知不觉中意识到了自己对表单的一无所知。

今天,就来聊聊Formily

Fomily 为什么诞生

Formily 只是众多表单处理方式里的一种。

我们必须要先说说表单本身。

先说说表单本身

最初我对表单的理解可能是这个样子的

但是实际的 Form 表单场景却是

这样的

这样的

等等…

作为一个 ToB 的前端开发,从我的角度去看,Form 表单在 B 端的使用是相当灵活且广泛的。

上图给出的场景,仅仅是表单形式的不同。

Form 场景的复杂还体现在

等等…

一些 Formily 的使用场景

通过一个多月对 Formily 从无到有的接触,再加上从组内其他熟悉 Formily 的小伙伴里取来的经。

我总结了几种在使用 Fomily 上相对让我觉得棘手的,并且有趣的场景。

异步获取接口数据

场景描述:

表单中有很多的下拉选项,但是页面(或者说 Form)并不是一开始就拥有对应 Select 组件的下拉选项数据。

因为下拉选项的数据很可能有很多,而且 B 端的 Form 表单项数量也非常多,如果一开始就获取所有下拉选项数据,将是一件大工程(并非指所有情况)

那么如何异步获取对应下拉项的选项数据,成了我要面临 Formily 的第一个问题。

实现

其实异步获取的接口数据,是 Select 组件的一个 props

所以我们把问题抽象一下,其实是如何通过 Formily 来动态在 Form 初始化改变某一个 FormItem 的 props的问题。

const schema = {
  type: "object",
  properties: {
    linkage: {
      type: "string",
      title: "联动选择框",
      enum: [
        { label: "发请求1", value: 1 },
        { label: "发请求2", value: 2 },
      ],
      "x-decorator": "FormItem",
      "x-component": "Select",
      "x-component-props": {
        style: {
          width: 120,
        },
      },
    },
    select: {
      type: "string",
      title: "异步选择框",
      "x-decorator": "FormItem",
      "x-component": "Select",
      "x-component-props": {
        style: {
          width: 120,
        },
      },
      "x-reactions": [""], // <----- look at here
    },
  },
};

其实需要用到 Formily 的 schema 的一个配置属性x-reaction

x-reaction可以理解为一个 trigger,就是在特定情况下会触发的 reaction 行为。

// 完成formily异步数据获取。也可以处理一些联动异步获取数据的操作
export const useAsyncOptions =
  (
    service: (
      field: FormilyField,
      searchValue?: string
    ) => Promise<OptionItem<SingleSelectType>[]>
  ) =>
  (field: FormilyField) => {
    const fieldName = field.props.name as SingleSelectType;

    if (field.dataSource?.length) {
      return;
    }

    if (SINGLE_SELECT[fieldName]) {
      service(field).then(
        action.bound?.((selectOptions) => {
          const tempProps = {
            options: selectOptions,
            labelKey: DEFAULT_LABEL_VALUE_KEY[fieldName].labelKey,
            valueKey: DEFAULT_LABEL_VALUE_KEY[fieldName].valueKey,
          };

          // 移动端需要添加drawer header
          if (isMobile()) {
            Object.assign(tempProps, {
              headerProps: { title: field.title || "请选择", canClose: true },
            });
          }
          field.setComponentProps(tempProps);
        })
      );
    }
  };

useAsyncOptions接收的参数其实就是异步请求接口的 function。上方代码中的loadData可以对应多个 function。


<SchemaField
  schema={schema}
  scope={{
    useAsyncOptions,
    duty_level_options,
    corporation_options,
    office_address_options,
    duty_options,
    position_options,
  }}
/>

这个场景相对简单一些,其实就是需要给SchemaFieldscope属性进行一些配置,将一些函数放入 Form 的 scope 中,这样 Form 就能拿到对应的方法,在相对应的时机去触发。

后端搜索

后端搜索所所对应的场景是FormItem可能会有很多数据的情况(多发生在 Select 等组件),需要通过输入关键字来进行搜索。

但是有时候并非是前端的搜索,需要通过给后端接口传入搜索关键字来与后端形成交互。

来抽象一下这个问题,和刚才的异步获取数据有些类似,通过 Formily 来实时改变某一个 FormItem 的 props


import { action, observable } from "@formily/reactive";

export const useAsyncOptions =
  (
    service: (
      field: FormilyField,
      searchValue?: string
    ) => Promise<OptionItem<SingleSelectType>[]>
  ) =>
  (field: FormilyField) => {
    const fieldName = field.props.name as SingleSelectType;

    // 获取当前field对应的Form表单对象
    const form: Form = field.form;

    // 通过生成一个ref来记录搜索的关键词
    const keyword = observable.ref("");

    // 给form添加副作用处理(也可以在createForm的时候来进行)
    form.addEffects(field.address, () => {
      // Field初始化的时候要做的事情
      onFieldInit(field.address, () => {
        // 职位需要支持搜索
        if (fieldName === SINGLE_SELECT_TYPE.JOB_POSITIONS) {
          field.setComponentProps({
            searchable: true,
            onSearch: debounce((value: string) => {
              keyword.value = value;
            }, 200),
          });
        }
      });

      // 当Field一些内容发生了改变的时候(这里是关键词)
      onFieldReact(field.address, () => {
        service(field, keyword.value).then(
          action.bound?.((selectOptions) => {
            field.setComponentProps({ options: selectOptions });
          })
        );
      });
    });
  };


我们还需要用到刚才的x-reaction配置和与其对应的useAsyncOptions

刚才在异步获取下拉选项数据时,是去调用接口。现在的步骤则是:

而上述代码中useAsyncOptions的参数 service 依然是调取后端接口的 function。我们通过给 form 添加 effect,然后来监听 field 的改变来触发onFieldReact

而关键词的改变则交给组件本身提供的回调函数即可,我们仅需在onFieldInit中配置好相应的 props 即可。

复合字段

复合字段是在 form 中是一个FormItem,但是实际上却对应了多个子Item的情况。

比如

图中,现居住地址在 Form 中仅代表一个字段,value也对应的一个值,比如currentAddress: { ... }

但是显然currentAddress需要多个子字段的值来组成最终的 value 。这个时候就需要子表单。

const currentAddress = {
  address: [
    {
      code: '120000',
      name: '天津市',
    },
    {
      code: '120000',
      name: '天津市',
    },
    {
      code: '120101',
      name: '和平区',
    },
  ],
  detail: '华南里28号楼-908',
}

在本篇文章中,仅给出一种解决方案,就是子表单

子表单其实就可以单独把这个字段理解成一个表单。而这个表单暴露出去的最终value就是外部表单所对应字段的value。


const FormAddressDetail: React.FC<FormAddressDetailProps> = (props) => {
  const { addressProps, detailProps, detailRequired } = props;
  const field = useField<ArrayField>();

  // 创建FormAddressDetail内部特有的子表单
  const form = useMemo(
    () =>
      createForm({
        effects() {
          onFormValuesChange(() => {
            const { address = [], details = '' } = form.values;
            const newAddress = address.map((item: ExposeValueItem) => {
              return {
                code: item[KEY_MAP.VALUE],
                name: item[KEY_MAP.LABEL],
              };
            });
            field.value = [...newAddress, details];
          });
        },
      }),
    [field]
  );

  // 需要在外部表单校验的同时校验内部表单
  useEffect(() => {
    field.form.addEffects('subFormValidate', () => {
      onFieldValidateStart(field.address, async () => {
        try {
          await form.validate();
        } catch (error) {
          // 自身是一个子表单,所以不需要父表单的item再显示报错样式
          field.setFeedback({
            type: 'error',
            code: 'ValidateError',
            triggerType: 'onInput',
            messages: [''],
          });
        }
      });
    });
  }, [field, form]);

  // 子表单对应的schema
  const schema = useMemo(() => {
    return {
      type: 'object',
      properties: {
        address: {
          required: field.required,
          default: field.value?.filter((item) => typeof item === 'object'),
          'x-decorator': 'FormItem',
          'x-decorator-props': {
            safeArea: 0,
            style: {
              paddingTop: 0,
              paddingBottom: 8,
            },
          },
          'x-component': 'FormAddressSelect',
          'x-component-props': { ...addressProps },
        },
        details: {
          type: 'string',
          required: detailRequired,
          default: field.value?.find((item) => typeof item === 'string'),
          'x-decorator': 'FormItem',
          'x-decorator-props': {
            safeArea: 0,
            style: {
              paddingTop: 0,
            },
          },
          'x-component': 'FormTextarea',
          'x-component-props': { ...detailProps },
        },
      },
    };
  }, [addressProps, detailProps, field.required, detailRequired, field.value]);

  return (
    <FormProvider form={form}>
      <SchemaField schema={schema} />
    </FormProvider>
  );
};

其实这个操作相对好理解。因为子表单其实就是一个普通的表单。

我们遇到的问题,是如何将子表单和外部的表单关联起来?特别是校验

因为我们需要在外部表单校验每一个表单项是否合规时,子表单应该也要触发自身的表单校验。

field.form.addEffects('subFormValidate', () => {
  onFieldValidateStart(field.address, async () => {
    try {
      await form.validate();
    } catch (error) {
      // 自身是一个子表单,所以不需要父表单的item再显示报错样式
      field.setFeedback({
        type: 'error',
        code: 'ValidateError',
        triggerType: 'onInput',
        messages: [''],
      });
    }
  });
});

注意到这里,我们需要先拿到当前field对应的外部表单(父表单)。

const outerForm = field.form;

然后监听外部表单(父表单)的校验时机onFieldValidateStart。当外部表单校验时,触发子表单的校验

form.validate()

我们还需要将父表单的错误通过子表单的错误给覆盖

field.setFeedback({
  type: 'error',
  code: 'ValidateError',
  triggerType: 'onInput',
  messages: [''],
});

这里有些hack的意思,只能通过message: ['']来将父表单对应Item报错置空。

这样就实现了复合字段在Formily的应用。

除了表单还有什么?

其实Formily还提供了一种思路,为后续的拖拽生成表单,乃至页面、搭建PaaS平台提供了一种思路。

至于这一块儿,期待后面的博客吧~

参考