React + Antd+TS 动态表单容器组件技术解析与实现

概述

在现代前端应用中,表单是用户交互的核心部分。本文将深入分析一个基于 React 和 Ant Design 的高级动态表单容器组件,它提供了强大的可配置性、灵活的布局选项和丰富的功能扩展能力。

组件核心特性

1. 高度可配置的表单结构

javascript 复制代码
interface FormContainerProps {
  formData?: FormValues;          // 表单初始数据
  formList?: FormItem[];          // 表单配置项数组
  canCollapse?: boolean;          // 是否可折叠
  labelWidth?: string | number;   // 标签宽度
  clearable?: boolean;            // 是否可清空
  horizontal?: boolean;           // 是否水平布局
  defaultShow?: number;           // 默认显示表单项数量
  onReset?: (data: FormValues) => void;      // 重置回调
  onSearch?: (values: FormValues) => void;   // 搜索回调
  // ... 其他配置项
}

2. 多样化的表单项类型支持

组件支持多种表单项类型,包括:

  • 文本输入框 (input)

  • 选择器 (select)

  • 级联选择器 (cascader)

  • 日期范围选择器 (daterange)

  • 数值范围输入 (range)

  • 自定义插槽 (slot)

实现细节解析

智能标签宽度计算

javascript 复制代码
const computedLabelWidth = useMemo(() => {
  if (labelWidth) return labelWidth;
  
  if (!formList.length) return '100px';
  
  // 根据最长标签文本自动计算合适宽度
  const maxLength = Math.max(...formList.map(item => item.label?.length || 0));
  if (maxLength <= 4) return '80px';
  if (maxLength <= 6) return '110px';
  if (maxLength < 10) return '120px';
  return '100px';
}, [formList, labelWidth]);

动态表单渲染机制

javascript 复制代码
const renderFormItem = useCallback((item: FormItem) => {
  const commonProps = {
    placeholder: item.placeholder,
    allowClear: clearable || item.clearable,
    style: { width: item.width || 240 },
    disabled: disabled || item.disabled,
    'aria-label': item.label
  };

  switch (item.type) {
    case 'input':
      return <Input {...commonProps} />;
    case 'select':
      return (
        <Select
          {...commonProps}
          mode={item.multiple ? 'multiple' : undefined}
          onChange={(value) => handleSelectChange(value, item)}
        >
          {/* 选项渲染 */}
        </Select>
      );
    // 其他类型处理...
    case 'slot':
      // 插槽机制实现自定义内容
      return Children.toArray(children).find(
        (child): child is ReactElement => 
          isValidElement(child) && child.props?.slot === `${item.prop}_slot`
      );
  }
}, [dependencies]);

折叠功能实现

javascript 复制代码
// 折叠状态管理
const [isCollapse, setIsCollapse] = React.useState(false);

// 折叠样式计算
const collapseStyle = useMemo(() => {
  if (isCollapse || !canCollapse) return {};
  return {
    height: `${48 * Math.max(1, defaultShow)}px`,
    overflow: 'hidden'
  };
}, [isCollapse, defaultShow, canCollapse]);

// 折叠切换
const toggleCollapse = useCallback(() => {
  setIsCollapse(prev => !prev);
}, []);

表单实例暴露与回调处理

javascript 复制代码
// 暴露form实例给父组件
useImperativeHandle(ref, () => form, [form]);

// 表单值变化处理
const handleValuesChange = useCallback((changedValues: FormValues, allValues: FormValues) => {
  onFormDataChange?.(allValues);
}, [onFormDataChange]);

// 搜索提交
const handleSearch = useCallback(async () => {
  try {
    const values = await form.validateFields();
    onSearch?.(values);
  } catch (error) {
    console.error('Form validation failed:', error);
  }
}, [form, onSearch]);

使用示例

html 复制代码
import React, { useRef } from 'react';
import FormContainer from '/@/components/searchForm/index';
import { FormInstance } from 'antd';

