shadcn/ui 工程实战:组件架构解析、主题系统设计与生产级扩展方案

深入解析 shadcn/ui 的组件架构原理、Radix UI + Tailwind CSS 协同机制、CSS 变量主题系统设计,以及生产环境中组件扩展、表单集成与性能优化的完整实战方案。

前端开发 2026-06-06 15 分钟

在 React 生态的 UI 组件库竞争中,2024-2026 年发生了一个有趣的现象:一个"不是组件库"的项目,GitHub Star 数突破了 80k,成为了最受欢迎的 React UI 方案——它就是 shadcn/ui。与 Ant Design、Material UI 等传统组件库不同,shadcn/ui 采用了一种激进的"代码归你,框架归你"策略:它不发布 npm 包,而是将源代码直接复制到你的项目中。这种设计哲学解决了传统组件库的哪些痛点?又带来了哪些新的工程挑战?

🏗️ 一、shadcn/ui 的架构设计哲学

1.1 为什么"不发布 npm 包"反而是优势

传统组件库(如 Ant Design 5.x)面临一个根本性矛盾:统一封装 vs 深度定制。当你的设计师要求一个与默认主题截然不同的按钮时,你可能需要覆盖 30+ 个 CSS 变量,甚至用 !important 强行覆盖内部样式。

shadcn/ui 的解决方案极为彻底——它把组件源代码直接放到你的 components/ui/ 目录下,你可以像修改自己的代码一样修改任何组件。

// ❌ 传统组件库的定制方式——在外部覆盖样式
// 组件库升级时,覆盖规则可能失效
.ant-btn-custom {
  border-radius: 2px !important;
  font-weight: 600 !important;
  padding: 8px 24px !important;
}
// ✅ shadcn/ui 的定制方式——直接修改源码
// components/ui/button.tsx 中直接修改
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        // ✅ 直接在这里添加你的自定义 variant
        gradient: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:opacity-90",
      },
    },
  }
)

💡 提示: shadcn/ui 的 “不发布 npm 包” 模式,本质上是把组件库从"依赖项"变成了"项目代码"。你永远不需要等待上游发布新版本来修复 bug——直接在自己的代码里改就行。

1.2 三层技术栈:Radix UI + Tailwind CSS + CVA

shadcn/ui 的技术架构由三层组成,每一层各司其职:

层级 技术 职责 是否可替换
无障碍层 Radix UI Primitives 键盘导航、焦点管理、ARIA 属性 ⚠️ 替换成本极高
样式层 Tailwind CSS + CSS Variables 视觉呈现、响应式、主题 ✅ 可迁移到 UnoCSS
变体管理层 Class Variance Authority (CVA) 组件变体定义、样式组合 ✅ 可替换为 cva 或自研方案

这种分层设计的核心优势是每一层都可以独立升级。Radix UI 发布了新的无障碍特性?直接升级 @radix-ui/react-* 包即可,样式层完全不受影响。

// 架构示意:三层协作关系
// Button 组件的实际代码结构

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"        // 第一层:Radix UI(无障碍)
import { cva, type VariantProps } from "class-variance-authority"  // 第二层:CVA(变体管理)
import { cn } from "@/lib/utils"                     // Tailwind CSS 工具函数

// 定义变体——这是 shadcn/ui 的核心设计模式
const buttonVariants = cva(
  // 基础样式:所有变体共享
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

1.3 与传统组件库的核心差异

维度 Ant Design / MUI shadcn/ui
安装方式 npm install antd npx shadcn add button
代码所有权 ❌ node_modules 中 ✅ 你的项目代码
定制方式 CSS 变量 / ConfigProvider 直接修改源码
包体积影响 整个库(需 tree-shaking) 只含你添加的组件
升级方式 npm update 手动对比差异后合并
TypeScript ✅ 完整类型 ✅ 完整类型(可修改)
无障碍 ✅ 各自实现 ✅ Radix UI 统一保障
SSR 支持

⚠️ 警告: shadcn/ui 的"代码归你"模式意味着你需要自行承担组件的维护成本。当上游修复了 bug 或增加了新特性时,你需要手动同步更新,而不是简单的 npm update

🎨 二、主题系统深度解析

2.1 CSS 变量驱动的 Design Tokens

shadcn/ui 的主题系统基于 CSS 自定义属性(Custom Properties),而非传统的 SCSS 变量或 JavaScript 主题对象。这意味着主题切换完全在浏览器端完成,无需重新编译:

/* globals.css — shadcn/ui 的主题定义 */
/* 使用 HSL 色彩空间,便于派生颜色 */

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;
    --primary-foreground: 210 40% 98%;
    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 221.2 83.2% 53.3%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
    --primary: 217.2 91.2% 59.8%;
    --primary-foreground: 222.2 47.4% 11.2%;
    --secondary: 217.2 32.6% 17.5%;
    --secondary-foreground: 210 40% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;
    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;
    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;
    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 224.3 76.3% 48%;
  }
}

