普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月13日首页

React19项目中 FormEdit / FormEditModal 组件封装设计说明

2026年4月13日 15:36

适用于 React + TypeScript + Arco Design Web 的配置化表单与弹窗表单方案

文档目标: 说明当前 FormEdit / FormEditModal / types / utils 方案的职责分层、设计原因、优点与风险、关键语法,以及实际使用方式。

1. 方案概览

当前方案是一套“配置驱动 + ref 驱动 + 弹窗承载 + 字段类型扩展”的通用表单基础设施。它的核心不是写死某个业务表单,而是通过一组统一的配置和类型,把新增、编辑、查看、弹窗提交这些高重复场景抽象出来。

• FormEdit:负责表单渲染、字段分发、初始值处理、对外暴露表单 API。

• FormEditModal:负责弹窗显示、确定/取消流程、是否关闭、是否重置。

• types:负责定义字段类型、配置结构、ref 方法、组件 props,是整个方案的契约层。

• utils:负责类名合并、初始值归一化、根据表单配置构建整表默认值。

2. 每个代码文件的详解

2.1 types.ts

types.ts 是整套方案最底层的协议文件。它的作用不是直接渲染 UI,而是约束“这套表单系统支持什么配置、暴露什么能力、字段有哪些类型”。

• 定义 EditState:统一新增、编辑、查看三种状态,避免业务层自行约定字符串。

• 定义 FormFieldType:从基础的 input / textarea / select 扩展到 switch / upload / cascader / datePicker / custom。

• 定义 FormItemConfig:让每个字段既包含业务属性,也包含布局和组件渲染属性。

• 定义 FormEditRef:把 validate、resetFields、setValues、getValues、setFieldValue 等能力统一收敛。

• 定义 FormEditProps:支持表单整体布局方向 direction、列数 columns、回填控制 syncKey、卡片/纯内容 layout 等。

import type { ReactNode, RefObject } from 'react';

export type EditState = 'add' | 'edit' | 'view';

export type FieldValue = string | number | boolean;

export type FormFieldType =
  | 'input'
  | 'textarea'
  | 'select'
  | 'radioGroup'
  | 'checkboxGroup'
  | 'switch'
  | 'upload'
  | 'cascader'
  | 'datePicker'
  | 'divider'
  | 'custom';

export interface OptionItem {
  key?: string | number;
  label: string;
  value: FieldValue;
  disabled?: boolean;
  children?: OptionItem[];
}

export interface RuleItem {
  required?: boolean;
  message?: string;
  trigger?: 'change' | 'blur' | Array<'change' | 'blur'>;
  validator?: (value: unknown, values: Record<string, unknown>) => void | string | Promise<void | string>;
}

export interface RenderFieldContext {
  value: unknown;
  formData: Record<string, unknown>;
  editState: EditState;
  disabled: boolean;
  setFieldValue: (key: string, value: unknown) => void;
  getFieldValue: (key: string) => unknown;
  setValues: (values: Record<string, unknown>) => void;
}

export interface FormItemConfig {
  key: string;
  title?: string;
  type: FormFieldType;

  hidden?: boolean;
  required?: boolean;
  disabled?: boolean;
  placeholder?: string;

  /**
   * columns=2 时可设置 2 跨整行
   */
  colSpan?: 1 | 2;

  /**
   * 外层栅格项 className
   */
  className?: string;

  /**
   * Form.Item 自身 className
   */
  formItemClassName?: string;

  rules?: RuleItem[];

  maxLength?: number;
  showWordLimit?: boolean;
  rows?: number;

  /**
   * select / radioGroup / checkboxGroup / cascader 共用
   */
  options?: OptionItem[];

  /**
   * select 多选
   */
  mode?: 'single' | 'multiple';

  /**
   * 选项展示 label-value
   */
  showKV?: boolean;

  /**
   * radio / checkbox 排列方向
   */
  direction?: 'horizontal' | 'vertical';

  /**
   * checkbox 最大可选数
   */
  max?: number;

  extra?: ReactNode;
  initialValue?: unknown;

  /**
   * 透传给具体字段组件的属性
   */
  fieldProps?: Record<string, unknown>;

  /**
   * switch 专用
   */
  checkedValue?: string | number | boolean;
  uncheckedValue?: string | number | boolean;

  /**
   * upload 专用
   */
  uploadAction?: string;
  limit?: number;

  /**
   * datePicker 专用
   */
  datePickerType?: 'date' | 'week' | 'month' | 'quarter' | 'year' | 'range';
  format?: string;

  /**
   * custom 专用
   */
  render?: (ctx: RenderFieldContext) => ReactNode;

  onChange?: (value: unknown, formData: Record<string, unknown>) => void;
}

export interface FormEditProps {
  modelValue?: Record<string, unknown>;
  formArr: FormItemConfig[];
  editState?: EditState;
  className?: string;

