React业务积累,如何打造一个适应Antd的表单项组件(一)?

646 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

业务背景

在react的后台界面的开发中,关于表单的开发是必不可少的。因为ui和交互奇奇怪怪的脑洞常常会设计一些结构复杂的表单项组件,这时候优秀的antd组件库并没有相应的组件可供我们选择。为此只能自己自定义实现。因为是自定义,很多同学为了开始阶段的方便,常常就直接耦合进业务特殊表单项交互就特殊处理,并不抽离成适应于Antd的表单组件。一开始是爽了,如果表单数据结构复杂,有值的编辑需要(这时候一般要使用 form.setFieldsValue(initValue)来初始化表单视图,显示已经存在的值)重点就是这里——

如果是直接将表单项交互耦合进业务,那初始化表单的时候就要各种使用useEffect进行处理,麻烦不说,还容易出错。要是下一个项目,又是这个组件,复制粘贴修改风险也很大,因此为了项目的可维护,也是为了后面更好的摸鱼,我们需要写一个适应于Antd的表单项组件

前奏示例

话不多说,来个截图,自定义实现这样一个antd表单项,组件名称为FormListSelect

效果截图

1649326257(1).jpg

功能要求

  1. 点击按钮,出现弹窗或者抽屉,列表加载数据,提供数据的选择项
  2. 有单选和多选两个功能,选择后按钮变成选择值
  3. 可以直接配合antd的Form,Form.Item组件无需使用onChange操作
  4. 可以使用form.setFieldsValue(initValue)来初始化表单项
  5. 可以在自定义表单项组件下,自定义数据项展示选择组件(SeletctMapList),用于后期可能面临的组件扩展,效果如图:
<FormListSelect>
    <SeletctMapList></SeletctMapList>
</FormListSelect>

1649326872(1).jpg

问题难点

难点项

  1. 问题的第一个难点就是我们怎么样让自定义的表单项组件的变化的值,通知到Form.Item组件,让他获取到(其实很简单,就是大部分的同学没有意识到)
  2. 第二个难点是自定义表单,如何拿到自定义数据项展示选择组件的选择值,其实原理和Form.Item组件差不多

难点解决思路

  1. 针对第一难点,其实很简单,只是大部分的前端程序员不知道,其实antd的Form.Item组件是通过给其下的表单项组件,注册valueonChange属性来获取订阅其下表单项变动的值的,这也就是意味着,我们要实现一个适应antd表单项的组件,就需要对这个表单项组件实现一个value属性,并用useEffect进行监听;实现一个onChange方法订阅发布我们自定义表单项变动的值

  2. 解决第二个难点,其实就是FormListSelect这个表单自定义项组件要给其下的props.children子组件注册valueonChange(当然该子组件也要实现这两个属性);怎么注册呢?其实也很简单,只是大家业务写多了,忽视了而已,那就是直接用React.cloneElement来克隆一下,然后在第二个参数写上要注册的value,和onChange方法即可

组件编写(仅有部分代码)

1. 选取适合的组件

import { ReactElement, ReactNode, useEffect, useMemo, useState } from 'react';
import React from 'react';

import { CloseOutlined, PlusCircleOutlined } from '@ant-design/icons';
import { DrawerForm, DrawerFormProps, ModalForm, ModalFormProps } from '@ant-design/pro-form';
import ProTable, { ProTableProps } from '@ant-design/pro-table';
import { Button, Space } from 'antd';
import { isArray } from 'lodash';

具体的组件用法可以参考antdantd-procomponents

2. 定义类型

我这边定义类型就简单了一些,没有做重载,也没有做泛型的约束

interface handleValueType<T, S> {
  (selectedVal: T): S;
}
/**
 * T 表示数据行类型
 * S 表示数据行转换成value的类型
 */