📌 记住: shadcn/ui 使用 HSL 色彩空间(色相 饱和度 亮度),而非 RGB 或 HEX。这是因为 HSL 的三个维度分别对应人类对颜色的直观感知,更便于系统性地派生颜色变体。

2.2 暗黑模式实现原理

shadcn/ui 推荐使用 next-themes(Next.js)或手动管理 class 来切换暗黑模式。核心原理是在 <html> 标签上切换 dark class,利用 CSS 变量的级联特性自动切换所有颜色:

// lib/theme-provider.tsx — 主题上下文
"use client"

import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

// app/layout.tsx — 在根布局中使用
import { ThemeProvider } from "@/lib/theme-provider"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

2.3 多主题方案:品牌色一键切换

在 SaaS 产品中,不同租户可能需要不同的品牌色。利用 CSS 变量的特性,只需覆盖少量变量即可实现完整的主题切换:

// lib/themes.ts — 多主题定义
export const themes = {
  // 默认蓝色主题
  blue: {
    "--primary": "221.2 83.2% 53.3%",
    "--primary-foreground": "210 40% 98%",
    "--ring": "221.2 83.2% 53.3%",
  },
  // 紫色主题
  purple: {
    "--primary": "262.1 83.3% 57.8%",
    "--primary-foreground": "210 40% 98%",
    "--ring": "262.1 83.3% 57.8%",
  },
  // 绿色主题
  green: {
    "--primary": "142.1 76.2% 36.3%",
    "--primary-foreground": "355.7 100% 97.3%",
    "--ring": "142.1 76.2% 36.3%",
  },
  // 橙色主题
  orange: {
    "--primary": "24.6 95% 53.1%",
    "--primary-foreground": "60 9.1% 97.8%",
    "--ring": "24.6 95% 53.1%",
  },
} as const

// 应用主题的工具函数
export function applyTheme(themeName: keyof typeof themes) {
  const theme = themes[themeName]
  const root = document.documentElement
  Object.entries(theme).forEach(([key, value]) => {
    root.style.setProperty(key, value)
  })
}
// components/theme-switcher.tsx — 主题切换组件
"use client"

import { applyTheme, themes } from "@/lib/themes"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Paintbrush } from "lucide-react"

const colorNames: Record<keyof typeof themes, string> = {
  blue: "天空蓝",
  purple: "典雅紫",
  green: "翡翠绿",
  orange: "活力橙",
}

export function ThemeSwitcher() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Paintbrush className="h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        {(Object.keys(themes) as Array<keyof typeof themes>).map((name) => (
          <DropdownMenuItem key={name} onClick={() => applyTheme(name)}>
            <div
              className="mr-2 h-4 w-4 rounded-full"
              style={{ backgroundColor: `hsl(${themes[name]["--primary"]})` }}
            />
            {colorNames[name]}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

⚠️ 警告: 使用 style.setProperty() 动态修改 CSS 变量时,暗黑模式的颜色也需要同步覆盖。建议在主题定义中同时包含 light 和 dark 两套颜色值,通过 .dark 选择器切换。

🔧 三、组件扩展与生产级实战

3.1 DataTable:TanStack Table 深度集成

shadcn/ui 提供了 DataTable 组件示例,但实际项目中,你需要根据业务需求进行深度定制。以下是一个完整的生产级 DataTable 实现:

// components/data-table/data-table.tsx
// 通用数据表格组件,支持排序、筛选、分页、行选择

"use client"

import {
  ColumnDef,
  ColumnFiltersState,
  SortingState,
  VisibilityState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table"
import { useState } from "react"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Settings2 } from "lucide-react"

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
  searchKey?: string
  searchPlaceholder?: string
  pageSize?: number
}

