基于 Ant Design 的JSON配置表单组件设计与实现

671 阅读7分钟

在后台系统的日常开发中,form表单是我们最常用的组件之一,不同的业务模块需求有不同的信息表单。一个复杂的表单往往由十数个不同的表单项组成,逻辑重复复杂,代码重复率也很高。因此设计一个能够通过json配置生成对应表单的组件,想必可以提升开发的体验。

需求分析

DynamicForm组件的设计需求大概可以分为以下几点

  1. 配置化驱动:通过 JSON 配置生成表单
  2. 丰富的表单项类型:支持各种常见的表单控件
  3. 灵活的布局系统:支持多列布局和自适应
  4. 强大的联动能力:支持表单项之间的依赖关系
  5. 易扩展:支持自定义渲染和事件处理
  6. 易操作:开发人员可以非常方便的使用

组件设计

JSON配置

  • layout:布局配置
    • cols:每行显示的表单数量,值为1、2、3、4
    • fullItems:独占整行的表单项,接受一个数组,值为独占整行的表单项name
  • initialValues:初始值,为整个表单赋初始值
  • items:表单配置项数组
    • formType:表单项类型
    • name:字段名
    • label:标签文本
    • rules:校验规则
    • visible:可见性
    • props:组件属性
    • dependencies:动态依赖配置
    • events:事件处理器

在表单项的配置中,除了常规的formItem配置和组件内的props属性配置外,重点是dependencies和events两个参数。dependencies提供了表单项之间的联动功能,events中可以写事件处理函数,方便用户进行事件监听

整个组件采用分层设计,进行职责分离

UI层

  • dynamicForm:负责状态管理和对外接口
  • FormContent:负责表单布局和表单项组织
  • FormItem:负责具体表单项的渲染

逻辑层

  • useDynamicForm:负责表单的核心逻辑
  • useFormDependencies:负责表单项动态的依赖逻辑

组件实现

DynamicForm作为组件的入口,提供组件的对外接口

可以通过getFormInstance()来获取form实例

let formInstance: DynamicFormMethods | null = null;
export const getFormInstance = () => formInstance;

const DynamicForm: React.FC<DynamicFormProps> = ({ config: rawConfig, onMount, onChange, onValuesChange, onSubmit, onValidateFailed, children }) => {
  const config = useMemo(() => {
    const parsedConfig = typeof rawConfig === 'string' ? JSON.parse(rawConfig) : rawConfig;
    const errors = validateConfig(parsedConfig);
    if (errors.length > 0) {
      console.error('表单配置错误:', errors);
      return processConfig({ items: [] });
    }
    return processConfig(parsedConfig);
  }, [rawConfig]);

  const [form, methods, items] = useDynamicForm(config);
  formInstance = methods;

  const handleDependencies = useFormDependencies(config, methods);

  const handleValuesChange = useCallback(
    (changedValues: Record<string, any>, allValues: Record<string, any>) => {
      console.log('表单值变化:', changedValues);
      handleDependencies(changedValues);
      onValuesChange?.(changedValues, allValues);
      onChange?.(allValues);
    },
    [handleDependencies, onChange, onValuesChange],
  );

  useEffect(() => {
    onMount?.(methods);
  }, [methods, onMount]);

  return (
    <Form form={form} initialValues={config.initialValues} onFinish={onSubmit} onFinishFailed={onValidateFailed} onValuesChange={handleValuesChange}>
      <FormContent config={{ ...config, items }} />
      {children}
    </Form>
  );
};

export type { DynamicFormMethods, FormConfig };
export default DynamicForm;

FormContent 负责表单层面的UI渲染工作

在这里提供了一个独占整行的设计,方便用户使某项表单项或自定义表单项独占整行(后续示例中有展示)

interface FormContentProps {
  config: FormConfig;
}

interface GroupedItems {
  normalItems: FormItemConfig[];
  fullWidthItems: FormItemConfig[];
}