  title?: ReactNode;
  description?: ReactNode;
  width?: number | string;
  layout?: 'card' | 'plain';

  /**
   * 表单布局方向
   * horizontal:水平布局
   * vertical:垂直布局
   */
  direction?: 'horizontal' | 'vertical';

  /**
   * 默认 1 列
   * 字段多时可用 2 列
   */
  columns?: 1 | 2;

  /**
   * 用于控制何时重新回填表单
   * 推荐编辑态传入主键,例如 id
   */
  syncKey?: string | number | null | undefined;

  onValuesChange?: (changedValues: Record<string, unknown>, values: Record<string, unknown>) => void;
}

export interface FormEditRef {
  validate: () => Promise<boolean>;
  resetFields: () => void;
  setValues: (values: Record<string, unknown>) => void;
  getValues: () => Record<string, unknown>;
  setFieldValue: (key: string, value: unknown) => void;
  getFieldValue: (key: string) => unknown;
  clearErrors: () => void;
}

export interface FormEditModalProps {
  open?: boolean;
  title?: ReactNode;
  width?: string | number;
  className?: string;
  contentClassName?: string;

  showFooter?: boolean;
  useCustomFooter?: boolean;
  footer?: ReactNode;

  okText?: string;
  cancelText?: string;
  confirmLoading?: boolean;

  maskClosable?: boolean;
  escToClose?: boolean;
  destroyOnClose?: boolean;

  closeOnOk?: boolean;
  closeOnCancel?: boolean;

  formRef?: RefObject<FormEditRef | null>;
  validateBeforeOk?: boolean;
  resetAfterClose?: boolean;

  children?: ReactNode;

  onOpenChange?: (open: boolean) => void;
  onCancel?: () => void;
  onOk?: (values?: Record<string, unknown>) => void | Promise<void>;
}

export interface FormEditModalRef {
  open: () => void;
  close: () => void;
  toggle: () => void;
}

这一层的好处是:调用方和组件实现方共用同一份类型约束,后续扩展字段类型时,编译器会帮助你定位所有待补位置。

2.2 utils.ts

utils.ts 的核心是让表单初始值更稳定。通用表单最容易出问题的地方,不是“字段渲染不出来”,而是不同控件在新增态、编辑态、回填态下的默认值类型不一致。

• cx:合并 className,支持字符串、数组、对象条件写法。

• normalizeInitialValue:根据字段类型给出合理默认值,例如多选 select、upload、cascader、range 类型日期使用数组,switch 使用 uncheckedValue 或 false。

• buildInitialFormData:把单字段归一化提升为整张表单数据的构建函数。

• getVisibleFormItems:过滤 hidden 字段,避免隐藏字段仍参与渲染和初始值流程。

import type { FormItemConfig } from './types';

type ClassDictionary = Record<string, boolean | null | undefined>;
type ClassArray = ClassValue[];
type ClassValue = string | null | undefined | false | ClassDictionary | ClassArray;

export function cx(...args: ClassValue[]): string {
  const classes: string[] = [];

  const append = (value: ClassValue) => {
    if (!value) return;

    if (typeof value === 'string') {
      classes.push(value);
      return;
    }

    if (Array.isArray(value)) {
      value.forEach(append);
      return;
    }

    if (typeof value === 'object') {
      Object.keys(value).forEach((key) => {
        if (value[key]) {
          classes.push(key);
        }
      });
    }
  };

  args.forEach(append);
  return classes.join(' ');
}

export function normalizeInitialValue(item: FormItemConfig, value: unknown): unknown {
  if (value !== undefined) return value;
  if (item.initialValue !== undefined) return item.initialValue;

  if (item.type === 'checkboxGroup') return [];
  if (item.type === 'select' && item.mode === 'multiple') return [];
  if (item.type === 'upload') return [];
  if (item.type === 'cascader') return [];
  if (item.type === 'datePicker' && item.datePickerType === 'range') return [];
  if (item.type === 'switch') {
    return item.uncheckedValue ?? false;
  }

  return '';
}

export function buildInitialFormData(formArr: FormItemConfig[], modelValue: Record<string, unknown> = {}) {
  const nextData: Record<string, unknown> = {};

  formArr.forEach((item) => {
    if (item.type === 'divider') return;
    nextData[item.key] = normalizeInitialValue(item, modelValue[item.key]);
  });

  return nextData;
}

export function getVisibleFormItems(formArr: FormItemConfig[]) {
  return formArr.filter((item) => !item.hidden);
}

这层看似简单,但它决定了表单是否能稳定受控,是否会出现默认值错乱或组件警告。

2.3 formEdit.tsx

