React通用解决方案——表单容器

3,198 阅读6分钟

1. 前话

提问:ToB中台类系统的后端开发主要做啥?

🙋‍♂️:CRUD

再次提问:那前端开发呢?

🙋‍♂️:增删查改

开个玩笑哈啊哈哈哈😂😂😂

虽然没有具体数据统计,但作者仍主观地认为中台类的系统的前端内容至少一半都是增删查改🌚🌚🌚,其对应的前端页面类型就是列表页面和表单页面。

对于列表页面的通用实现,如果读者有看过《React通用解决方案——组件数据请求》一文应该会根据自身实际业务场景得出较好的解决方案,提升实际业务的列表页面的开发效率。

而对于表单页面,又该如何实现以作为通用开发模版进行提效?大致有两种:

  • 一种「配置表单」,也就是定义表单DSL,通过JSON配置生成表单页面,这也是业界低代码平台的表单实现。优点是显而易见的,配置简单,快速实现。缺点是灵活性受限于DSL的完整性,对于特殊场景需进行表单组件底层实现的定制化。
  • 另一种是「原生表单」,也就是直接使用表单组件。其优缺陷大致与「配置表单」相反。

本篇由于主题定义就不讲解表单的通用实现只分享表单的通用呈现哈🧎‍♂️🧎‍♂️🧎‍♂️下面开始正文。

2. 正文

常见的表单的呈现有两种模式,分别是页面和浮层

首先是「页面表单」,也就是以页面的形式呈现表单。示例代码如下:

const FormPage: React.FC = () => {
  const [form] = useForm();

  const handleSubmit = useCallback((value) => {
    // TODO 表单提交逻辑
    console.log(value);
  }, []);

  return (
    <div className="test-page">
      <h2>新建用户</h2>
      <Form form={form} onSubmit={handleSubmit} layout="inline">
        <Form.Item
          field="name"
          label="名称"
          rules={[
            {
              required: true,
              message: "请输入名称",
            },
          ]}
        >
          <Input placeholder="请输入" />
        </Form.Item>
        <Form.Item>
          <Button htmlType="submit">提交</Button>
        </Form.Item>
      </Form>
    </div>
  );
};

浏览器展现如下:

image.png

某一天,产品为了优化交互体验改成「以弹窗呈现表单」,这时便会用到表单的另一种呈现——「浮层表单」。在原「页面表单」的实现中进行修改,修改后的示例代码如下:

const FormPage: React.FC = () => {
  const [form] = useForm();

  const visible = useBoolean(false);

  const handleSubmit = useCallback(() => {
    form.validate((error, value) => {
      if (error) {
        return;
      }
      // TODO 表单提交逻辑
      console.log(value);

      visible.setFalse();
    });
  }, []);

  return (
    <div className="test-page">
      <h2>新建用户</h2>
      <Button onClick={visible.setTrue}>点击新建</Button>

      <Modal
        visible={visible.state}
        title="新建用户"
        okText="提交"
        onOk={handleSubmit}
        onCancel={visible.setFalse}
      >
        <Form form={form} layout="inline">
          <Form.Item
            field="name"
            label="名称"
            rules={[
              {
                required: true,
                message: "请输入名称",
              },
            ]}
          >
            <Input placeholder="请输入" />
          </Form.Item>
        </Form>
      </Modal>
    </div>
  );
};

浏览器展现如下:

image.png

某一天,产品提了个新需求,另一个「用户新建页面表单」。某一天,产品提了个新需求,另一个「用户新建弹窗表单」。某一天,产品提了个新需求,另一个「用户新建抽屉表单」。某一天。。。

这时RD纠结了,为了快速完成需求直接是拷贝一个新的「FormPage」组件完成交付最终的结局肯定就是「秃头」,亟需总结个通用的解决方案应对表单不同呈现的场景的实现。

切入点是对表单和呈现进行拆分,避免表单和呈现的耦合

那该如何拆分?我们先明确下表单和呈现各自的关注点,表单主要关注表单值和表单动作,而呈现主要关注自身的样式。如果表单的动作需要呈现进行触发,例如弹窗的确定按钮触发表单的提交动作呢?这就需要表单与呈现之间需存在连接的桥梁

作者根据这个思路最终拆分的结果是,实现了个「表单容器」。「表单」+「表单容器」,让表单的实现不关注呈现,从而实现表单的复用,提升了开发效率。