export function DataTable<TData, TValue>({
  columns,
  data,
  searchKey,
  searchPlaceholder = "搜索...",
  pageSize = 10,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([])
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
  const [rowSelection, setRowSelection] = useState({})

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    initialState: { pagination: { pageSize } },
    state: { sorting, columnFilters, columnVisibility, rowSelection },
  })

  return (
    <div className="space-y-4">
      {/* 工具栏:搜索 + 列显示控制 */}
      <div className="flex items-center justify-between">
        {searchKey && (
          <Input
            placeholder={searchPlaceholder}
            value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
            onChange={(e) => table.getColumn(searchKey)?.setFilterValue(e.target.value)}
            className="max-w-sm"
          />
        )}
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="outline" className="ml-auto">
              <Settings2 className="mr-2 h-4 w-4" />
              显示列
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            {table.getAllColumns().filter((col) => col.getCanHide()).map((column) => (
              <DropdownMenuCheckboxItem
                key={column.id}
                className="capitalize"
                checked={column.getIsVisible()}
                onCheckedChange={(value) => column.toggleVisibility(!!value)}
              >
                {column.id}
              </DropdownMenuCheckboxItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      {/* 表格主体 */}
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id}>
                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="h-24 text-center">
                  暂无数据
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>

      {/* 分页控制 */}
      <div className="flex items-center justify-end space-x-2">
        <div className="flex-1 text-sm text-muted-foreground">
          已选 {table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length} 行
        </div>
        <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
          上一页
        </Button>
        <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
          下一页
        </Button>
      </div>
    </div>
  )
}

3.2 表单集成:React Hook Form + Zod

shadcn/ui 的 Form 组件与 React Hook Form 深度集成,配合 Zod 进行运行时验证,可以实现完全类型安全的表单处理:

// components/user-form.tsx
// 完整的用户表单:类型安全 + 运行时验证 + 自动错误展示

"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { toast } from "@/components/ui/use-toast"

// 第一步:用 Zod 定义表单 Schema——类型和验证规则一次定义
const userFormSchema = z.object({
  name: z.string().min(2, { message: "姓名至少 2 个字符" }).max(50),
  email: z.string().email({ message: "请输入有效的邮箱地址" }),
  role: z.enum(["admin", "editor", "viewer"], {
    required_error: "请选择角色",
  }),
  bio: z.string().max(200, { message: "简介不超过 200 字" }).optional(),
})

// 从 Schema 自动推导 TypeScript 类型——零重复
type UserFormValues = z.infer<typeof userFormSchema>

interface UserFormProps {
  defaultValues?: Partial<UserFormValues>
  onSubmit: (data: UserFormValues) => Promise<void>
}

export function UserForm({ defaultValues, onSubmit }: UserFormProps) {
  const form = useForm<UserFormValues>({
    resolver: zodResolver(userFormSchema),
    defaultValues: {
      name: "",
      email: "",
      role: undefined,
      bio: "",
      ...defaultValues,
    },
  })

  async function handleSubmit(data: UserFormValues) {
    try {
      await onSubmit(data)
      toast({ title: "保存成功", description: "用户信息已更新" })
    } catch (error) {
      toast({ title: "保存失败", description: "请稍后重试", variant: "destructive" })
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>姓名</FormLabel>
              <FormControl>
                <Input placeholder="请输入姓名" {...field} />
              </FormControl>
              <FormDescription>2-50 个字符</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>邮箱</FormLabel>
              <FormControl>
                <Input type="email" placeholder="user@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>角色</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="选择一个角色" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="admin">管理员</SelectItem>
                  <SelectItem value="editor">编辑者</SelectItem>
                  <SelectItem value="viewer">查看者</SelectItem>
                </SelectContent>
              </Select>
              <FormDescription>决定用户的操作权限范围</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? "保存中..." : "保存"}
        </Button>
      </form>
    </Form>
  )
}

💡 提示: Zod Schema 的 z.infer<typeof schema> 是 TypeScript 类型体操的杀手级应用。一份 Schema 同时定义了运行时验证规则和编译时类型,彻底消除了"表单字段类型与验证规则不一致"的问题。

3.3 Dialog + Form 组合模式

在实际项目中,最常见的 UI 模式是"弹窗 + 表单"。shadcn/ui 的 Dialog 和 Form 组件可以优雅地组合:

// components/create-user-dialog.tsx
// 弹窗表单组合——常见的管理后台模式

"use client"

import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { UserForm } from "@/components/user-form"
import { Plus } from "lucide-react"

export function CreateUserDialog() {
  const [open, setOpen] = useState(false)

  async function handleSubmit(data: { name: string; email: string; role: string }) {
    const response = await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    })
    if (!response.ok) throw new Error("创建失败")
    setOpen(false) // 成功后关闭弹窗
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>
          <Plus className="mr-2 h-4 w-4" />
          新建用户
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>新建用户</DialogTitle>
          <DialogDescription>填写以下信息创建新用户账户</DialogDescription>
        </DialogHeader>
        <UserForm onSubmit={handleSubmit} />
      </DialogContent>
    </Dialog>
  )
}