const ExampleComponent: React.FC = () => {
  const formRef = useRef<FormInstance>(null);

  const formList = [
    {
      label: '姓名',
      prop: 'name',
      type: 'input',
      placeholder: '请输入姓名'
    },
    {
      label: '性别',
      prop: 'gender',
      type: 'select',
      options: [
        { label: '男', value: 'male' },
        { label: '女', value: 'female' }
      ]
    },
    {
      label: '日期范围',
      prop: 'dateRange',
      type: 'daterange'
    },
      {
    label: "责任人",
    type: "select",
    prop: "personId",
    placeholder: "请选择",
    options: [
    ],
    },
       {
    label: "部门",
    type: "input",
    prop: "organizeList",
    checkStrictly: true,
    placeholder: "级联多选",
    options: [],
    },
    {
    label: "标签",
    type: "select",
    prop: "userTagIdList",
    multiple: true,
    collapsetags: true,
    collapseTagsTooltip: true,
    placeholder: "请选择",
    options: [],
  },
  ];

  const handleSearch = (formData: any) => {
    console.log('查询参数:', formData);
  };

  const handleReset = (formData: any) => {
    console.log('重置表单:', formData);
  };

  return (
    <FormContainer
      ref={formRef}
      formList={formList}
      onSearch={handleSearch}
      onReset={handleReset}
    />
  );
};

export default ExampleComponent;

组件完整代码实现

html 复制代码
import React, { 
  useMemo, 
  useCallback,
  useImperativeHandle,
  forwardRef,
  ReactElement,
  cloneElement,
  isValidElement,
  Children
} from 'react';
import {
  Form,
  Input,
  Select,
  Cascader,
  DatePicker,
  Button,
  Space,
  FormInstance,
  FormProps
} from 'antd';
import { 
  UpOutlined, 
  DownOutlined 
} from '@ant-design/icons';
import { FormContainerProps, FormItem, FormValues } from './types';
import './index.css';

const { RangePicker } = DatePicker;
const { Option } = Select;