const FormContent: React.FC<FormContentProps> = React.memo(({ config }) => {
  const { items = [], layout = {} } = config;
  const { cols = 3, fullItems = [], labelAlign = 'right' } = layout;
  const form = Form.useFormInstance();

  // 计算栅格布局参数
  const { colSpan, rowGutter } = useMemo(
    () => ({
      colSpan: 24 / cols,
      rowGutter: { xs: 8, sm: 16, md: 24 },
    }),
    [cols],
  );

  // 分组表单项并过滤掉不可见的项
  const { normalItems, fullWidthItems } = useMemo(() => {
    return items.reduce<GroupedItems>(
      (acc, item) => {
        // 如果项目不可见,则不添加到布局中
        if (item.visible === false) {
          return acc;
        }

        if (fullItems.includes(item.name)) {
          acc.fullWidthItems.push(item);
        } else {
          acc.normalItems.push(item);
        }
        return acc;
      },
      { normalItems: [], fullWidthItems: [] },
    );
  }, [items, fullItems]);

  return (
    <div>
      {/* 普通宽度表单项 */}
      <Row gutter={rowGutter}>
        {normalItems.map((item) => (
          <Col key={item.name} span={colSpan}>
            <FormItem {...item} labelAlign={labelAlign} form={form} />
          </Col>
        ))}
      </Row>

      {/* 全宽表单项 */}
      {fullWidthItems.length > 0 && (
        <Row className={styles.fullItemRow}>
          {fullWidthItems.map((item) => (
            <Col key={item.name} span={24} className={styles.fullItemCol}>
              <FormItem {...item} labelAlign={labelAlign} wrapperCol={{ span: 20 }} form={form} />
            </Col>
          ))}
        </Row>
      )}
    </div>
  );
});

FormContent.displayName = 'FormContent';

export default FormContent;

FormItem负责对具体的表单项进行渲染

日常开发中的常规表单项,都进行了兼容,另外提供了三种特殊的表单项

  • space:一个空占位项,不进行数据处理
  • hidden:一个隐藏不显示的项,但会进行相关的数据处理
  • render:用户自定义表单项,可以自定义渲染,允许用户决定是否参与数据处理
// 组件渲染函数映射
const FormComponentMap: Record<FormType, (props: any) => React.ReactNode> = {
  input: (props) => <Input {...DEFAULT_COMPONENT_PROPS.input} {...props} />,
  textarea: (props) => <Input.TextArea {...DEFAULT_COMPONENT_PROPS.textarea} {...props} />,
  select: (props) => <Select {...DEFAULT_COMPONENT_PROPS.select} {...props} />,
  radio: ({ options, ...rest }) => (
    <Radio.Group {...DEFAULT_COMPONENT_PROPS.radio} {...rest}>
      {options?.map((opt) => (
        <Radio key={opt.value} value={opt.value}>
          {opt.label}
        </Radio>
      ))}
    </Radio.Group>
  ),
  checkbox: ({ options, ...rest }) => <Checkbox.Group {...DEFAULT_COMPONENT_PROPS.checkbox} options={options} {...rest} />,
  switch: (props) => <Switch {...DEFAULT_COMPONENT_PROPS.switch} {...props} />,
  datePicker: (props) => <DatePicker {...DEFAULT_COMPONENT_PROPS.datePicker} {...props} />,
  rangePicker: (props) => <RangePicker {...DEFAULT_COMPONENT_PROPS.rangePicker} {...props} />,
  cascader: (props) => <Cascader {...DEFAULT_COMPONENT_PROPS.cascader} {...props} />,
  number: (props) => <InputNumber {...DEFAULT_COMPONENT_PROPS.number} {...props} />,
  space: () => null,
  hidden: () => null,
  render: ({ render, form }) => (render ? render(form) : null),
};

在FormItem中添加了事件处理器,方便用户对事件进行监听

