Skip to content

组件开发规范

核心原则

组件必须保持单一职责,使用 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>
  );
}

相关文档