内容
前言
在日常的 React + Ant Design 项目开发中,你是否也遇到过这些痛点:
- 重复编写表单验证逻辑
- 手动处理各种表单组件的状态管理
- 缺乏统一的表单组件封装
- 表单样式和交互体验不一致
今天给大家分享一个我自研的 ProForm 组件封装,它基于 Ant Design 设计,提供了完整的表单解决方案,让你的表单开发效率提升 300%!
🎯 核心特性
1. 丰富的表单组件类型
支持 12+ 种常用的表单组件类型:
- 基础输入框(input)
- 邮箱输入框(email)
- 数字输入框(inputNumber)
- 日期选择器(date)
- 日期范围选择器(dateRange)
- 下拉选择器(select)
- 单选框组(radio)
- 复选框组(checkbox)
- 开关组件(switch)
- 树形选择器(treeSelect)
- 文件上传(upload)
- 自定义组件
2. 智能表单验证
内置完整的表单验证机制:
rules: [
{ required: true, message: "请输入用户名" },
{ type: "email", message: "请输入正确的邮箱格式" },
{ min: 6, message: "密码长度不能少于6位" }
]
3. 灵活的配置方式
通过配置对象快速生成表单,支持:
- 动态表单项
- 条件渲染
- 自定义验证规则
- 表单联动
4. 完整的生命周期管理
- 表单初始化
- 数据回填
- 提交处理
- 重置操作
- 模态框集成
🔧 技术实现亮点
1. TypeScript 类型安全
完整的类型定义,开发时享受智能提示:
interface FormItem {
name: string;
label: string;
type: FormItemType;
rules?: Rule[];
options?: Option[];
// ... 更多配置项
}
2. 组件引用管理
使用 useRef
和 forwardRef
实现组件间的通信:
const formRef = useRef<any>(null);
// 表单数据回填
formRef.current?.setFromFieldsValue({
username: editRecord?.username,
email: editRecord?.email
});
// 表单重置
formRef.current?.resetForm?.();
3. 状态管理优化
通过 useMemo
优化表单配置,避免不必要的重新渲染:
const formItems = useMemo<FormItem[]>(() => {
return [
// 表单项配置
];
}, [editRecord]);
4. 事件处理机制
支持各种表单事件:
onChange
: 值变化回调onFinish
: 表单提交onReset
: 表单重置- 自定义验证逻辑
🎯 实际项目应用
在我的项目中,ProForm 组件被广泛应用于:
- 用户管理表单
- 数据配置表单
- 系统设置表单
- 各种 CRUD 操作
使用前后对比:
- 之前:一个复杂表单需要 100+ 行代码
- 现在:同样的表单只需要 20+ 行配置代码
- 开发效率提升:300%+
- 代码维护性:显著提升
🚀 快速开始
引入组件
import { ProForm } from '@/components/ProForm';
配置表单项
const formItems = useMemo<FormItem[]>(() => {
return [
{
name: "username",
label: "用户名",
type: "input",
onChange: (e: any) => {
console.log(e.target.value);
},
rules: [{ required: true, message: "请输入用户名" }],
},
{
name: "email",
label: "邮箱",
type: "input",
rules: [{ required: true, message: "请输入邮箱" }],
},
{
name: "status",
label: "状态",
type: "select",
options: [
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
],
rules: [{ required: true, message: "请选择状态" }],
},
{
name: "createTime",
label: "注册时间",
type: "date",
format: "YYYY-MM-DD",
},
{
name: "createTime2",
label: "注册时间2",
type: "date",
isDateRange: true,
format: "YYYY-MM-DD",
rangeName: ["startDate", "endDate"],
// initialValue: [dayjs().subtract(12, 'month'), dayjs()],
},
{
name: "checkbox",
label: "复选框",
type: "checkbox",
options: [
{ label: "选项1", value: 1 },
{ label: "选项2", value: 2 },
{ label: "选项3", value: 3 },
],
},
{
name: "inputNumber",
label: "数字输入框",
type: "inputNumber",
},
{
name: "radio",
label: "单选框",
type: "radio",
options: [
{ label: "选项1", value: 1 },
{ label: "选项2", value: 2 },
{ label: "选项3", value: 3 },
],
},
{
name: "switch",
label: "开关",
type: "switch",
},
{
name: "treeSelect",
label: "树选择器",
type: "treeSelect",
treeCheckable: true,
onChange: (value: any, label: any, extra: any) => {
console.log(value, label, extra);
},
treeData: [
{
value: "1",
title: "选项1",
children: [{ value: "1-1", title: "选项1-1" }],
},
{
value: "2",
title: "选项2",
children: [{ value: "2-1", title: "选项2-1" }],
},
],
},
{
name: "upload",
label: "上传",
type: "upload",
onChange: (file) => {
console.log(file);
},
listType: "picture-card",
previewImage: true,
fileType: ["image/png", "image/jpg", "image/jpeg"],
maxFileSize: 2,
maxCount: 2,
},
];
}, [editRecord]);
渲染表单
<ProForm
formItems={formItems}
onFinish={onFinish}
ref={formRef}
/>
示例图片
💡 最佳实践
- 合理使用 useMemo:对于复杂的表单配置,使用 useMemo 避免重复计算
- 统一错误处理:在 onFinish 中统一处理表单提交错误
- 表单验证优化:合理设置验证规则,避免过度验证
- 性能优化:对于大型表单,考虑使用虚拟滚动或分步表单
🚀 未来规划
- 支持更多表单组件类型
- 添加表单设计器功能
- 支持拖拽排序
- 添加表单模板库
- 支持主题定制
📚 相关资源
🤝 开源计划
这个组件目前是我的个人项目,如果你觉得有用,欢迎:
- ⭐ Star 项目
- 提交 Issue
- 💡 提出建议
- 🔧 贡献代码
总结
ProForm 组件通过合理的抽象和封装,大大简化了表单开发的复杂度。它不仅提供了丰富的功能,还保持了良好的扩展性和可维护性。
如果你也在使用 Ant Design 开发表单,强烈推荐尝试这个组件。相信它会成为你表单开发的得力助手!
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享!有任何问题或建议,欢迎在评论区留言讨论。
标签: #React #Ant Design #TypeScript #组件封装 #表单开发 #前端工程化
最后附源码
ProForm的Index.tsx
import { forwardRef, memo, useImperativeHandle } from "react";
import { Form, Button, Row, Col } from "antd";
import type { BaseOptionType, DefaultOptionType } from "antd/es/select";
import { useProForm } from "./useFrom";
import type { FormInstance } from "antd/es/form";
import type { CheckboxOptionType, UploadFile, UploadProps, GetProp } from "antd";
import {
FromInput,
FromSelect,
FromDatePicker,
FromCheckbox,
FromInputNumber,
FromRadio,
FromSwitch,
FromTreeSelect,
FromUpload,
} from "./components/form-element";
import type { Dayjs } from "dayjs";
interface RuleConfig {
message?: string;
required?: boolean;
pattern?: RegExp;
type?: string; // 常见有 string |number |boolean |url | email
min?: number; // 必须设置 type:string 类型为字符串最小长度;number 类型时为最小值;array 类型时为数组最小长度
max?: number; // 必须设置 type:string 类型为字符串最大长度;number 类型时为最大值;array 类型时为数组最大长度
len?: number; // string 类型时为字符串长度;number 类型时为确定数字; array 类型时为数组长度
validator?: (rule: RuleConfig, value: any) => void;
}
export type FileType = Parameters<GetProp<UploadProps, "beforeUpload">>[0];
type Rule = RuleConfig | ((form: FormInstance) => RuleConfig) | any;
type TreeDataType = Array<{
value: string;
title: string;
children?: Array<{
value: string;
title: string;
children?: Array<{ value: string; title: string }>;
}>;
disabled?: boolean;
disableCheckbox?: boolean;
}>;
export interface ShowUploadListInterface<T = any> {
extra?: React.ReactNode | ((file: UploadFile<T>) => React.ReactNode);
showRemoveIcon?: boolean | ((file: UploadFile<T>) => boolean);
showPreviewIcon?: boolean | ((file: UploadFile<T>) => boolean);
showDownloadIcon?: boolean | ((file: UploadFile<T>) => boolean);
removeIcon?: React.ReactNode | ((file: UploadFile<T>) => React.ReactNode);
downloadIcon?: React.ReactNode | ((file: UploadFile<T>) => React.ReactNode);
previewIcon?: React.ReactNode | ((file: UploadFile<T>) => React.ReactNode);
}
export interface FormItem {
name: string; // 表单项名称
type:
| "input" //输入框
| "select" // 选择框
| "date" // 日期
| "checkbox" // 多选
| "inputNumber" // 数字输入
| "radio" // 单选
| "switch" // 开关
| "treeSelect" //树状选择
| "upload"; // 表单项类型
label?: string;
placeholder?: string; // 表单项提示
options?:
| (BaseOptionType | DefaultOptionType)[]
| undefined
| CheckboxOptionType<string>[]
| string[]
| number[]
| Array<CheckboxOptionType>; // 表单项选项
initialValue?: string | number | boolean | Dayjs[] | undefined; // 表单项初始值
value?: any; // 表单项值
rules?: Rule[]; // 表单项验证规则
render?: () => React.ReactNode; // 表单项渲染
span?: number; // 表单项列宽
picker?: "date" | "week" | "month" | "quarter" | "year"; // 日期选择器类型
format?: string; // 日期选择器格式
showTime?: boolean; // 日期选择器是否显示时间
isDateRange?: boolean; // 是否是联动选择时间
hidden?: boolean; // 表单项是否隐藏
disabled?: boolean; // 表单项是否禁用
readonly?: boolean; // 表单项是否只读
onChange?: (e: any, label?: any, extra?: any) => void; // 表单项变化回调
rangeName?: string[]; // 范围的key
allowClear?: boolean | { clearIcon: React.ReactNode }; // 表单项是否允许清空
inputType?: "input" | "textarea"; // 表单项类型
min?: number; // 最小值, 用于inputNumber
max?: number; // 最大值, 用于inputNumber
treeCheckable?: boolean; // 树选择显示 Checkbox
treeData?: TreeDataType;
action?: string;
uploadText?: string; // 上传按钮文本
defaultFileList?: UploadFile[]; // 上传列表
fileList?: UploadFile[];
listType?: "text" | "picture" | "picture-card"; // 上传列表类型
uplaodBtnRender?: () => React.ReactNode; // 自定义上传按钮渲染
previewImage?: boolean; // 是否展示预览
maxFileSize?: number; // 最大文件大小
fileType?: string[]; // 文件类型
maxCount?: number;
showUploadList?: ShowUploadListInterface; // 上传列表 是否展示文件列表, 可设为一个对象,用于单独设定 extra(5.20.0+), showPreviewIcon, showRemoveIcon, showDownloadIcon, removeIcon 和 downloadIcon
}
type LayoutType = {
labelCol?: { span: number };
wrapperCol?: { span: number };
layout?: "horizontal" | "vertical" | "inline";
};
interface ProFormProps<T> {
formItems: FormItem[]; // 表单项
saveBtnText?: string; // 保存按钮文本
cancelBtnText?: string; // 取消按钮文本
isShowCancelBtn?: boolean; // 是否显示取消按钮
onFinish?: (e: T) => void; // 表单提交
layout?: LayoutType; // 表单布局
closeModal?: () => void; // 关闭模态框
}
// 表单组件的 ref 类型
interface ProFormRef<T> {
formItemNew: FormItem[];
resetForm?: () => void;
setFromFieldsValue?: (values: T) => void;
}
// 自定义表单组件
const ProForm = memo(
forwardRef(<T extends object = any>(props: ProFormProps<T>, ref: React.ForwardedRef<ProFormRef<T>>) => {
// 暴露给父组件的属性
useImperativeHandle(ref, () => ({
formItemNew,
resetForm,
setFromFieldsValue,
}));
// 内部属性
const {
form,
defaultSpan,
onFinish,
closeModal,
formItemNew,
initialValues,
formLayout,
resetForm,
setFromFieldsValue,
} = useProForm({
...props,
});
const normFile = (e: any) => {
if (Array.isArray(e)) {
return e;
}
return e?.fileList;
};
return (
<div>
<Form form={form} initialValues={initialValues} onFinish={onFinish} {...formLayout}>
<Row gutter={16} className="w-full">
{formItemNew?.length &&
formItemNew?.map((item: FormItem) => {
const render = item?.render;
return (
<Col span={item?.span ?? defaultSpan} key={item.name}>
<Form.Item
name={item.name}
label={item?.label}
rules={item?.rules}
getValueFromEvent={item?.type === "upload" ? normFile : undefined}
valuePropName={item?.type === "upload" ? "fileList" : undefined}
>
{/* 输入框 */}
{item.type === "input" && (render ? render() : <FromInput {...item} />)}
{/* 下拉选择器 */}
{item.type === "select" && <FromSelect {...item} />}
{/* 日期选择器 */}
{item.type === "date" && <FromDatePicker {...item} />}
{/* 复选框 */}
{item.type === "checkbox" && <FromCheckbox {...item} />}
{/* 数字输入框 */}
{item.type === "inputNumber" && <FromInputNumber {...item} />}
{/* 单选框 */}
{item.type === "radio" && <FromRadio {...item} />}
{/* 开关 */}
{item.type === "switch" && <FromSwitch {...item} />}
{/* 树选择器 */}
{item.type === "treeSelect" && <FromTreeSelect {...item} />}
{/* 上传 */}
{item.type === "upload" && <FromUpload {...item} />}
</Form.Item>
</Col>
);
})}
{/* 保存按钮 */}
<Col span={24} className="flex justify-end">
<Form.Item>
<div className="flex gap-2 items-center justify-end">
<Button type="default" onClick={closeModal}>
{props?.cancelBtnText ?? "取消"}
</Button>
<Button type="primary" htmlType="submit">
{props?.saveBtnText ?? "确定"}
</Button>
</div>
</Form.Item>
</Col>
</Row>
</Form>
</div>
);
}),
) as <T extends object = any>(props: ProFormProps<T> & { ref?: React.Ref<ProFormRef<T>> }) => React.ReactElement;
export default ProForm;
ProForm的components/form-element.tsx
import {
Input,
Select,
DatePicker,
Checkbox,
InputNumber,
Radio,
Switch,
TreeSelect,
Upload,
Button,
message,
Image,
} from "antd";
import type { FormItem } from "../Index";
import type { CheckboxOptionType, UploadFile, UploadProps } from "antd";
import type { DefaultOptionType } from "antd/es/select";
import { useState } from "react";
import userStore from "@/store/userStore";
import { PlusOutlined, UploadOutlined } from "@ant-design/icons";
import type { FileType } from "../Index";
type CheckboxOption = string[] | number[] | Array<CheckboxOptionType>;
const { TextArea } = Input;
const { RangePicker } = DatePicker;
/**
* 要引入value这个属性不然值不会回填上去
*/
/**
* 表单项输入框
* @param props
* @returns
*/
export const FromInput = ({ ...props }: FormItem) => {
return props?.inputType === "textarea" ? (
<TextArea
disabled={props?.disabled}
allowClear={props?.allowClear ?? true}
placeholder={props?.placeholder ?? `请输入${props?.label}`}
onChange={props?.onChange}
value={props?.value}
/>
) : (
<Input
disabled={props?.disabled}
allowClear={props?.allowClear ?? true}
placeholder={props?.placeholder ?? `请输入${props?.label}`}
onChange={props?.onChange}
value={props?.value}
/>
);
};
/**
* 表单项选择器
* @param props
* @returns
*/
export const FromSelect = ({ ...props }: FormItem) => {
return (
<Select
disabled={props?.disabled}
options={props?.options as DefaultOptionType[]}
onChange={props?.onChange}
allowClear={props?.allowClear ?? true}
placeholder={props?.placeholder ?? `请选择${props?.label}`}
value={props?.value}
/>
);
};
/**
* 表单项日期选择器
* @param props
* @returns
*/
export const FromDatePicker = ({ ...props }: FormItem) => {
return props?.isDateRange ? (
<RangePicker
className="w-full"
picker={props?.picker}
format={props?.format}
showTime={props?.showTime}
onChange={props?.onChange}
disabled={props?.disabled}
value={props?.value}
allowClear={props?.allowClear ?? true}
/>
) : (
<DatePicker
className="w-full"
picker={props?.picker}
format={props?.format}
showTime={props?.showTime}
onChange={props?.onChange}
disabled={props?.disabled}
value={props?.value}
allowClear={props?.allowClear ?? true}
/>
);
};
/**
* 表单项复选框
* @param props
* @returns
*/
export const FromCheckbox = ({ ...props }: FormItem) => {
return (
<Checkbox.Group
className="w-full"
disabled={props?.disabled}
value={props?.value}
onChange={props?.onChange}
options={props?.options as CheckboxOptionType[]}
/>
);
};
/**
* 表单项输入框
* @param props
* @returns
*/
export const FromInputNumber = ({ ...props }: FormItem) => {
return (
<InputNumber
style={{ width: "100%" }}
disabled={props?.disabled}
min={props?.min}
max={props?.max}
value={props?.value}
onChange={props?.onChange}
/>
);
};
/**
* 表单项单选框
* @param props
* @returns
*/
export const FromRadio = ({ ...props }: FormItem) => {
return (
<Radio.Group
options={props?.options as CheckboxOption}
onChange={props?.onChange}
disabled={props?.disabled}
value={props?.value}
/>
);
};
/**
* 表单项开关
* @param props
* @returns
*/
export const FromSwitch = ({ ...props }: FormItem) => {
return <Switch disabled={props?.disabled} onChange={props?.onChange} value={props?.value} />;
};
/**
* 表单项树选择器
* @param props
* @returns
*/
export const FromTreeSelect = ({ ...props }: FormItem) => {
return (
<TreeSelect
style={{ width: "100%" }}
disabled={props?.disabled}
onChange={props?.onChange}
value={props?.value}
treeCheckable={props?.treeCheckable}
treeData={props?.treeData}
/>
);
};
/**
* 表单项上传
* @param props
* @returns
*/
export const FromUpload = ({ ...props }: FormItem) => {
const token = userStore.getState().userToken?.accessToken || "";
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState<string | undefined>(undefined);
// 预览
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj as FileType);
}
setPreviewImage(file.url || file.preview);
setPreviewOpen(true);
};
// 上传
const propsFile: UploadProps = {
beforeUpload: (file) => {
const maxFileSize = props?.maxFileSize;
const fileType = props?.fileType;
// 上传之前校验文件大小
if (maxFileSize) {
const fileSize = file.size / 1024 / 1024 < maxFileSize;
if (!fileSize) {
message.error(`文件大小不能超过${maxFileSize}MB`);
return fileSize || Upload.LIST_IGNORE;
}
}
// 上传之前校验文件类型
if (fileType) {
console.log(file.type);
const isFileType = fileType.includes(file.type);
if (!isFileType) {
message.error(`文件类型只支持${fileType.join(",")}`);
return isFileType || Upload.LIST_IGNORE;
}
}
},
action: props?.action ?? "https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload",
onChange: props?.onChange,
headers: {
token,
},
disabled: props?.disabled,
showUploadList: props?.showUploadList,
onPreview: props?.previewImage ? handlePreview : undefined,
defaultFileList: props?.defaultFileList,
listType: props?.listType,
fileList: props?.fileList,
maxCount: props?.maxCount,
};
const uploadText = props?.uploadText ?? "上传文件";
// 获取base64
const getBase64 = (file: FileType): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
// 上传按钮
const uploadButton =
props?.listType === "picture-card" ? (
<button style={{ border: 0, background: "none" }} type="button">
<PlusOutlined />
<div style={{ marginTop: 8 }}>{uploadText}</div>
</button>
) : (
<Button icon={<UploadOutlined />}>{uploadText}</Button>
);
// 是否显示上传按钮
const isShowUploadBtn =
propsFile.maxCount && propsFile?.fileList ? propsFile?.fileList?.length < propsFile.maxCount : true;
return (
<div>
<Upload {...propsFile}>
{isShowUploadBtn ? (props?.uplaodBtnRender ? props?.uplaodBtnRender() : uploadButton) : null}
</Upload>
{props?.previewImage && (
<Image
wrapperStyle={{ display: "none" }}
preview={{
visible: previewOpen,
onVisibleChange: (visible) => setPreviewOpen(visible),
afterOpenChange: (visible) => !visible && setPreviewImage(""),
}}
src={previewImage}
/>
)}
</div>
);
};
ProForm的自定义Hooks:useFrom.ts
import { Form } from "antd";
import type { FormItem } from "./Index";
import { useMemo } from "react";
import dayjs from "dayjs";
export const useProForm = ({ ...props }) => {
const [form] = Form.useForm();
const defaultSpan = 24;
// 表单布局
const layout = {
labelCol: { span: 4 },
wrapperCol: { span: 24 },
};
const formLayout = useMemo(() => {
return props?.formLayout ?? layout;
}, [props?.formLayout]);
// 表单提交
const onFinish = (values: any) => {
console.log(values);
const valuesArray = Object.entries(values).map(([key, value]) => ({ key, value }));
// console.log(valuesArray);
// 如果存在范围,则将转化key和value的值
for (let key = 0; key < valuesArray.length; key++) {
// 获取当前key的value
const value = valuesArray?.[key]?.value;
// 获取当前key的范围
const rangeName = props?.formItems?.[key]?.rangeName;
// 获取当前key的格式
const format = props?.formItems?.[key]?.format;
// 获取当前key的名称
const name = props?.formItems?.[key]?.name ?? "";
// 获取当前type
const type = props?.formItems?.[key]?.type ?? "";
//多个日期
if (type === "date") {
if (rangeName && value) {
const keyValue = Array.isArray(value) ? value : [];
values[rangeName[0]] = Array.isArray(keyValue) ? dayjs(keyValue?.[0]).format(format as string) : "";
values[rangeName[1]] = Array.isArray(keyValue) ? dayjs(keyValue?.[1]).format(format as string) : "";
delete values[name];
}
// 单个日期
if (value && !rangeName) {
values[name] = dayjs(value as any).format(format as string);
}
}
}
console.log(values);
props?.onFinish?.(values);
};
// 过滤隐藏的表单项
const formItemNew = useMemo<FormItem[]>(() => {
const { formItems } = props;
return formItems.filter((item: FormItem) => !item.hidden);
}, [props.formItems]);
// 表单初始值
const initialValues = useMemo(() => {
const { formItems } = props;
return formItems.reduce((acc: Record<string, any>, item: FormItem) => {
acc[item.name] = item.initialValue;
return acc;
}, {});
}, [props.formItems]);
// 重置表单
const resetForm = () => {
form.resetFields();
};
// 关闭模态框
const closeModal = () => {
props?.closeModal?.();
};
// 设置表单值
const setFromFieldsValue = (values: Record<string, any>) => {
console.log(values);
form.setFieldsValue(values);
};
return {
form,
defaultSpan,
onFinish,
formItemNew,
initialValues,
formLayout,
resetForm,
setFromFieldsValue,
closeModal,
};
};