const FormItem: React.FC<ExtendedFormItemProps> = React.memo(
  ({ formType, name, label, rules, visible = true, props = {}, labelAlign = 'right', events, form }) => {
    const Component = FormComponentMap[formType];

    if (!Component || !visible) return null;

    const mergedProps = useMemo(() => {
      const defaultProps = DEFAULT_COMPONENT_PROPS[formType] || {};
      const placeholder = props.placeholder || '';

      // 处理事件处理器
      const eventHandlers: EventHandlers = {};

      if (events) {
        Object.entries(events).forEach(([eventName, handler]) => {
          eventHandlers[eventName] = (...args: any[]) => {
            if (typeof handler === 'function') {
              handler(...args);
            }
            if (typeof props[eventName] === 'function') {
              (props[eventName] as EventHandler)(...args);
            }
          };
        });
      }

      return {
        ...defaultProps,
        ...props,
        ...eventHandlers,
        placeholder,
      };
    }, [formType, label, props, events]);

    // 优化表单项样式
    const formItemStyle = useMemo(
      () => ({
        marginBottom: 24,
        ...(props.style || {}),
      }),
      [props.style],
    );

    // space 类型只渲染一个空的 Form.Item
    if (formType === 'space') {
      return <Form.Item />;
    }

    // hidden 类型只渲染一个隐藏的 Form.Item
    if (formType === 'hidden') {
      return (
        <Form.Item name={name} hidden>
          <Input type="hidden" />
        </Form.Item>
      );
    }

    // render 类型的特殊处理
    if (formType === 'render') {
      const { render, collectData = true } = props;

      if (!render) return null;

      // 如果不需要收集数据,直接渲染
      if (!collectData) {
        return render(form);
      }

      // 需要收集数据时,包装在 Form.Item 中
      return (
        <Form.Item name={name} label={label} rules={rules} labelCol={{ flex: '120px' }} wrapperCol={{ flex: 'auto' }} labelAlign={labelAlign}>
          {render(form)}
        </Form.Item>
      );
    }

    // 获取组件渲染函数
    const renderComponent = FormComponentMap[formType];
    if (!renderComponent) return null;

    return (
      <Form.Item
        name={name}
        label={label}
        rules={rules}
        labelCol={{ flex: '120px' }}
        wrapperCol={{ flex: 'auto' }}
        labelAlign={labelAlign}
        style={formItemStyle}
        required={rules?.some((rule) => 'required' in rule && rule.required)}
      >
        {renderComponent(mergedProps)}
      </Form.Item>
    );
  },
);

FormItem.displayName = 'FormItem';

export default FormItem;

UI方面的处理基本结束,接下来是核心逻辑

useDynamicForm为组件提供了操作接口,分为数据操作和UI操作。数据方面操作依旧沿用了form本身的方法。UI方面则提供了新增表单项、更新表单项、删除表单项的方法,方便用户对表单进行动态操作

