Skip to content

Lesson 13:专业级 UI 集成 — shadcn/ui 组件库基础 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 2(进阶篇)
  • 推荐时长:90~120 分钟(首次学习)
  • 先修要求:完成 Lesson 12;已配置 Vite 7、React 19、Tailwind 4 与 Query 请求层
  • 学习产出:配置路径别名,接入 Button、Dialog、Select 和 Sonner,能解释源码与底层依赖的关系
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:告别纯手写基础样式,使用以源码分发为主的 shadcn/ui 快速构建美观的界面,深入理解 Headless UI 的设计哲学。

📦 本节产出:将项目中的原生 HTML 元素替换为专业的 Button、Dialog、Select、Toast 等多态组件。

一、为什么是 shadcn/ui? ​

在之前的课程里,我们所有的按钮都是这样手写的:

tsx
<button className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold ... hover:bg-indigo-700">提交</button>

当组件多了以后,每次都这么写容易出错且难以维护。 传统做法是引入 Ant Design 或 Material UI 这种组件库。

Ant Design、Material UI 等库提供完整的组件和主题体系,具体定制成本与包体积要按实际功能评估。shadcn/ui 的主要区别在于交付方式:把组件源码添加到项目中,由你维护和修改。

本课使用 Radix 风格的组件实现,加上 Tailwind 样式;当前 shadcn 也提供其他底层实现。源码在本地并不意味着没有依赖,CLI 仍会安装 Radix、Sonner 等所需包。


二、🧠 深度专题:Headless UI 理念 ​

在理解 shadcn/ui 之前,我们需要先搞清楚它的底层 —— Headless UI(无头 UI) 是什么。

2.1 什么叫"无头"? ​

带样式的 UI 库通常同时提供行为逻辑与默认外观,并提供一定的主题和定制能力。 Headless UI = 只提供行为逻辑,完全不管长什么样。

Radix UI 就是一个 Headless UI 库。它帮你解决了组件开发中 交互细节:

  • 弹窗打开时焦点被锁定在内部(Tab 键循环)
  • 按 Esc 关闭弹窗
  • 下拉菜单的键盘上下方向键导航
  • ARIA 无障碍属性(屏幕阅读器能正确朗读)
  • 点击弹窗外部区域自动关闭

而 本课的 shadcn/ui 实现 = Radix UI 的行为 + Tailwind CSS 的样式,打包好送你一份可修改的源码。

2.2 为什么选择这种模式? ​

特性Ant Designshadcn/ui (Radix + Tailwind)
自定义难度😫 覆盖 CSS 容易出错😊 直接改源码
升级风险😫 大版本可能破坏覆盖自行维护源码;底层依赖升级仍需验证
包体积按所用组件与构建配置评估按组件引入,仍包含对应底层依赖
无障碍提供基础支持,仍需正确使用Radix 提供基础行为,仍需标签、对比度与键盘验证
上手速度✅ 开箱即用🔶 需要理解结构后才能改

三、初始化 shadcn/ui (Tailwind v4) ​

先配置 @/ 别名,否则课文里的导入和 CLI 检查会失败。在 tsconfig.json 与 tsconfig.app.json 各自的 compilerOptions 中合并以下配置,保留模板原有选项:

json
{
  "baseUrl": ".",
  "paths": { "@/*": ["./src/*"] }
}

更新 Vite 配置。TypeScript 的 paths 只影响类型解析,运行时仍需要对应的 alias:

ts
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
})

本课固定 CLI 3.8.5,沿用 Radix / New York 组件的 asChild API。CLI 从远端 registry 获取源码,锁 CLI 并不能锁住 registry 内容;请检查生成文件并保留 lockfile。参见 Vite 安装说明。

bash
# 在 phase2-task-manager 目录下执行
npx [email protected] init

按提示选择 New York 风格和 Zinc 基色;不要依赖机器上保存的默认选项。Tailwind v4 的 components.json 中 tailwind.config 留空,CSS 指向 src/index.css。初始化后检查保留了 Lesson 10 的 .dark 变体,避免重复定义。