formEdit.tsx 是整套方案的核心。它接收字段配置 formArr,把配置转换成真正的 Arco Form 结构,并通过 ref 向父组件暴露命令式方法。

• 通过 Form.useForm 获取 Arco 表单实例。

• 通过 useMemo 构建 initialValues,避免每次 render 都重新生成。

• 通过 syncKey + lastPatchedKeyRef 控制何时重新回填表单,避免误覆盖用户输入。

• 通过 buildArcoRules 把业务规则适配成 Arco 规则。

• 通过 renderField 按 type 分发不同字段类型的渲染逻辑。

• 通过 direction 控制 Form 的整体布局方向,通过 columns + colSpan 控制字段网格布局。

import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import {
  Card,
  Cascader,
  Checkbox,
  DatePicker,
  Divider,
  Form,
  Input,
  Radio,
  Select,
  Switch,
  Typography,
  Upload,
} from '@arco-design/web-react';
import type { FormEditProps, FormEditRef, FormItemConfig } from './types';
import { buildInitialFormData, cx, getVisibleFormItems } from './utils';

const { Title, Paragraph, Text } = Typography;

type FormItemProps = React.ComponentProps<typeof Form.Item>;
type ArcoRules = NonNullable<FormItemProps['rules']>;
type ArcoRule = ArcoRules extends Array<infer T> ? T : never;

function buildArcoRules(item: FormItemConfig, getValues: () => Record<string, unknown>): ArcoRule[] {
  const rules: ArcoRule[] = [];

  if (item.required) {
    rules.push({
      required: true,
      message: `${item.title || item.key}不能为空`,
    } as ArcoRule);
  }

  if (!item.rules?.length) {
    return rules;
  }

  item.rules.forEach((rule) => {
    if (rule.required) {
      rules.push({
        required: true,
        message: rule.message || `${item.title || item.key}不能为空`,
        trigger: rule.trigger,
      } as ArcoRule);
    }

    if (rule.validator) {
      rules.push({
        trigger: rule.trigger,
        validator: (value, callback) => {
          Promise.resolve(rule.validator?.(value, getValues()))
            .then((result) => {
              if (typeof result === 'string' && result) {
                callback(result);
                return;
              }

              callback();
            })
            .catch((error: unknown) => {
              if (error instanceof Error) {
                callback(error.message);
                return;
              }

              if (typeof error === 'string') {
                callback(error);
                return;
              }

              callback(rule.message || '校验失败');
            });
        },
      } as ArcoRule);
    }
  });

  return rules;
}

function getFieldColClass(columns: 1 | 2, item: FormItemConfig) {
  if (columns === 1) return 'col-span-1';
  return item.colSpan === 2 ? 'col-span-2' : 'col-span-1';
}

function renderLabel(item: FormItemConfig) {
  if (!item.title) return null;

  return (
    <span className="inline-flex items-center gap-1 whitespace-nowrap">
      {item.required ? <span className="leading-none text-red-500">*</span> : null}
      <span>{item.title}</span>
    </span>
  );
}