2.1 表单容器定义

表单容器的定义基于浮层容器拓展,定义如下:

  • 表单容器支持各种呈现(弹窗和抽屉等);
  • 表单容器只关注浮层的标题、显隐状态和显隐状态变更处理逻辑,不关注浮层内容;
  • 表单容器组件提供接口控制浮层容器的标题和显隐状态;
  • 任何内容被表单容器包裹即可获得浮层的能力;
  • 表单容器提供向浮层内容透传属性的能力,内置透传Form实例、表单模式和只读状态的属性;
  • 表单容器的浮层确认逻辑自动触发Form实例的提交逻辑

基于上面的定义实现的TS类型定义如下:

import React from "react";

import { ModalProps, DrawerProps, FormInstance } from "@arco-design/web-react";

import { EFormMode, IBaseFormProps } from "@/hooks/use-common-form";

export type IFormWrapperBaseProps = {
  /** 标题 */
  title?: React.ReactNode;
};

export type IFormWrapperOpenProps<T = any, P = {}> = IFormWrapperBaseProps & {
  /** 表单模式 */
  mode?: EFormMode;
  /** 表单值 */
  value?: T;
  /** 内容属性 */
  props?: P;
};

export type IFormWrapperProps<T = any, P = {}> = IFormWrapperBaseProps & {
  /** 表单实例 */
  form?: FormInstance<T>;
  /** 表单弹窗提交回调函数 */
  onSubmit?: (
    formValue: T,
    currentValue: T,
    formMode: EFormMode,
    formInstance: FormInstance<T>,
    componentProps?: P,
  ) => Promise<any>;
  /** 表单弹窗显隐状态 */
  visible?: boolean;
  /** 表单弹窗确认回调函数 */
  onOk?: (result: any, componentProps?: P) => void | Promise<void>;
  /** 表单弹窗取消回调函数 */
  onCancel?: () => void;
  /** 内容属性 */
  componentProps?: P;
};

export type IFormWrappedModalProps<T = any, P = {}> = Omit<
  ModalProps,
  'onOk' | 'onCancel'
> &
  IFormWrapperProps<T, P>;

export type IFormWrappedDrawerProps<T = any, P = {}> = Omit<
  DrawerProps,
  'onOk' | 'onCancel'
> &
  IFormWrapperProps<T, P> & {
    operation?: React.ReactNode;
  };

export type IFormWrapperRef<T = any, P = {}> = {
  /** 表单弹窗打开接口 */
  open: (openProps?: IFormWrapperOpenProps<T, P>) => void;
  /** 表单弹窗关闭接口 */
  close: () => void;
};

export type IWithFormWrapperOptions<T = any, P = {}> = {
  /** 默认值 */
  defaultValue: T;
  /** 默认属性 */
  defaultProps?: Partial<IFormWrapperProps<T, P>>;
};

export type IWithFormWrapperProps<T = any, P = {}> = IFormWrapperBaseProps &
  IBaseFormProps & {
    /** 表单弹窗显隐状态 */
    visible?: boolean;
    /** 表单弹窗提交回调函数 */
    onOk?: () => Promise<void>;
    /** 表单弹窗取消回调函数 */
    onCancel?: () => void;
    /** 表单实例 */
    form: FormInstance<T>;
  } & P;

2.2 表单容器定义实现

基于上面的表单容器定义,我们这里实现一个Hook,实现代码如下:

/**
 * 表单容器Hook
 * @param ref 浮层实例
 * @param wrapperProps 浮层属性
 * @param defaultValue 默认值
 * @returns
 */