export type FormListSelectProps<T = unknown, S = unknown | T> = {
  // handleValue?:handleValueType<T,S>
  valueDispose?: {
    tag: keyof S | string[]; // 显示值的字段
    key: keyof S | string[]; // 显示值的key
    handleValue: handleValueType<T, S>;
  };
  title?: string;
  multiple?: boolean;
  showType?: showTypeEnum;
  triggerType?: 'modal' | 'drawer';
  triggerForm?: ModalFormProps & DrawerFormProps;
  tableProps?: ProTableProps<T, Record<string, unknown>>;
  isTableSearch?: boolean;
  onChange?: (v: S | S[]) => void;
  children?: ReactNode;
  value?: S[];
  defaultValue?: S[];
};

3. 实现点击按钮,出现弹窗或者抽屉,列表加载数据,提供数据的选择项

  1. 将触发modal类型的组件和drawer类型的组件用一个对象指引
const triggerTypeElementMap = {
    modal: ModalForm,
    drawer: DrawerForm,
 };
  1. 使用props.triggerType来控制显示什么组件,默认是modal
const RenderTrigger = triggerTypeElementMap[props.triggerType || 'modal'];
  1. 将触发渲染组件封装进一个函数
const fromTiggerRender = (value: (S | T)[], type: showTypeEnum = 'button', tagfiled: keyof S | string[]) => {
    if (type === 'button') {
      return (
        <div>
          <Space style={{ display: value.length !== 0 ? 'inline-flex' : 'none' }}>
            {value.map((item, index) => {
              if (typeof item !== 'object') {
                return (
                  <Button key={index} size="small" type="dashed">
                    <Space>
                      {item}
                      <CloseOutlined />
                    </Space>
                  </Button>
                );
              } else {
                let showTag = item;
                if (isArray(tagfiled)) {
                  tagfiled.forEach((tagf) => {
                    showTag = showTag[tagf];
                  });
                } else {
                  showTag = showTag[tagfiled as string];
                }
                return (
                  <Button size="small" type="dashed" key={index}>
                    <Space>
                      {showTag}
                      <CloseOutlined />
                    </Space>
                  </Button>
                );
              }
            })}
          </Space>
          <Space style={{ display: value.length == 0 ? 'inline-flex' : 'none' }}>
            <Button>
              <Space>
                {title}
                <PlusCircleOutlined />
              </Space>
            </Button>
          </Space>
        </div>
      );
    } else {
      return <></>;
    }
  };
  1. 重点来了,实现一个onChange方法,当然这个方法是可选的,也是方便Form.Item进行主动注册
<RenderTrigger
      title={title}
      {...TiggerProps}
      onFinish={async () => {
        //  这里是重点,也就是通过这里,`Form.Item`来获取到表单项的值的
        if (onChange) onChange(multiple ? selectValArray : selectValArray?.[0]);
        return true;
      }}
      trigger={fromTiggerRender(selectValArray, showType, valueDispose?.tag!)}
   />
  1. 监听value,这部分就比较简单了,略
  2. 如果有props.children进行劫持,克隆修改,注册valueonChange
const CustomNode = props.children;
... 
React.cloneElement(CustomNode as ReactElement, {
  onChange(value: T) {
    console.log(value, 'afasdfa');
    let valueRes: S | S[];
    if (valueDispose) {
      valueRes = isArray(value)
        ? value.map((vs: T) => {
            return valueDispose.handleValue(vs);
          })
        : valueDispose.handleValue(value);
    } else {
      valueRes = value as unknown as S | S[];
    }
    setSelectValArray(() => {
      return isArray(valueRes) ? valueRes : [valueRes];
    });
  },
  value: selectValArray,
})

完整代码点击这里

编写该类组件的注意点

  1. 定义的onChange方法千万不要在监听value和监听你组件内部设置值的变量函数中,因为触发了onChange之后,Form.Item会更新你的value,然后导致死循环

扩展

antd,表单form.setFieldsValue(initValue);有一个很实用的地方,就是你用这个方法设置初始值,如果当前Form.Itemname没有表现到initValue里的字段,提交的时候也不会有该字段,提交的时候,值的字段仅仅只包含Form.Itemname注册过的字段

例如:

form.setFieldsValue({
    sex:'男',
    age:18
})
// 我的Form.Item只有一个他的属性[name]为'sex',那么提交的时候,表单的输出值就是{sex:'男'}