function FormEditInner(
  {
    modelValue = {},
    formArr,
    editState = 'add',
    className,
    title,
    description,
    width = 760,
    layout = 'card',
    direction = 'vertical',
    columns = 1,
    syncKey,
    onValuesChange,
  }: FormEditProps,
  ref: React.Ref<FormEditRef>,
) {
  const [form] = Form.useForm();
  const isView = editState === 'view';

  const visibleFormItems = useMemo(() => {
    return getVisibleFormItems(formArr);
  }, [formArr]);

  const initialValues = useMemo(() => {
    return buildInitialFormData(visibleFormItems, modelValue);
  }, [visibleFormItems, modelValue]);

  const currentPatchKey = syncKey ?? modelValue;
  const lastPatchedKeyRef = useRef(currentPatchKey);
  const mountedRef = useRef(false);

  useEffect(() => {
    if (!mountedRef.current) {
      mountedRef.current = true;
      form.setFieldsValue(initialValues);
      lastPatchedKeyRef.current = currentPatchKey;
      return;
    }

    if (lastPatchedKeyRef.current === currentPatchKey) return;

    lastPatchedKeyRef.current = currentPatchKey;
    form.resetFields();
    form.setFieldsValue(buildInitialFormData(visibleFormItems, modelValue));
  }, [currentPatchKey, form, initialValues, modelValue, visibleFormItems]);

  useImperativeHandle(
    ref,
    () => ({
      validate: async () => {
        try {
          await form.validate();
          return true;
        } catch {
          return false;
        }
      },
      resetFields: () => {
        form.resetFields();
        form.setFieldsValue(buildInitialFormData(visibleFormItems, modelValue));
      },
      setValues: (values) => {
        form.setFieldsValue(values);
      },
      getValues: () => {
        return form.getFieldsValue() as Record<string, unknown>;
      },
      setFieldValue: (key, value) => {
        form.setFieldValue(key, value);
      },
      getFieldValue: (key) => {
        return form.getFieldValue(key);
      },
      clearErrors: () => {
        const currentValues = form.getFieldsValue() as Record<string, unknown>;
        form.clearFields();
        form.setFieldsValue(currentValues);
      },
    }),
    [form, modelValue, visibleFormItems],
  );

  const getAllValues = () => {
    return form.getFieldsValue() as Record<string, unknown>;
  };

  const renderField = (item: FormItemConfig) => {
    const commonDisabled = isView || item.disabled;
    const commonValue = form.getFieldValue(item.key);

    const fieldContext = {
      value: commonValue,
      formData: getAllValues(),
      editState,
      disabled: !!commonDisabled,
      setFieldValue: (key: string, value: unknown) => form.setFieldValue(key, value),
      getFieldValue: (key: string) => form.getFieldValue(key),
      setValues: (values: Record<string, unknown>) => form.setFieldsValue(values),
    };

    switch (item.type) {
      case 'input':
        return (
          <Input
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请输入'}
            maxLength={item.maxLength}
            showWordLimit={item.showWordLimit}
            {...item.fieldProps}
          />
        );

      case 'textarea':
        return (
          <Input.TextArea
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请输入'}
            maxLength={item.maxLength}
            showWordLimit={item.showWordLimit}
            autoSize={{
              minRows: item.rows || 4,
              maxRows: Math.max((item.rows || 4) + 3, 6),
            }}
            {...item.fieldProps}
          />
        );

      case 'select':
        return (
          <Select
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择'}
            mode={item.mode === 'multiple' ? 'multiple' : undefined}
            maxTagCount={item.mode === 'multiple' ? 3 : undefined}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Select.Option key={opt.key ?? String(opt.value)} value={opt.value} disabled={opt.disabled}>
                {item.showKV ? `${opt.label}-${opt.value}` : opt.label}
              </Select.Option>
            ))}
          </Select>
        );

      case 'radioGroup':
        return (
          <Radio.Group
            direction={item.direction === 'vertical' ? 'vertical' : 'horizontal'}
            disabled={commonDisabled}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Radio key={opt.key ?? String(opt.value)} value={opt.value} disabled={commonDisabled || opt.disabled}>
                {opt.label}
              </Radio>
            ))}
          </Radio.Group>
        );

      case 'checkboxGroup':
        return (
          <Checkbox.Group
            direction={item.direction === 'vertical' ? 'vertical' : 'horizontal'}
            disabled={commonDisabled}
            max={item.max}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Checkbox key={opt.key ?? String(opt.value)} value={opt.value} disabled={commonDisabled || opt.disabled}>
                {opt.label}
              </Checkbox>
            ))}
          </Checkbox.Group>
        );

      case 'switch':
        return (
          <Switch
            disabled={commonDisabled}
            checked={commonValue === (item.checkedValue ?? true)}
            checkedValue={item.checkedValue ?? true}
            uncheckedValue={item.uncheckedValue ?? false}
            onChange={(value) => {
              form.setFieldValue(item.key, value);
              item.onChange?.(value, {
                ...getAllValues(),
                [item.key]: value,
              });
            }}
            {...item.fieldProps}
          />
        );

      case 'upload':
        return (
          <Upload
            disabled={commonDisabled}
            action={item.uploadAction}
            limit={item.limit}
            fileList={Array.isArray(commonValue) ? (commonValue as never[]) : []}
            onChange={(fileList) => {
              form.setFieldValue(item.key, fileList);
              item.onChange?.(fileList, {
                ...getAllValues(),
                [item.key]: fileList,
              });
            }}
            {...item.fieldProps}
          />
        );

      case 'cascader':
        return (
          <Cascader
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择'}
            options={item.options || []}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          />
        );

      case 'datePicker':
        if (item.datePickerType === 'range') {
          return (
            <DatePicker.RangePicker
              disabled={commonDisabled}
              placeholder={['开始日期', '结束日期']}
              format={item.format}
              onChange={(value) => {
                item.onChange?.(value, getAllValues());
              }}
              {...item.fieldProps}
            />
          );
        }

        return (
          <DatePicker
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择日期'}
            format={item.format}
            picker={item.datePickerType && item.datePickerType !== 'date' ? item.datePickerType : undefined}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          />
        );

      case 'divider':
        return <Divider style={{ margin: '4px 0 12px' }} />;

      case 'custom':
        if (item.render) {
          return item.render(fieldContext);
        }
        return <Text type="secondary">custom 类型缺少 render 配置</Text>;

      default:
        return <Text type="secondary">暂不支持的表单类型:{item.type}</Text>;
    }
  };

  const formContent = (
    <div className={cx('w-full', className)}>
      {title || description ? (
        <div style={{ marginBottom: 24 }}>
          {title ? <Title heading={6}>{title}</Title> : null}
          {description ? (
            <Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
              {description}
            </Paragraph>
          ) : null}
        </div>
      ) : null}

      <Form
        form={form}
        layout={direction}
        initialValues={initialValues}
        autoComplete="off"
        onValuesChange={(changedValues, values) => {
          onValuesChange?.(changedValues as Record<string, unknown>, values as Record<string, unknown>);
        }}
      >
        <div className={cx('grid gap-x-5', columns === 2 ? 'grid-cols-2' : 'grid-cols-1')}>
          {visibleFormItems.map((item, index) => {
            if (item.type === 'divider') {
              return (
                <div key={`${item.key}-${index}`} className="col-span-full">
                  {renderField(item)}
                </div>
              );
            }

            return (
              <div key={`${item.key}-${index}`} className={cx(getFieldColClass(columns, item), item.className)}>
                <Form.Item
                  className={item.formItemClassName}
                  label={renderLabel(item)}
                  field={item.key}
                  rules={buildArcoRules(item, () => form.getFieldsValue() as Record<string, unknown>)}
                  requiredSymbol={false}
                  extra={item.extra}
                  triggerPropName={item.type === 'switch' ? 'checked' : 'value'}
                >
                  {renderField(item)}
                </Form.Item>
              </div>
            );
          })}
        </div>
      </Form>
    </div>
  );

  const containerStyle = {
    width: typeof width === 'number' ? `${width}px` : width,
    maxWidth: '100%',
    margin: '0 auto',
  };

  if (layout === 'plain') {
    return <div style={containerStyle}>{formContent}</div>;
  }

  return (
    <Card bordered style={{ borderRadius: 16 }}>
      <div style={containerStyle}>{formContent}</div>
    </Card>
  );
}