export function useFormWrapper<T = any, P = {}>(
  ref: ForwardedRef<IFormWrapperRef<T, P>>,
  wrapperProps: IFormWrapperProps<T, P>,
  defaultValue: T,
) {
  const [form] = Form.useForm(wrapperProps.form);

  const visible = useBoolean(false);
  const loading = useBoolean(false);

  const [title, setTitle] = useState<React.ReactNode>();
  const [componentProps, setComponentProps] = useState<P>();

  const [value, setValue] = useState(defaultValue);
  const [mode, setMode] = useState(EFormMode.view);

  // 计算是否只读
  const readOnly = useReadOnly(mode);

  // 提交处理逻辑
  const onOk = async () => {
    loading.setTrue();

    const targetComponentProps = wrapperProps.componentProps ?? componentProps;

    try {
      // 校验表单
      const formValue = await form.validate();
      // 提交表单
      form.submit();
      const result = await wrapperProps?.onSubmit?.(
        formValue,
        value,
        mode,
        form,
        targetComponentProps,
      );
      await wrapperProps.onOk?.(result, targetComponentProps);
      visible.setFalse();
    } catch (err) {
      console.error(err);
    } finally {
      loading.setFalse();
    }
  };

  // 取消处理逻辑
  const onCancel = () => {
    wrapperProps.onCancel?.();
    visible.setFalse();
  };

  // 实例挂载表单操作接口
  useImperativeHandle(
    ref,
    (): IFormWrapperRef<T, P> => ({
      open: openProps => {
        const {
          title: newTitle,
          mode: newMode = EFormMode.view,
          value: newValue = defaultValue,
          props: newComponentProps,
        } = openProps ?? {};

        setMode(newMode);
        setTitle(newTitle);
        setValue(newValue);
        setComponentProps(newComponentProps);

        form.resetFields();
        form.setFieldsValue(newValue as any);
        visible.setTrue();
      },
      close: onCancel,
    }),
  );

  // 初始化表单默认值
  useEffect(() => {
    form.setFieldsValue(defaultValue as any);
  }, []);

  const ret = [
    {
      loading: loading.state,
      visible: wrapperProps.visible ?? visible.state,
      title: wrapperProps.title ?? title,
      componentProps: wrapperProps.componentProps ?? componentProps,
      form,
      value,
      mode,
      readOnly,
    },
    {
      onOk,
      onCancel,
      setTitle,
      setComponentProps,
      setValue,
      setMode,
    },
  ] as const;

  return ret;
}

2.3 表单容器呈现实现

表单容器的呈现有多种,常见的为弹窗和抽屉。下面我使用Arco对应组件进行呈现实现 👇 。

2.3.1 弹窗表单容器

/**
 * 表单弹窗容器
 * @param options 表单配置
 * @returns
 */
function withModal<T = any, P = {}>(options: IWithFormWrapperOptions<T, P>) {
  const { defaultValue, defaultProps } = options;

  return function (Component: any) {
    const WrappedComponent = (
      props: IFormWrappedModalProps<T, P>,
      ref: ForwardedRef<IFormWrapperRef<T, P>>,
    ) => {
      const wrapperProps = {
        ...defaultProps,
        ...props,
      };

      const {
        okButtonProps,
        cancelButtonProps,
        okText = 'Submit',
        cancelText = 'Cancel',
        maskClosable = false,
        unmountOnExit = true,
        ...restProps
      } = wrapperProps;

      const [
        { form, mode, readOnly, visible, title, componentProps },
        { onOk, onCancel },
      ] = useFormWrapper<T, P>(ref, wrapperProps, defaultValue);

      return (
        <Modal
          {...restProps}
          maskClosable={maskClosable}
          visible={visible}
          onOk={onOk}
          okText={okText}
          okButtonProps={{
            hidden: readOnly,
            ...okButtonProps,
          }}
          onCancel={onCancel}
          cancelText={cancelText}
          cancelButtonProps={{
            hidden: readOnly,
            ...cancelButtonProps,
          }}
          title={title}
          unmountOnExit={unmountOnExit}
        >
          {React.createElement(Component, {
            form,
            mode,
            readOnly,
            visible,
            title,
            onOk,
            onCancel,
            ...componentProps,
          })}
        </Modal>
      );
    };

    WrappedComponent.displayName = `FormWrapper.withModal(${getDisplayName(
      Component,
    )})`;

    const ForwardedComponent = forwardRef<
      IFormWrapperRef<T, P>,
      IFormWrappedModalProps<T, P>
    >(WrappedComponent);

    return ForwardedComponent;
  };
}

2.3.1 抽屉表单容器

/**
 * 表单抽屉容器
 * @param options 表单配置
 * @returns
 */