const FormContainer = forwardRef<FormInstance, FormContainerProps>((props, ref) => {
  const {
    formData = {},
    formList = [],
    canCollapse = true,
    labelWidth,
    clearable = false,
    horizontal = true,
    defaultShow = 1,
    onReset,
    onSearch,
    onSelectChange,
    onCascaderChange,
    onFormDataChange,
    children,
    loading = false,
    disabled = false
  } = props;

  const [form] = Form.useForm();
  const [isCollapse, setIsCollapse] = React.useState(false);

  // 暴露form实例给父组件
  useImperativeHandle(ref, () => form, [form]);

  // 计算标签宽度
  const computedLabelWidth = useMemo(() => {
    if (labelWidth) return labelWidth;
    
    if (!formList.length) return '100px';
    
    const maxLength = Math.max(...formList.map(item => item.label?.length || 0));
    if (maxLength <= 4) return '80px';
    if (maxLength <= 6) return '110px';
    if (maxLength < 10) return '120px';
    return '100px';
  }, [formList, labelWidth]);

  // 折叠样式
  const collapseStyle = useMemo(() => {
    if (isCollapse || !canCollapse) return {};
    return {
      height: `${48 * Math.max(1, defaultShow)}px`,
      overflow: 'hidden'
    };
  }, [isCollapse, defaultShow, canCollapse]);

  // 表单值变化处理
  const handleValuesChange = useCallback((changedValues: FormValues, allValues: FormValues) => {
    onFormDataChange?.(allValues);
  }, [onFormDataChange]);

  // 选择器变化事件
  const handleSelectChange = useCallback((value: unknown, item: FormItem) => {
    const currentValues = form.getFieldsValue();
    onSelectChange?.(item, currentValues);
  }, [form, onSelectChange]);

  // 级联选择变化事件
  const handleCascaderChange = useCallback((value: unknown, item: FormItem) => {
    const currentValues = form.getFieldsValue();
    onCascaderChange?.(item, currentValues);
  }, [form, onCascaderChange]);

  // 重置表单
  const handleReset = useCallback(() => {
    try {
      form.resetFields();
      const resetData = form.getFieldsValue();
      onReset?.(resetData);
    } catch (error) {
      console.error('Form reset failed:', error);
    }
  }, [form, onReset]);

  // 查询提交
  const handleSearch = useCallback(async () => {
    try {
      const values = await form.validateFields();
      onSearch?.(values);
    } catch (error) {
      console.error('Form validation failed:', error);
    }
  }, [form, onSearch]);

  // 切换折叠状态
  const toggleCollapse = useCallback(() => {
    setIsCollapse(prev => !prev);
  }, []);

  // 通用属性
  const getCommonProps = useCallback((item: FormItem) => ({
    placeholder: item.placeholder,
    allowClear: clearable || item.clearable,
    style: { width: item.width || 240 },
    disabled: disabled || item.disabled,
    'aria-label': item.label
  }), [clearable, disabled]);

  // 渲染表单项
  const renderFormItem = useCallback((item: FormItem) => {
    const commonProps = getCommonProps(item);

    switch (item.type) {
      case 'input':
        return (
          <Input
            {...commonProps}
            type={item.inputType || 'text'}
            maxLength={item.maxLength}
          />
        );

      case 'select':
        return (
          <Select
            {...commonProps}
            mode={item.multiple ? 'multiple' : undefined}
            maxTagCount={item.collapseTags ? 1 : undefined}
            showSearch={item.filterable}
            optionFilterProp="children"
            onChange={(value) => handleSelectChange(value, item)}
            notFoundContent={loading ? '加载中...' : '暂无数据'}
          >
            {item.options?.map((option, idx) => {
              const value = option.value ?? option.itemValue ?? option.id;
              const label = option.label ?? option.itemText;
              return (
                <Option key={`${value}-${idx}`} value={value}>
                  {label}
                </Option>
              );
            })}
          </Select>
        );

      case 'cascader':
        return (
          <Cascader
            {...commonProps}
            options={item.options || []}
            fieldNames={item.props}
            multiple={item.multiple}
            showArrow
            changeOnSelect={!item.showAllLevels}
            maxTagCount={item.collapseTags ? 1 : undefined}
            showSearch={item.filterable}
            onChange={(value) => handleCascaderChange(value, item)}
            notFoundContent={loading ? '加载中...' : '暂无数据'}
          />
        );

      case 'daterange':
        return (
          <RangePicker
            {...commonProps}
            format={item.format || 'YYYY-MM-DD'}
            placeholder={item.placeholder ? [item.placeholder, item.placeholder] : ['开始时间', '结束时间']}
          />
        );

      case 'range':
        return (
          <Space>
            <Input
              {...commonProps}
              style={{ width: item.width || 110 }}
              type={item.inputType || 'text'}
              min={item.min}
              addonAfter={item.unit}
              aria-label={`${item.label}最小值`}
            />
            <span aria-hidden="true">-</span>
            <Input
              {...commonProps}
              style={{ width: item.width || 110 }}
              type={item.inputType || 'text'}
              min={item.min}
              addonAfter={item.unit}
              aria-label={`${item.label}最大值`}
            />
          </Space>
        );

      case 'slot':
        const slot = Children.toArray(children).find(
          (child): child is ReactElement => 
            isValidElement(child) && child.props?.slot === `${item.prop}_slot`
        );
        return slot ? cloneElement(slot, { 
          data: form.getFieldsValue(),
          disabled: disabled || item.disabled 
        }) : null;

      default:
        console.warn(`Unknown form item type: ${item.type}`);
        return null;
    }
  }, [getCommonProps, loading, children, form, handleSelectChange, handleCascaderChange]);

  // 表单配置
  const formLayout: FormProps = useMemo(() => ({
    layout: 'inline',
    labelAlign: 'right',
    labelWrap: true,
    style: collapseStyle,
    form,
    initialValues: formData,
    onValuesChange: handleValuesChange,
    disabled: disabled || loading
  }), [collapseStyle, form, formData, handleValuesChange, disabled, loading]);

  // 是否显示折叠按钮
  const shouldShowCollapseButton = useMemo(() => 
    canCollapse && formList.length > defaultShow, 
    [canCollapse, formList.length, defaultShow]
  );

  // 渲染的表单项列表
  const renderedFormItems = useMemo(() => 
    formList.map((item, index) => {
      if (!item.prop || !item.type) {
        console.warn(`Form item at index ${index} missing required prop or type`);
        return null;
      }
      
      return (
        <Form.Item
          key={`${item.prop}-${index}`}
          label={`${item.label || ''}:`}
          name={item.prop}
          rules={item.rules}
          labelCol={{ style: { width: computedLabelWidth } }}
        >
          {renderFormItem(item)}
        </Form.Item>
      );
    }), 
    [formList, computedLabelWidth, renderFormItem]
  );

  return (
    <div 
      className="search-form-container"
      role="search"
      aria-label="搜索表单"
    >
      <div className="search-form-layout">
        <div className="form-content">
          <Form {...formLayout}>
            {renderedFormItems}
          </Form>
        </div>
        
        <div className="form-actions">
          <Space>
            <Button 
              type="primary" 
              onClick={handleSearch}
              loading={loading}
              aria-label="搜索"
            >
              搜索
            </Button>
            <Button 
              onClick={handleReset}
              disabled={loading}
              aria-label="重置"
            >
              重置
            </Button>
            {shouldShowCollapseButton && (
              <Button 
                type="link" 
                onClick={toggleCollapse}
                icon={isCollapse ? <UpOutlined /> : <DownOutlined />}
                aria-label={isCollapse ? '收起' : '展开'}
                aria-expanded={isCollapse}
              >
                {isCollapse ? '收起' : '展开'}
              </Button>
            )}
          </Space>
        </div>
      </div>
      {children}
    </div>
  );
});

