在现代前端开发中,表单是用户交互最常见的组件之一。传统的表单开发往往需要大量重复代码,维护性差,扩展困难。本文将分析一套基于 shadcn 和 React Hook Form 的可配置表单系统,通过配置驱动的方式,大幅提升表单开发效率。
🎯 设计目标
这套表单系统解决了以下痛点:
- 重复代码过多:每个表单都需要编写类似的验证、布局、提交逻辑
- 维护困难:表单字段变更需要修改多处代码
- 扩展性差:新增字段类型需要大量改动
- 一致性问题:不同开发者实现的表单样式和交互不统一
🏗️ 系统架构
系统采用分层架构设计,各层职责清晰:
- 配置层(Config):定义表单结构、字段类型、验证规则
- 渲染层(Dialog):通用表单对话框组件,处理状态管理和生命周期
- 字段层(Fields):具体的表单字段组件实现
- 基础层(UI):基于 shadcn 的基础组件
📁 核心文件结构
bash
dialog/
├── types.ts # 类型定义
├── CreateSimpleDialog.tsx # 通用表单组件
├── configs/ # 表单配置
│ ├── articleConfig.ts
│ ├── mediaFileConfig.ts
│ └── socialAccountConfig.ts
└── fields/
└── index.tsx # 字段组件库
🧩 核心类型定义
系统的类型设计非常关键,确保了类型安全和开发体验:
typescript
// 字段配置接口
export interface FieldConfig {
name: string; // 字段名
label: string; // 显示标签
type: FieldType; // 字段类型
required: boolean; // 是否必填
component: React.ComponentType<any>; // 对应的组件
props?: Record<string, unknown>; // 组件属性
layout?: 'horizontal' | 'vertical'; // 布局方式
controlWidth?: string; // 控件宽度
}
// 表单配置接口
export interface ContentConfig<T = Record<string, unknown>> {
type: ContentType; // 表单类型
title: string; // 标题
buttonText: string; // 按钮文字
fields: FieldConfig[]; // 字段配置
defaultValues?: Partial<T>; // 默认值
validate?: ValidationFunction<T>; // 验证函数
}
📝 配置驱动的表单定义
示例:文章表单配置
typescript
export const articleConfig: ContentConfig<ArticleFormValues> = {
type: 'article',
title: '文章',
buttonText: '添加文章',
defaultValues: {
title: '',
content: null,
status: 1,
remark: null,
externalUrl: null,
priority: 1,
authorId: '',
},
fields: [
{
name: 'title',
label: '文章标题',
type: 'input',
required: true,
component: TextInputField,
layout: 'horizontal',
controlWidth: '270px',
props: {
placeholder: '请输入文章标题',
},
},
{
name: 'authorId',
label: '所属作者',
type: 'select',
required: true,
component: AuthorSelectField,
layout: 'horizontal',
controlWidth: '270px',
},
// ... 更多字段配置
],
validate: validateArticleForm,
};
示例:媒体文件表单配置
typescript
export const mediaFileConfig: ContentConfig<MediaFileFormValues> = {
type: 'mediaFile',
title: '上传媒体文件',
buttonText: '添加媒体文件',
fields: [
{
name: 'selectedCategoryName',
label: '资源库',
type: 'searchSelect',
required: true,
component: CategorySearchSelectField,
},
{
name: 'selectedAuthorId',
label: '作者',
type: 'select',
required: true,
component: MediaAuthorSelectField,
},
{
name: 'selectedFile',
label: '媒体文件',
type: 'filePicker',
required: true,
component: MediaFilePickerField,
},
],
validate: validateMediaFileForm,
};
🎨 通用表单组件实现
CreateSimpleDialog
是系统的核心组件,它接收配置并渲染完整的表单:
typescript
export const CreateSimpleDialog = <T = Record<string, unknown>,>({
config,
open: externalOpen,
onOpenChange: externalOnOpenChange,
onSubmit,
isSubmitting = false,
submitText,
currentData,
}: CreateSimpleDialogProps<T>) => {
// 创建动态表单的默认值
const createDefaultValues = useCallback(() => {
if (currentData) return currentData;
const defaults: Record<string, unknown> = {};
for (const field of config.fields) {
if (config.defaultValues && field.name in config.defaultValues) {
defaults[field.name] = config.defaultValues[field.name];
} else {
// 根据字段类型设置默认值
switch (field.type) {
case 'filePicker':
defaults[field.name] = null;
break;
case 'number':
defaults[field.name] = 0;
break;
default:
defaults[field.name] = '';
}
}
}
return defaults;
}, [config.fields, config.defaultValues, currentData]);
const form = useForm({ defaultValues: createDefaultValues() });
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{config.title}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit}>
{/* 动态渲染表单字段 */}
{config.fields.map((fieldConfig) => (
<ProFormField
key={fieldConfig.name}
layout={fieldConfig?.layout ?? 'horizontal'}
name={fieldConfig.name}
form={form}
label={fieldConfig.label}
controlWidth={fieldConfig?.controlWidth ?? '200px'}
renderFormControl={(field) => {
const FieldComponent = fieldConfig.component;
return (
<FieldComponent
form={form}
value={field.value}
onChange={field.onChange}
disabled={isSubmitting}
{...(fieldConfig.props || {})}
/>
);
}}
/>
))}
{/* 表单按钮 */}
<div className="flex gap-4">
<Button variant="ghost" onClick={() => setOpen(false)}>
取消
</Button>
<Button type="submit" disabled={isSubmitting || !isFormValid()}>
{isSubmitting ? '处理中...' : submitText || '确认'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
🛠️ 字段组件库
系统提供了丰富的字段组件,每个组件都遵循统一的接口规范:
文本输入字段
typescript
export const TextInputField: React.FC<{
value: string | null;
onChange: (value: string | null) => void;
disabled?: boolean;
placeholder?: string;
}> = ({ value, onChange, disabled, placeholder }) => {
return (
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value || null)}
disabled={disabled}
placeholder={placeholder}
className="w-full"
/>
);
};
选择器字段
typescript
export const AuthorSelectField: React.FC<{
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}> = ({ value, onChange, disabled }) => {
const { data: authors = [], isLoading } = useAuthorsQuery();
if (isLoading) {
return <Skeleton className="h-12 w-full rounded-md" />;
}
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="选择作者" />
</SelectTrigger>
<SelectContent>
{authors.map((author) => (
<SelectItem key={author.id} value={author.id}>
{author.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
文件上传字段
typescript
export const MediaFilePickerField: React.FC<{
value: File | File[] | null;
onChange: (file: File | File[] | null) => void;
disabled?: boolean;
maxFiles?: number;
}> = ({ value, onChange, disabled, maxFiles = 10 }) => {
return (
<FilePicker
value={value}
onChange={onChange}
accept="image/*,video/*,audio/*"
multiple={maxFiles > 1}
maxFiles={maxFiles}
disabled={disabled}
className="w-full"
/>
);
};
🔗 使用方式
使用这套系统非常简单,只需要引入配置和组件:
typescript
import { CreateSimpleDialog } from '@/components/biz/dialog';
import { articleConfig } from '@/components/biz/dialog/configs';
function ArticleManagement() {
const [isOpen, setIsOpen] = useState(false);
const handleSubmit = async (values: ArticleFormValues) => {
// 处理表单提交
console.log('表单值:', values);
// 调用 API 保存数据
await saveArticle(values);
setIsOpen(false);
};
return (
<div>
<CreateSimpleDialog
config={articleConfig}
open={isOpen}
onOpenChange={setIsOpen}
onSubmit={handleSubmit}
/>
</div>
);
}
🚀 扩展新字段类型
系统的扩展性很强,新增字段类型只需要三步:
1. 定义字段组件
typescript
export const DatePickerField: React.FC<{
value: Date | null;
onChange: (value: Date | null) => void;
disabled?: boolean;
}> = ({ value, onChange, disabled }) => {
return (
<DatePicker
selected={value}
onSelect={onChange}
disabled={disabled}
/>
);
};
2. 更新类型定义
typescript
export type FieldType =
| 'select'
| 'input'
| 'textarea'
| 'number'
| 'datePicker' // 新增
| 'filePicker';
3. 在配置中使用
typescript
{
name: 'publishDate',
label: '发布日期',
type: 'datePicker',
required: true,
component: DatePickerField,
}
🎯 最佳实践
1. 配置文件组织
- 按业务模块划分配置文件
- 使用 TypeScript 严格类型
- 提供完整的默认值
2. 字段组件设计
- 保持接口一致性
- 支持所有必要的属性
- 处理加载和错误状态
3. 验证规则
- 前端验证配合后端验证
- 提供清晰的错误提示
- 支持异步验证
📝 总结
这套基于 shadcn 的可配置表单系统通过以下方式大幅提升了开发效率:
- 配置驱动:通过配置文件定义表单,减少重复代码
- 类型安全:完整的 TypeScript 支持,减少运行时错误
- 组件复用:统一的字段组件库,保证一致性
- 易于扩展:清晰的架构设计,便于新增功能
- 维护友好:分层设计,职责明确
对于中大型项目中的表单开发,这套方案能够显著提升开发效率,降低维护成本,是一个值得借鉴的优秀实践。