组件开发规范
核心原则
组件必须保持单一职责,使用 shadcn/ui 组件库,确保代码可复用和可测试。
shadcn/ui 组件安装
初始化项目
bash
# 初始化 shadcn/ui
npx shadcn@latest init
# 按照提示选择配置
# - Style: Default
# - Base color: Neutral
# - CSS variables: yes添加组件
bash
# 添加单个组件
npx shadcn@latest add button
# 添加多个组件
npx shadcn@latest add button card dialog form input table
# 添加所有组件
npx shadcn@latest add --all目录结构
src/
├── components/
│ ├── ui/ # shadcn/ui 组件
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ └── table.tsx
│ ├── UserCard.tsx # 业务组件
│ ├── UserForm.tsx
│ └── UserList.tsx
├── lib/
│ └── utils.ts # cn() 工具函数基础组件
卡片组件
tsx
// src/components/UserCard.tsx
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { User } from '@/types';
interface UserCardProps {
user: User;
onEdit?: (id: number) => void;
onDelete?: (id: number) => void;
}
export function UserCard({ user, onEdit, onDelete }: UserCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>{user.name}</CardTitle>
<CardDescription>{user.email}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
注册时间: {user.createdAt}
</p>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => onEdit?.(user.id)}>
编辑
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => onDelete?.(user.id)}
>
删除
</Button>
</CardFooter>
</Card>
);
}按钮组件扩展
tsx
// src/components/ui/loading-button.tsx
import { Button, ButtonProps } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface LoadingButtonProps extends ButtonProps {
loading?: boolean;
}
export function LoadingButton({
loading,
disabled,
children,
className,
...props
}: LoadingButtonProps) {
return (
<Button
disabled={loading || disabled}
className={cn('relative', className)}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</Button>
);
}表单组件
tsx
// src/components/UserForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { LoadingButton } from '@/components/ui/loading-button';
const userSchema = z.object({
name: z.string().min(2, '姓名至少 2 个字符'),
email: z.string().email('邮箱格式不正确'),
});
type UserFormData = z.infer<typeof userSchema>;
interface UserFormProps {
initialValues?: Partial<UserFormData>;
onSubmit: (values: UserFormData) => Promise<void>;
}
export function UserForm({ initialValues, onSubmit }: UserFormProps) {
const form = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
name: initialValues?.name ?? '',
email: initialValues?.email ?? '',
},
});
const handleSubmit = async (data: UserFormData) => {
await onSubmit(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>姓名</FormLabel>
<FormControl>
<Input placeholder="请输入姓名" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input type="email" placeholder="请输入邮箱" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<LoadingButton
type="submit"
loading={form.formState.isSubmitting}
className="w-full"
>
提交
</LoadingButton>
</form>
</Form>
);
}表格组件
tsx
// src/components/UserTable.tsx
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { User } from '@/types';
interface UserTableProps {
users: User[];
onEdit?: (user: User) => void;
onDelete?: (id: number) => void;
}
export function UserTable({ users, onEdit, onDelete }: UserTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>姓名</TableHead>
<TableHead>邮箱</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.id}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit?.(user)}
>
编辑
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => onDelete?.(user.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
))}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}对话框组件
tsx
// src/components/UserDialog.tsx
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { UserForm } from './UserForm';
import { User } from '@/types';
interface UserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSubmit: (data: { name: string; email: string }) => Promise<void>;
}
export function UserDialog({
open,
onOpenChange,
user,
onSubmit,
}: UserDialogProps) {
const handleSubmit = async (data: { name: string; email: string }) => {
await onSubmit(data);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{user ? '编辑用户' : '新建用户'}</DialogTitle>
<DialogDescription>
{user ? '修改用户信息' : '创建一个新用户'}
</DialogDescription>
</DialogHeader>
<UserForm initialValues={user} onSubmit={handleSubmit} />
</DialogContent>
</Dialog>
);
}业务组件
tsx
// src/components/UserManagement.tsx
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { UserTable } from './UserTable';
import { UserDialog } from './UserDialog';
import { userService } from '@/services/user';
import { User } from '@/types';
import { toast } from 'sonner';
import { Plus } from 'lucide-react';
export function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | undefined>();
const loadUsers = async () => {
setLoading(true);
try {
const data = await userService.getList();
setUsers(data);
} catch {
toast.error('加载用户列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const handleCreate = () => {
setEditingUser(undefined);
setDialogOpen(true);
};
const handleEdit = (user: User) => {
setEditingUser(user);
setDialogOpen(true);
};
const handleSubmit = async (data: { name: string; email: string }) => {
if (editingUser) {
await userService.update(editingUser.id, data);
toast.success('更新成功');
} else {
await userService.create(data);
toast.success('创建成功');
}
loadUsers();
};
const handleDelete = async (id: number) => {
await userService.delete(id);
toast.success('删除成功');
loadUsers();
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">用户管理</h1>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
新建用户
</Button>
</div>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
) : (
<UserTable
users={users}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
<UserDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
user={editingUser}
onSubmit={handleSubmit}
/>
</div>
);
}组件设计原则
单一职责
tsx
// ✅ 正确 - 每个组件只做一件事
function UserAvatar({ user }: { user: User }) {
return (
<Avatar>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>{user.name[0]}</AvatarFallback>
</Avatar>
);
}
// ❌ 错误 - 组件做太多事情
function UserAvatarWithDropdownAndEditForm({ user }) {
// 同时处理头像显示、下拉菜单、编辑表单...
}Props 接口设计
tsx
// ✅ 正确 - 清晰的 Props 定义
interface UserCardProps {
/** 用户数据 */
user: User;
/** 点击编辑时的回调 */
onEdit?: (id: number) => void;
/** 点击删除时的回调 */
onDelete?: (id: number) => void;
/** 自定义类名 */
className?: string;
}
// ❌ 错误 - Props 定义不清晰
interface BadProps {
data: any;
callback: Function;
}组件命名
✅ 正确命名:
- UserCard.tsx - 用户卡片
- UserForm.tsx - 用户表单
- UserTable.tsx - 用户表格
- UserDialog.tsx - 用户对话框
❌ 错误命名:
- Card.tsx - 与 shadcn/ui 冲突
- user-card.tsx - 应使用 PascalCase
- UserCardComponent.tsx - 冗余后缀禁止的做法
- ❌ 禁止 组件超过 200 行
- ❌ 禁止 深层嵌套超过 3 层
- ❌ 禁止 使用 Ant Design、Material-UI 等其他 UI 框架
- ❌ 禁止 在组件中直接修改 props
- ❌ 禁止 使用
any类型作为 Props - ❌ 禁止 省略
cn()函数合并类名
tsx
// ❌ 错误 - 组件过大
function MassiveComponent() {
// 500 行代码...
}
// ✅ 正确 - 拆分组件
function UserManagement() {
return (
<div className="space-y-4">
<UserHeader />
<UserTable />
<UserDialog />
</div>
);
}