FormEditInner.displayName = 'FormEdit';

const FormEdit = forwardRef(FormEditInner);
FormEdit.displayName = 'FormEdit';

export default FormEdit;

当前版本里,renderField 已经覆盖 input、textarea、select、radioGroup、checkboxGroup、switch、upload、cascader、datePicker、divider、custom 等类型,说明这套组件已经从“基础表单”提升为“可承载复杂业务表单”的组件。

2.4 formEditModal.tsx

formEditModal.tsx 负责承载表单弹窗。它把“打开 / 关闭、点击确定、点击取消、关闭后是否重置、提交前是否校验”等通用行为统一起来,让业务页面只关心 open 状态和 onOk 保存逻辑。

• 支持受控 / 非受控兼容的显示方式。

• 支持通过 formRef 在点击确定前自动 validate。

• 支持 closeOnOk / closeOnCancel 控制点击后是否关闭。

• 支持 resetAfterClose 控制关闭后是否恢复初始值。

• 支持自定义 footer,也支持内置确定 / 取消按钮。

这一层的价值在于:把弹窗提交流程标准化,避免每个页面重复写 validate -> getValues -> onOk -> close 这类模板代码。

import { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
import { Button, Modal, Space } from '@arco-design/web-react';
import type { FormEditModalProps, FormEditModalRef } from './types';

function FormEditModalInner(
  {
    open,
    title,
    width = 720,
    className,
    contentClassName,

    showFooter = true,
    useCustomFooter = false,
    footer,

    okText = '确定',
    cancelText = '取消',
    confirmLoading = false,

    maskClosable = false,
    escToClose = true,
    destroyOnClose = true,

    closeOnOk = true,
    closeOnCancel = true,

    formRef,
    validateBeforeOk = true,
    resetAfterClose = false,

    children,

    onOpenChange,
    onCancel,
    onOk,
  }: FormEditModalProps,
  ref: React.Ref<FormEditModalRef>,
) {
  const [innerOpen, setInnerOpen] = useState(false);

  const isControlled = open !== undefined;
  const visible = isControlled ? open : innerOpen;

  const setVisible = (next: boolean) => {
    if (!isControlled) {
      setInnerOpen(next);
    }
    onOpenChange?.(next);
  };

  const handleClose = () => {
    setVisible(false);
  };

  const handleAfterClose = () => {
    if (resetAfterClose) {
      formRef?.current?.resetFields();
    }
  };

  const handleCancel = () => {
    onCancel?.();

    if (closeOnCancel) {
      handleClose();
    }
  };

  const handleOk = async () => {
    if (validateBeforeOk && formRef?.current) {
      const passed = await formRef.current.validate();
      if (!passed) return;
    }

    const values = formRef?.current?.getValues();

    try {
      await onOk?.(values);

      if (closeOnOk) {
        handleClose();
      }
    } catch (error) {
      console.error('FormEditModal onOk 执行失败:', error);
    }
  };

  useImperativeHandle(
    ref,
    () => ({
      open: () => setVisible(true),
      close: () => setVisible(false),
      toggle: () => setVisible(!visible),
    }),
    [visible],
  );

  const defaultFooter = useMemo(() => {
    if (!showFooter) return null;

    if (useCustomFooter) {
      return footer;
    }

    return (
      <Space>
        <Button onClick={handleCancel}>{cancelText}</Button>
        <Button type="primary" loading={confirmLoading} onClick={handleOk}>
          {okText}
        </Button>
      </Space>
    );
  }, [cancelText, confirmLoading, footer, okText, showFooter, useCustomFooter, handleCancel, handleOk]);

  return (
    <Modal
      visible={visible}
      title={title}
      style={{ width }}
      className={className}
      unmountOnExit={destroyOnClose}
      maskClosable={maskClosable}
      escToExit={escToClose}
      onCancel={handleCancel}
      afterClose={handleAfterClose}
      footer={defaultFooter}
    >
      <div className={contentClassName}>{children}</div>
    </Modal>
  );
}

FormEditModalInner.displayName = 'FormEditModal';

const FormEditModal = forwardRef(FormEditModalInner);
FormEditModal.displayName = 'FormEditModal';

export default FormEditModal;

2.5 示例页面

import { useMemo, useRef, useState } from 'react';
import { Button, Input, Message, Space, Tag } from '@arco-design/web-react';
import { FormEdit, FormEditModal } from '@/components/FormEdit';
import type { FormEditRef, FormItemConfig } from '@/components/FormEdit';

export default function DemoModalForm() {
  const singleColFormRef = useRef<FormEditRef>(null);
  const doubleColFormRef = useRef<FormEditRef>(null);
  const customFormRef = useRef<FormEditRef>(null);

  const [singleOpen, setSingleOpen] = useState(false);
  const [doubleOpen, setDoubleOpen] = useState(false);
  const [customOpen, setCustomOpen] = useState(false);

  const [singleSubmitLoading, setSingleSubmitLoading] = useState(false);
  const [doubleSubmitLoading, setDoubleSubmitLoading] = useState(false);
  const [customSubmitLoading, setCustomSubmitLoading] = useState(false);

  // 一行一列:字段少,适合小弹窗
  const singleColumnFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'name',
        title: '名称',
        type: 'input',
        required: true,
        placeholder: '请输入名称',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        required: true,
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
        ],
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入说明',
        rows: 5,
        extra: '适合字段较少、弹窗较窄的场景',
      },
    ];
  }, []);

  // 一行两列:字段多,部分字段支持跨整行
  const doubleColumnFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'name',
        title: '名称',
        type: 'input',
        required: true,
        placeholder: '请输入名称',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
        ],
      },
      {
        key: 'type',
        title: '类型',
        type: 'radioGroup',
        options: [
          { label: '个人', value: 'personal' },
          { label: '企业', value: 'company' },
        ],
      },
      {
        key: 'tags',
        title: '标签',
        type: 'checkboxGroup',
        direction: 'horizontal',
        options: [
          { label: '热门', value: 'hot' },
          { label: '推荐', value: 'recommend' },
          { label: '最新', value: 'new' },
        ],
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入说明',
        rows: 5,
        colSpan: 2,
        extra: '这个字段比较长,所以在两列布局里跨整行显示',
      },
    ];
  }, []);

  // 一行 2 列,使用全部封装组件
  const customFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'projectName',
        title: '项目名称',
        type: 'input',
        required: true,
        placeholder: '请输入项目名称',
      },
      {
        key: 'status',
        title: '启用状态',
        type: 'switch',
        required: true,
        checkedValue: 1,
        uncheckedValue: 0,
        initialValue: 1,
        extra: '开启为 1,关闭为 0',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        required: true,
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
          { label: '杭州', value: 'hangzhou' },
        ],
      },
      {
        key: 'identityType',
        title: '用户类型',
        type: 'radioGroup',
        required: true,
        options: [
          { label: '个人', value: 'personal' },
          { label: '企业', value: 'company' },
        ],
      },
      {
        key: 'tags',
        title: '标签',
        type: 'checkboxGroup',
        direction: 'horizontal',
        options: [
          { label: '热门', value: 'hot' },
          { label: '推荐', value: 'recommend' },
          { label: '最新', value: 'new' },
        ],
      },
      {
        key: 'region',
        title: '地区级联',
        type: 'cascader',
        required: true,
        placeholder: '请选择地区',
        options: [
          {
            label: '浙江省',
            value: 'zhejiang',
            children: [
              {
                label: '杭州市',
                value: 'hangzhou',
                children: [
                  { label: '西湖区', value: 'xihu' },
                  { label: '滨江区', value: 'binjiang' },
                ],
              },
              {
                label: '宁波市',
                value: 'ningbo',
                children: [{ label: '鄞州区', value: 'yinzhou' }],
              },
            ],
          },
          {
            label: '广东省',
            value: 'guangdong',
            children: [
              {
                label: '深圳市',
                value: 'shenzhen',
                children: [
                  { label: '南山区', value: 'nanshan' },
                  { label: '福田区', value: 'futian' },
                ],
              },
            ],
          },
        ],
      },
      {
        key: 'publishDate',
        title: '发布日期',
        type: 'datePicker',
        required: true,
        datePickerType: 'date',
        format: 'YYYY-MM-DD',
        placeholder: '请选择发布日期',
      },
      {
        key: 'timeRange',
        title: '时间范围',
        type: 'datePicker',
        required: true,
        datePickerType: 'range',
        format: 'YYYY-MM-DD',
      },
      {
        key: 'cover',
        title: '上传封面',
        type: 'upload',
        uploadAction: '/api/upload',
        limit: 1,
        fieldProps: {
          listType: 'picture-card',
          imagePreview: true,
        },
        extra: '示例,后续需要讲他封装为单独的组件,图片上传oss',
      },
      {
        key: 'customField',
        title: '自定义组件',
        type: 'custom',
        required: false,
        render: ({ value, setFieldValue, disabled }) => {
          return (
            <div className="flex flex-wrap items-center gap-2">
              <Tag
                checkable
                checked={value === 'A'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'A');
                }}
              >
                方案 A
              </Tag>
              <Tag
                checkable
                checked={value === 'B'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'B');
                }}
              >
                方案 B
              </Tag>
              <Tag
                checkable
                checked={value === 'C'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'C');
                }}
              >
                方案 C
              </Tag>

              <Input
                style={{ width: 220 }}
                placeholder="也可以输入自定义值"
                value={typeof value === 'string' ? value : ''}
                disabled={disabled}
                onChange={(nextValue) => {
                  setFieldValue('customField', nextValue);
                }}
              />
            </div>
          );
        },
        extra: '这里演示 custom 自定义渲染能力',
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入详细说明',
        rows: 5,
        colSpan: 2,
        extra: '最下面一行跨整行显示,用于填写较长说明内容',
      },
    ];
  }, []);

  return (
    <div className="p-6">
      <Space size="large">
        <Button type="primary" onClick={() => setSingleOpen(true)}>
          打开一列布局弹窗
        </Button>

        <Button type="primary" status="success" onClick={() => setDoubleOpen(true)}>
          打开两列布局弹窗
        </Button>

        <Button type="primary" status="warning" onClick={() => setCustomOpen(true)}>
          打开有自定义的布局弹窗
        </Button>
      </Space>

      <FormEditModal
        open={singleOpen}
        title="新增信息(一列布局)"
        width={460}
        formRef={singleColFormRef}
        confirmLoading={singleSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setSingleOpen}
        onOk={async (values) => {
          setSingleSubmitLoading(true);
          try {
            console.log('一列布局提交数据:', values);
            Message.success('一列布局提交成功');
          } finally {
            setSingleSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={singleColFormRef}
          modelValue={{
            name: '',
            city: '',
            desc: '',
          }}
          formArr={singleColumnFormArr}
          layout="plain"
          direction="horizontal"
          columns={1}
          title="基础信息"
          description="当前示例为一行一列布局,适合字段较少或弹窗较窄的场景"
        />
      </FormEditModal>

      <FormEditModal
        open={doubleOpen}
        title="新增信息(两列布局)"
        width={720}
        formRef={doubleColFormRef}
        confirmLoading={doubleSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setDoubleOpen}
        onOk={async (values) => {
          setDoubleSubmitLoading(true);
          try {
            console.log('两列布局提交数据:', values);
            Message.success('两列布局提交成功');
          } finally {
            setDoubleSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={doubleColFormRef}
          modelValue={{
            name: '',
            city: '',
            type: 'personal',
            tags: [],
            desc: '',
          }}
          formArr={doubleColumnFormArr}
          layout="plain"
          direction="horizontal"
          columns={2}
          title="基础信息"
          description="当前示例为一行两列布局,长文本字段可通过 colSpan: 2 跨整行显示"
        />
      </FormEditModal>

      <FormEditModal
        open={customOpen}
        title="新增信息(两列 + 全组件示例)"
        width={920}
        formRef={customFormRef}
        confirmLoading={customSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setCustomOpen}
        onOk={async (values) => {
          setCustomSubmitLoading(true);
          try {
            console.log('自定义两列布局提交数据:', values);
            Message.success('自定义布局提交成功');
          } finally {
            setCustomSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={customFormRef}
          modelValue={{
            projectName: '',
            status: 1,
            city: '',
            identityType: 'personal',
            tags: [],
            region: [],
            publishDate: '',
            timeRange: [],
            cover: [],
            customField: 'A',
            desc: '',
          }}
          formArr={customFormArr}
          layout="plain"
          columns={2}
          title="完整表单示例"
          description="当前示例为两列布局,演示了 input、select、radioGroup、checkboxGroup、switch、upload、cascader、datePicker、custom、textarea 等所有封装能力"
        />
      </FormEditModal>
    </div>
  );
}

3. 方案为什么这么设计,解决了什么问题

这套方案之所以采用“配置驱动 + ref 驱动 + 弹窗容器分离”的方式,是因为后台项目里的表单高度重复,但每个业务页又会有细小差异。如果每次都手写 Form.Item,不仅重复,而且难以统一。

• 解决重复开发问题:大部分新增/编辑/查看表单只需要配置 formArr,不需要从头写 UI。

• 解决行为不统一问题:校验、重置、关闭、查看态禁用、标题与说明展示都统一收口。

• 解决字段扩展问题:新增 switch、upload、cascader、datePicker、custom 后,可以覆盖更多真实业务场景。

• 解决回填控制问题:通过 syncKey 控制何时重新 patch 表单,避免编辑态误覆盖。

• 解决布局不灵活问题:通过 direction 和 columns 拆开“整体表单方向”和“字段网格布局”两个维度。

4. 方案的优点、改进点

4.1 优点

• 抽象清晰:类型、工具函数、表单、弹窗分层明确。

• 字段能力完整:覆盖了常见后台表单大部分控件。

• 配置统一:字段描述、布局控制、默认值、规则、扩展属性集中在一处。

• 对业务友好:父页面主要关心配置和 onOk 保存逻辑,不必处理表单底层细节。

• 可扩展性强:custom 字段为特殊业务组件提供了稳定扩展入口。

5. 关键语法解释

5.1 forwardRef

让函数组件可以接收 ref。当前 FormEdit 和 FormEditModal 都需要向父组件暴露方法,因此必须使用它。

5.2 useImperativeHandle

自定义 ref.current 上暴露的内容。这里暴露的是 validate、getValues、resetFields、open、close 等方法,而不是整个内部实例。

5.3 useMemo

缓存计算结果。当前主要用于 initialValues 和 visibleFormItems,减少重复计算并让依赖更明确。

5.4 useEffect

在数据变化时同步副作用。当前主要用于 modelValue / syncKey 变化时重新 patch 表单。

5.5 useRef

跨 render 持久保存引用。这里用于 mountedRef、lastPatchedKeyRef,也用于 formRef / modalRef。

5.6 联合类型

例如 direction?: 'horizontal' | 'vertical'。这种写法能把取值范围限制在有限集合内,减少误传。

6. 使用示例

6.1 基础单列表单

<FormEdit ref={formRef}
  formArr={singleColumnFormArr}
  layout="plain"
  direction="horizontal"
  columns={1}
/>

适用于字段较少、小弹窗场景。

6.2 两列表单 + 跨行 textarea

{
  key: 'desc',
  title: '说明',
  type: 'textarea',
  rows: 5,
  colSpan: 2,
}

适用于两列布局中某些长文本字段需要独占一行的场景。

6.3 switch / cascader / datePicker / upload

[
  { key: 'status', title: '状态', type: 'switch', checkedValue: 1, uncheckedValue: 0 },
  { key: 'region', title: '地区', type: 'cascader', options: regionOptions },
  { key: 'publishDate', title: '发布日期', type: 'datePicker', datePickerType: 'date' },
  { key: 'cover', title: '封面', type: 'upload', uploadAction: '/api/upload', limit: 1 },
]

6.4 custom 字段

{
  key: 'customField',
  title: '自定义组件',
  type: 'custom',
  render: ({ value, setFieldValue }) => (
    <Input
      value={typeof value === 'string' ? value : ''}
      onChange={(nextValue) => setFieldValue('customField', nextValue)}
    />
  ),
}

当内置字段类型不够用时,可以通过 custom 直接接入业务特有组件。

6.5 弹窗表单

<FormEditModal
  open={open}
  title="新增信息"
  formRef={formRef}
  onOpenChange={setOpen}
  onOk={async (values) => {
    console.log(values);
  }}
  <FormEdit
    ref={formRef}
    formArr={formArr}
    layout="plain"
    direction="horizontal"
    columns={2}
  />
</FormEditModal>

这是最推荐的项目用法:弹窗负责交互流程,FormEdit 负责表单本体。

7. 结论

当前这套 FormEdit 结构是一套面向项目复用的表单基础设施。它通过类型契约、配置化字段、布局解耦、规则适配、初始值归一化和弹窗容器分离,把后台系统中最常见的新增/编辑/查看型表单进行了有效抽象。

❌
❌