export const useDynamicForm = (config: FormConfig): [any, DynamicFormMethods, FormItemConfig[]] => {
  const [form] = Form.useForm();
  const [state, setState] = useState<FormState>({
    items: config.items,
    itemStates: {},
  });

  // 缓存初始配置
  const initialConfigRef = useRef<FormConfig>(config);

  /**
   * 重置表单状态到初始配置
   */
  const resetFormState = useCallback(() => {
    setState({
      items: initialConfigRef.current.items,
      itemStates: {},
    });
  }, []);

  /**
   * 更新表单项配置
   * @param name 表单项名称
   * @param config 要更新的表单项配置
   */
  const updateFormItem = (name: string, config: Partial<FormItemConfig>) => {
    setState((prev) => {
      const index = prev.items.findIndex((item) => item.name === name);
      if (index === -1) return prev;

      const newItems = [...prev.items];
      newItems[index] = {
        ...newItems[index],
        ...config,
        props: {
          ...newItems[index].props,
          ...config.props,
        },
      };

      // 如果设置为隐藏,自动清空字段值
      if (config.visible === false) {
        form.setFieldValue(name, undefined);
      }

      return {
        ...prev,
        items: newItems,
      };
    });
  };

  /**
   * 添加新的表单项
   * @param itemConfig 新表单项的配置
   * @param afterItemName 插入位置的参考表单项名称,不指定则添加到末尾
   */
  const addFormItem = useCallback((itemConfig: FormItemConfig, afterItemName?: string) => {
    setState((prev) => {
      if (prev.items.some((item) => item.name === itemConfig.name)) {
        return prev;
      }

      const newItems = [...prev.items];
      const insertIndex = afterItemName ? newItems.findIndex((item) => item.name === afterItemName) + 1 : newItems.length;

      newItems.splice(insertIndex, 0, itemConfig);
      return { ...prev, items: newItems };
    });
  }, []);

  /**
   * 移除表单项
   * @param name 要移除的表单项名称
   */
  const removeFormItem = useCallback((name: string) => {
    setState((prev) => ({
      ...prev,
      items: prev.items.filter((item) => item.name !== name),
    }));
  }, []);

  /**
   * 重置表单
   * @param names 要重置的字段名列表,不传则重置所有字段
   */
  const resetFields = useCallback(
    (names?: string[]) => {
      form.resetFields(names);
      if (!names) {
        resetFormState();
      }
    },
    [form, resetFormState],
  );

  // 导出方法
  const methods: DynamicFormMethods = {
    updateFormItem,
    addFormItem,
    removeFormItem,
    setFieldValue: form.setFieldValue,
    setFieldsValue: form.setFieldsValue,
    getFieldValue: form.getFieldValue,
    getFieldsValue: form.getFieldsValue,
    validateFields: form.validateFields,
    resetFields,
  };

  // 更新初始配置的缓存
  useEffect(() => {
    initialConfigRef.current = config;
  }, [config]);

  /**
   * 处理表单项的可见性
   * 根据 itemStates 中的状态更新表单项的可见性
   */
  const visibleItems = useMemo(() => {
    return state.items.map((item) => ({
      ...item,
      visible: state.itemStates[item.name]?.visible ?? item.visible,
    }));
  }, [state.items, state.itemStates]);

  return [form, methods, visibleItems];
};

useFormDependencies 负责表单项之间的动态依赖逻辑处理

export const useFormDependencies = (config: FormConfig, methods: DynamicFormMethods) => {
  const currentFieldRef = useRef<string>();
  const processingRef = useRef<boolean>(false);

  return useCallback(
    (changedValues: Record<string, any>) => {
      // 防止依赖处理过程中的循环调用
      if (processingRef.current) {
        return;
      }

      try {
        processingRef.current = true;
        const changedFields = Object.keys(changedValues);

        // 按照表单项顺序处理依赖
        for (const item of config.items) {
          if (item.dependencies?.fields && item.dependencies.fields.some((field) => changedFields.includes(field))) {
            currentFieldRef.current = item.name;

            // 包装 methods,处理 addFormItem 的默认行为
            const wrappedMethods = {
              ...methods,
              addFormItem: (itemConfig, afterItemName) => {
                methods.addFormItem(itemConfig, afterItemName || currentFieldRef.current);
              },
            };

            // 执行依赖处理器
            item.dependencies.handler(methods.getFieldsValue(), wrappedMethods);
          }
        }
      } catch (error) {
        console.error('处理表单依赖时发生错误:', error);
      } finally {
        processingRef.current = false;
        currentFieldRef.current = undefined;
      }
    },
    [config.items, methods],
  );
};

一些工具函数

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

// 验证表单配置
export const validateConfig = (config: FormConfig): string[] => {
  const errors: string[] = [];
  const names = new Set<string>();

  if (!Array.isArray(config.items)) {
    errors.push('配置项 items 必须是数组');
    return errors;
  }

  config.items.forEach((item, index) => {
    // space 类型只需要 name 和 formType
    if (item.formType === 'space') {
    //   if (!item.name) {
    //     errors.push(`第 ${index + 1} 项缺少 name 字段`);
    //   }
      return;
    }

    // 检查必填字段
    if (!item.name) {
      errors.push(`第 ${index + 1} 项缺少 name 字段`);
    }
    if (!item.formType) {
      errors.push(`第 ${index + 1} 项缺少 formType 字段`);
    }
    // if (!item.label) {
    //   errors.push(`第 ${index + 1} 项缺少 label 字段`);
    // }

    // 检查字段名重复
    if (names.has(item.name)) {
      errors.push(`字段名 ${item.name} 重复`);
    }
    names.add(item.name);

    // 检查依赖项
    if (item.dependencies) {
      const { fields, handler } = item.dependencies;
      if (!Array.isArray(fields)) {
        errors.push(`字段 ${item.name} 的依赖项 fields 必须是数组`);
      }
      if (typeof handler !== 'function') {
        errors.push(`字段 ${item.name} 的依赖项 handler 必须是函数`);
      }
    }
  });

  return errors;
};