⚡ 四、性能优化与工程化最佳实践

4.1 包体积优化

shadcn/ui 虽然是源码复制模式,但 Radix UI 的依赖仍然会影响包体积。以下是一些关键的优化策略:

// next.config.js — 按需引入 Lucide 图标(避免全量导入)
// ✅ 推荐:按需导入图标
import { Button } from "@/components/ui/button"
import { ArrowRight, Check, Loader2 } from "lucide-react"

// ❌ 避免:全量导入(会增加 ~200KB)
// import * as Icons from "lucide-react"
// lib/utils.ts — cn() 工具函数是性能关键点
// 使用 twMerge 解决 Tailwind 类名冲突
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

⚠️ 警告: tailwind-merge 的运行时开销不可忽略。在频繁重渲染的列表组件中(如 Virtual Scroll),建议将 cn() 的结果缓存,或使用 useMemo 避免重复计算。

4.2 组件变体设计原则

在扩展 shadcn/ui 组件时,遵循以下原则可以保持代码的一致性和可维护性:

// ✅ 推荐:使用 CVA 定义变体,保持声明式风格
const badgeVariants = cva(
  "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground shadow",
        secondary: "border-transparent bg-secondary text-secondary-foreground",
        destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
        outline: "text-foreground",
        // 业务自定义变体
        success: "border-transparent bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
        warning: "border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-400",
      },
    },
    defaultVariants: { variant: "default" },
  }
)
// ❌ 避免:在组件内部使用条件类名拼接
// 这种方式难以维护,且无法利用 CVA 的类型推导
function Badge({ variant, children }: { variant: string; children: React.ReactNode }) {
  const className = variant === "success"
    ? "bg-green-500 text-white"
    : variant === "warning"
    ? "bg-yellow-500 text-black"
    : "bg-blue-500 text-white"
  return <span className={className}>{children}</span>
}

4.3 工程化组织建议

目录 内容 职责
components/ui/ shadcn/ui 基础组件 通用 UI 原子,尽量不包含业务逻辑
components/custom/ 业务扩展组件 基于 ui/ 组件组合的业务组件
components/forms/ 表单组件 集成 React Hook Form 的表单
lib/utils.ts 工具函数 cn()、formatDate() 等
hooks/ 自定义 Hooks useToast()、useClipboard() 等

推荐: 将 shadcn/ui 组件放在 components/ui/ 目录下,业务组件放在 components/custom/ 下。这样可以清楚地区分"通用组件"和"业务组件",避免在一个目录下混杂数百个文件。

4.4 shadcn/ui 的适用场景与局限

场景 推荐度 理由
创业公司 / 中小项目 ⭐⭐⭐⭐⭐ 快速搭建,自由度高
设计系统要求高的产品 ⭐⭐⭐⭐⭐ 源码可控,便于深度定制
大型企业(需统一管控) ⭐⭐⭐ 需要自建组件发包机制
多团队协作大型项目 ⭐⭐⭐ 需要额外的版本同步策略
非 React 项目 仅支持 React(可关注 PortVue、shadcn-svelte)
需要高级组件(Tree、Gantt) ⭐⭐ 需要自行实现或集成第三方

💡 总结与建议

shadcn/ui 代表了前端组件库的一个新范式:代码归你,你负责定制。它的成功不是偶然的——在 React Server Components 时代,传统的"npm 包"模式在 bundle size、定制灵活性和 tree-shaking 方面都面临挑战,而"源码复制"模式恰好解决了这些问题。

如果你正在为新项目选择 UI 方案:

  • 选 shadcn/ui:如果你需要高度定制、追求包体积最小化、团队有能力维护组件源码
  • 选 Ant Design / MUI:如果你需要开箱即用的 60+ 组件、企业级 Table/Form/Tree、团队更关注业务开发速度

无论选择哪种方案,核心原则不变:组件库是工具,不是信仰。选择最适合你的团队和业务需求的方案,就是最好的方案。

相关工具推荐:

📚 相关文章