初始化完成后,发生了什么变化?

  1. components.json 出现在了根目录(记录你的配置)。
  2. src/lib/utils.ts 出现在了项目中(它包含 cn 核心合并样式函数)。
  3. src/index.css 被注入了大量 CSS 变量(决定了默认颜色体系)。

理解 cn 函数 ​

当前 registry 可能生成下面的转导出,并让组件直接从 cn 包导入。保留 CLI 实际生成的实现;不要仅凭旧教程补装或删除依赖。cn 官方仓库。

ts
// src/lib/utils.ts:当前 registry 的一种生成结果
export { cn } from "cn"

旧版源码常把两个库组合起来。下面用于阅读历史代码,无需替换当前生成文件;若独立运行它,需另装 clsx@2 与 tailwind-merge@3:

ts
// 历史实现示意
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

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

cn() 做了什么? ​

无论使用 cn 包还是旧版组合,实现都包含条件拼接和冲突消解。下面用旧实现中的两个函数分别说明:

第一层 clsx —— 条件拼接类名:

ts
clsx("base", false && "hidden", { "active": true })
// => "base active"

第二层 twMerge —— 解决 Tailwind 冲突:

ts
// 没有 twMerge 的话:
"p-4 p-8"  // 两个 padding 同时存在!浏览器按 CSS 层叠顺序决定,不由 class 字符串顺序决定

// 有了 twMerge:
twMerge("p-4 p-8")  // => "p-8" (后面的覆盖前面的)
twMerge("text-red-500 text-blue-600")  // => "text-blue-600"

cn 合并条件类名,并处理 tailwind-merge 已知的冲突分组。任意自定义 CSS、未知工具类或配置差异仍需检查;它不是通用 CSS 冲突分析器:

tsx
<Button className="w-full mt-8">提交</Button>

四、安装你的第一个组件:Button ​

bash
npx [email protected] add button

检查 src/components/ui/button.tsx。下面只截取变体定义用于阅读,不能替换 CLI 生成的完整组件;实际依赖入口可能随 registry 更新而变化。

运行 npm run lint 检查生成结果。若文件末尾的 export { Button, buttonVariants } 触发 react-refresh/only-export-components,本课没有外部调用 buttonVariants,可改为 export { Button },保留文件内的变体定义。日后需要跨文件复用时,将变体定义拆到独立 .ts 模块;不要关闭整个项目的 React Refresh 检查。

tsx
// src/components/ui/button.tsx (简化截取)
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

// cva = Class Variance Authority (类名变体管理器)
// 它定义了一套"变体系统"——同一个组件可以有多种外观
const buttonVariants = cva(
  // 基础类名(所有变体共享)
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 ...",
  {
    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",
        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" },
  }
)

cva 的强大之处 ​

它让组件有了"换肤"的 Props API,调用者只需要传 variant="destructive" 就能切换完全不同外观:

tsx
import { Button } from '@/components/ui/button'

function Example() {
  return (
    <div className="flex gap-4 flex-wrap">
      {/* 默认深色主要按钮 */}
      <Button>确认提交</Button>
      
      {/* 红色危险按钮 */}
      <Button variant="destructive">删除项目</Button>
      
      {/* 灰色描边按钮 */}
      <Button variant="outline">取消</Button>
      
      {/* 透明幽灵按钮(常用于工具栏) */}
      <Button variant="ghost" size="icon" aria-label="关闭">✖️</Button>
      
      {/* 链接样式的按钮 */}
      <Button variant="link">查看详情</Button>
      
      {/* 自己追加特殊类名覆盖 */}
      <Button className="w-full text-lg mt-8 rounded-full bg-blue-600">
        完全自定义覆盖按钮
      </Button>
    </div>
  )
}

五、安装更多高级组件 ​

5.1 Dialog (对话框) ​

对话框纯手写坑极多(Esc 关闭、焦点锁定、遮罩点击关闭):

bash
npx [email protected] add dialog