// 处理表单配置的默认值
export const processConfig = (config: FormConfig): FormConfig => {
  const { items = [], layout = {}, initialValues = {} } = config;

  // 处理布局默认值
  const processedLayout = {
    column: 3,
    labelCol: 8,
    wrapperCol: 16,
    fullRow: [],
    ...layout,
  };

  // 处理表单项默认值
  const processedItems = items.map((item): FormItemConfig => {
    const { formType, props = {}, visible = true, rules = [] } = item;

    // space 类型不需要处理 placeholder
    if (formType !== 'space' && !props.placeholder && ['input', 'select', 'datePicker', 'cascader'].includes(formType)) {
      props.placeholder = `请${formType === 'input' ? '输入' : '选择'}${item.label}`;
    }

    return {
      ...item,
      props,
      visible,
      rules,
    };
  });

  return {
    initialValues,
    layout: processedLayout,
    items: processedItems,
  };
};

// 获取依赖关系图
export const getDependencyGraph = (items: FormItemConfig[]) => {
  const graph = new Map<string, Set<string>>();

  items.forEach((item) => {
    if (item.dependencies?.fields) {
      item.dependencies.fields.forEach((field) => {
        if (!graph.has(field)) {
          graph.set(field, new Set());
        }
        graph.get(field)?.add(item.name);
      });
    }
  });

  return graph;
};

使用示例

主要展示dependencies部分的使用

layout: {
    cols: 3,
    fullItems: ['submit'],
},
items:[
  {
      formType: 'select',
      name: 'subject',
      label: '主教学科',
      rules: [{ required: true, message: '请选择主教学科' }],
      props: {
        options: SUBJECT_OPTIONS,
      },
      dependencies: {
        fields: ['subject'],
        handler: (values, form) => {
          const subject = values.subject;
          if (subject) {
            // 获取该学科对应的课程类型
            const courseTypes = SUBJECT_COURSE_MAP[subject] || [];
            // 更新任教课程类型的选项和值
            form.setFieldValue('courseTypes', courseTypes);
          } else {
            // 重置为默认状态
            form.setFieldValue('courseTypes', undefined);
          }
        },
      },
    },
    // 根据上一个主教课程自动选择课程类型
      {
        formType: 'select',
        name: 'courseTypes',
        label: '任教课程类型',
        rules: [{ required: true, message: '请选择任教课程类型' }],
        props: {
          disabled: true,
          placeholder: '请选择任教课程',
          options: COURSE_TYPE_OPTIONS,
        },
      },
      // ... 其他配置项
      {
        formType: 'render',
        name: 'submit',
        props: {
          collectData: false,
          render: (form) => (
            <div style={{ textAlign: 'right', marginTop: 24 }}>
              <Space>
                <Button onClick={() => form.resetFields()}>重置</Button>
                <Button type="primary" onClick={() => form.validateFields()}>
                  提交
                </Button>
              </Space>
            </div>
          ),
        },
      },
]      

layout配置项中,通过设置submit表单项为独占整行,实现重置、提交按钮在表单右下方

这个配置主要是为了使该组件不仅在信息新增、编辑方面使用,也可以在筛选搜索区域使用,更方便的进行布局控制。

测试组件使用时的实现效果如下:

image.png

后续优化

目前组件还在测试阶段,未来将着重于对组件的功能扩展方面做进一步的开发和优化

如有问题还请各位大佬批评指正