function withDrawer<T = any, P = {}>(options: IWithFormWrapperOptions<T, P>) {
  const { defaultValue, defaultProps } = options;

  return function (Component: any) {
    const WrappedComponent = (
      props: IFormWrappedDrawerProps<T, P>,
      ref: ForwardedRef<IFormWrapperRef<T, P>>,
    ) => {
      const wrapperProps = {
        ...defaultProps,
        ...props,
      };

      const {
        okText = 'Submit',
        okButtonProps,
        cancelText = 'Cancel',
        cancelButtonProps,
        maskClosable = false,
        unmountOnExit = true,
        operation,
        ...restProps
      } = wrapperProps;

      const [
        { form, mode, readOnly, loading, visible, title, componentProps },
        { onOk, onCancel },
      ] = useFormWrapper<T, P>(ref, wrapperProps, defaultValue);

      const footerNode = useMemo(
        () => (
          <div style={{ textAlign: 'right' }}>
            {operation}

            {!readOnly && (
              <>
                <Button
                  type="default"
                  onClick={onCancel}
                  {...cancelButtonProps}
                >
                  {cancelText}
                </Button>
                <Button
                  type="primary"
                  loading={loading}
                  onClick={onOk}
                  style={{ marginLeft: '8px' }}
                  {...okButtonProps}
                >
                  {okText}
                </Button>
              </>
            )}
          </div>
        ),
        [
          loading,
          onOk,
          onCancel,
          okText,
          cancelText,
          readOnly,
          okButtonProps,
          cancelButtonProps,
        ],
      );

      const showFooter = useMemo(
        () => !(readOnly && !operation),
        [readOnly, operation],
      );

      return (
        <Drawer
          {...restProps}
          maskClosable={maskClosable}
          visible={visible}
          title={title}
          footer={showFooter ? footerNode : null}
          unmountOnExit={unmountOnExit}
          onCancel={onCancel}
        >
          {React.createElement(Component, {
            form,
            mode,
            readOnly,
            visible,
            title,
            onOk,
            onCancel,
            ...componentProps,
          })}
        </Drawer>
      );
    };

    WrappedComponent.displayName = `FormWrapper.withDrawer(${getDisplayName(
      Component,
    )})`;

    const ForwardedComponent = forwardRef<
      IFormWrapperRef<T, P>,
      IFormWrappedDrawerProps<T, P>
    >(WrappedComponent);

    return ForwardedComponent;
  };
}

2.4 表单容器用例

对于上面的代码示例我们进行以下改造,将页面的表单抽离成单独的表单组件,代码如下:

type IUserFormValue = {
  name?: string;
};

const UserForm: React.FC<IWithFormWrapperProps<IUserFormValue>> = ({
  form,
}) => {
  return (
    <Form form={form} layout="inline">
      <Form.Item
        field="name"
        label="名称"
        rules={[
          {
            required: true,
            message: "请输入名称",
          },
        ]}
      >
        <Input placeholder="请输入" />
      </Form.Item>
    </Form>
  );
};

下面我们就可以使用上面实现的表单容器进行包裹生成弹窗表单组件,代码如下:

const submitForm = async (formValue: IUserFormValue) => {
  // TODO 表单提交逻辑
  console.log(formValue);
};

const UserFormModal = FormWrapper.withModal<IUserFormValue>({
  defaultValue: {
    name: "",
  },
  defaultProps: {
    onSubmit: submitForm,
  },
})(UserForm);

在实际业务场景中,弹窗表单和页面表单都能复用一个表单组件,代码如下:

const FormPage: React.FC = () => {
  const [form] = useForm<IUserFormValue>();

  const userFormRef = useRef<IFormWrapperRef<IUserFormValue>>(null);

  const handleSubmit = useCallback(() => {
    form.validate((error, formValue) => {
      if (error || !formValue) {
        return;
      }
      submitForm(formValue);
    });
  }, []);

  return (
    <div className="test-page">
      <h2>新建用户</h2>

      {/* 页面表单 */}
      <UserForm form={form} />
      <Button onClick={handleSubmit}>页面新建</Button>

      {/* 弹窗表单 */}
      <UserFormModal ref={userFormRef} />
      <Button
        onClick={() => {
          userFormRef.current?.open({
            title: "新建用户",
            mode: EFormMode.add,
            value: {
              name: "",
            },
          });
        }}
      >
        弹窗新建
      </Button>
    </div>
  );
};

3. 最后

表单容器的基于浮层容器进行实现,作者在实际业务开发过程中也广泛应用到了这两类容器,本篇也只是对简单表单场景进行实现,更为复杂的表单场景可以在评论区交流哈。

当然作者也期待读者们能有所收获,如果发现Bug也麻烦大家指出作者马上改正~