这不仅下载文件,还会安装对应的 Radix 依赖。依赖可能通过 radix-ui 或独立的 @radix-ui/* 包引入,以生成文件为准。

下面新增独立组件,不覆盖已有 Board.tsx。调用者必须提供真实删除函数;本课只实现确认交互,未接后端时不要显示“删除成功”。

tsx
// src/components/ConfirmDeleteDialog.tsx
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
  Dialog, DialogClose, DialogContent, DialogDescription,
  DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from '@/components/ui/dialog'

export default function ConfirmDeleteDialog({ onConfirm }: { onConfirm: () => Promise<void> }) {
  const [open, setOpen] = useState(false)
  const [pending, setPending] = useState(false)
  const [error, setError] = useState('')
  const handleDelete = async () => {
    if (pending) return
    setPending(true)
    setError('')
    try {
      await onConfirm()
      setOpen(false)
    } catch {
      setError('删除失败,请重试。')
    } finally {
      setPending(false)
    }
  }
  return (
    <Dialog open={open} onOpenChange={value => { if (!pending) setOpen(value) }}>
      <DialogTrigger asChild><Button variant="destructive">删除项目</Button></DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>确定删除这个项目吗?</DialogTitle>
          <DialogDescription>请确认已不再需要此项目。关联任务应由后端按约定一起处理。</DialogDescription>
        </DialogHeader>
        {error && <p role="alert">{error}</p>}
        <DialogFooter>
          <DialogClose asChild><Button variant="outline" disabled={pending}>取消</Button></DialogClose>
          <Button variant="destructive" disabled={pending} onClick={handleDelete}>
            {pending ? '删除中...' : '确认删除'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

DialogClose 让取消按钮真正关闭弹窗。Radix 提供焦点管理、Esc 等基础行为,调用方仍需提供标题、描述和业务状态。Dialog 组合方式。

5.2 Select (下拉选择) ​

bash
npx [email protected] add select
tsx
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"

function PrioritySelector() {
  return (
    <Select defaultValue="medium">
      <SelectTrigger className="w-40" aria-label="任务优先级">
        <SelectValue placeholder="选择优先级" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="low">🟢 低优先级</SelectItem>
        <SelectItem value="medium">🟡 中优先级</SelectItem>
        <SelectItem value="high">🔴 高优先级</SelectItem>
      </SelectContent>
    </Select>
  )
}

这个示例只切换 Select 内部的选中项。写入任务时应提供 value / onValueChange,下一课会介绍控件与表单的接口适配;组件也需要可访问名称。

5.3 Toast (全局通知) ​

bash
npx [email protected] add sonner

旧 toast 组件已弃用,这里只安装 Sonner。Tailwind v4 迁移说明。在 RootLayout 中导入 Toaster,将 <Toaster theme={theme} /> 放在 main 后、根 div 内;theme 使用 Lesson 10 的 store 值:

tsx
import { Toaster } from '@/components/ui/sonner'

随后修改 Lesson 11 的 AddProjectButton:导入 Button 与 toast,将原生按钮标签替换为 Button;替换 mutation 回调如下,保留已有 mutationFn 和其他逻辑:

tsx
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'

// useMutation 配置中的回调:
onSuccess: async () => {
  await queryClient.invalidateQueries({ queryKey: ['projects'] })
  toast.success('项目已创建')
},
onError: (error) => {
  toast.error('创建失败', { description: error.message })
},

成功和失败应按实际请求结果分别提示,不能在同一个点击处理器中连续触发两种通知。


六、练习 ​

  1. 使用 npx [email protected] add input 下载输入框组件,替换项目中所有手写的 <input> 元素。
  2. 使用 npx [email protected] add card 卡片组件,去包裹 Board 看板页面里的每一个 Task 任务。
  3. (进阶)去编辑 src/components/ui/button.tsx 源码。新增一个 variant: 'magic' 选项,让其带有一层彩虹渐变背景色(bg-linear-to-r from-pink-500 via-purple-500 to-indigo-500),并在 App 中调用看看效果。

📌 本节小结 ​

你做了什么你学到了什么
了解了 shadcn/ui 的核心思想复制源代码即所有权 (Copy-Paste Component)
理解了 Headless UI 的设计哲学Radix UI 提供行为逻辑,Tailwind 提供样式
搞清了 cn() 的工作原理clsx 条件拼接 + twMerge 冲突解决
学会了 cva 变体系统一个组件多种外观的 Props API
安装使用了 Button、Dialog、Select、Toast组合式组件的使用姿势

项目驱动 · 边写边学 · React 19