FormContainer.displayName = 'FormContainer';

export default FormContainer;
javascript 复制代码
import { Rule } from 'antd/es/form';
import { ReactNode } from 'react';

export type FormValues = Record<string, unknown>;

export interface OptionItem {
  label?: string;
  value?: string | number;
  itemText?: string;
  itemValue?: string | number;
  id?: string | number;
}

export interface FormItem {
  label: string;
  prop: string;
  type: 'input' | 'select' | 'cascader' | 'daterange' | 'range' | 'slot';
  placeholder?: string;
  width?: string | number;
  clearable?: boolean;
  disabled?: boolean;
  multiple?: boolean;
  collapseTags?: boolean;
  filterable?: boolean;
  options?: OptionItem[];
  props?: Record<string, string>;
  showAllLevels?: boolean;
  dateObj?: boolean;
  time?: string;
  format?: string;
  start?: string;
  end?: string;
  unit?: string;
  min?: number;
  maxLength?: number;
  inputType?: 'text' | 'number' | 'password' | 'email' | 'tel' | 'url';
  formatter?: (value: string) => string;
  rules?: Rule[];
}

export interface FormContainerProps {
  formData?: FormValues;
  formList: FormItem[];
  canCollapse?: boolean;
  labelWidth?: string;
  clearable?: boolean;
  horizontal?: boolean;
  defaultShow?: number;
  loading?: boolean;
  disabled?: boolean;
  onReset?: (form: FormValues) => void;
  onSearch?: (form: FormValues) => void;
  onSelectChange?: (item: FormItem, form: FormValues) => void;
  onCascaderChange?: (item: FormItem, form: FormValues) => void;
  onFormDataChange?: (form: FormValues) => void;
  children?: ReactNode;
}
css 复制代码
.search-form-container {
  width: 100%;
  border-bottom: 1px solid #ebeef5;
  margin-bottom: 24px;
  padding-bottom: 8px;
}

.search-form-layout {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
}

.form-content {
  flex: 1;
  min-width: 0;
}

.form-content .ant-form-item {
  display: inline-block;
  margin-right: 16px;
  margin-bottom: 16px;
  vertical-align: top;
}

.form-actions {
  flex-shrink: 0;
  padding-top: 4px;
}

@media (max-width: 768px) {
  .search-form-layout {
    flex-direction: column;
    align-items: stretch;
  }
  
  .form-content .ant-form-item {
    display: block;
    width: 100%;
    margin-right: 0;
  }
  
  .form-actions {
    align-self: flex-end;
  }
}

总结

这个动态表单容器组件展示了如何构建一个高度可配置、可扩展的表单解决方案。通过合理的组件设计、状态管理和性能优化,它能够满足大多数复杂表单场景的需求。开发者可以根据实际业务需求进一步扩展其功能,如表单验证规则、动态表单项、异步数据加载等。

这种组件化思维不仅提高了代码的复用性,也使得表单的维护和迭代变得更加简单高效。


希望这篇技术博客对您理解和实现高级表单组件有所帮助。如果您有任何问题或建议,欢迎在评论区留言讨论。