Lesson 13:专业级 UI 集成 — shadcn/ui 组件库基础
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:90~120 分钟(首次学习)
- 先修要求:完成 Lesson 12;已配置 Vite 7、React 19、Tailwind 4 与 Query 请求层
- 学习产出:配置路径别名,接入 Button、Dialog、Select 和 Sonner,能解释源码与底层依赖的关系
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:告别纯手写基础样式,使用以源码分发为主的 shadcn/ui 快速构建美观的界面,深入理解 Headless UI 的设计哲学。
📦 本节产出:将项目中的原生 HTML 元素替换为专业的 Button、Dialog、Select、Toast 等多态组件。
一、为什么是 shadcn/ui?
在之前的课程里,我们所有的按钮都是这样手写的:
<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 Design | shadcn/ui (Radix + Tailwind) |
|---|---|---|
| 自定义难度 | 😫 覆盖 CSS 容易出错 | 😊 直接改源码 |
| 升级风险 | 😫 大版本可能破坏覆盖 | 自行维护源码;底层依赖升级仍需验证 |
| 包体积 | 按所用组件与构建配置评估 | 按组件引入,仍包含对应底层依赖 |
| 无障碍 | 提供基础支持,仍需正确使用 | Radix 提供基础行为,仍需标签、对比度与键盘验证 |
| 上手速度 | ✅ 开箱即用 | 🔶 需要理解结构后才能改 |
三、初始化 shadcn/ui (Tailwind v4)
先配置 @/ 别名,否则课文里的导入和 CLI 检查会失败。在 tsconfig.json 与 tsconfig.app.json 各自的 compilerOptions 中合并以下配置,保留模板原有选项:
{
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
}更新 Vite 配置。TypeScript 的 paths 只影响类型解析,运行时仍需要对应的 alias:
// 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 安装说明。
# 在 phase2-task-manager 目录下执行
npx [email protected] init按提示选择 New York 风格和 Zinc 基色;不要依赖机器上保存的默认选项。Tailwind v4 的 components.json 中 tailwind.config 留空,CSS 指向 src/index.css。初始化后检查保留了 Lesson 10 的 .dark 变体,避免重复定义。
初始化完成后,发生了什么变化?
components.json出现在了根目录(记录你的配置)。src/lib/utils.ts出现在了项目中(它包含cn核心合并样式函数)。src/index.css被注入了大量 CSS 变量(决定了默认颜色体系)。
理解 cn 函数
当前 registry 可能生成下面的转导出,并让组件直接从 cn 包导入。保留 CLI 实际生成的实现;不要仅凭旧教程补装或删除依赖。cn 官方仓库。
// src/lib/utils.ts:当前 registry 的一种生成结果
export { cn } from "cn"旧版源码常把两个库组合起来。下面用于阅读历史代码,无需替换当前生成文件;若独立运行它,需另装 clsx@2 与 tailwind-merge@3:
// 历史实现示意
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}cn() 做了什么?
无论使用 cn 包还是旧版组合,实现都包含条件拼接和冲突消解。下面用旧实现中的两个函数分别说明:
第一层 clsx —— 条件拼接类名:
clsx("base", false && "hidden", { "active": true })
// => "base active"第二层 twMerge —— 解决 Tailwind 冲突:
// 没有 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 冲突分析器:
<Button className="w-full mt-8">提交</Button>四、安装你的第一个组件:Button
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 检查。
// 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" 就能切换完全不同外观:
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 关闭、焦点锁定、遮罩点击关闭):
npx [email protected] add dialog这不仅下载文件,还会安装对应的 Radix 依赖。依赖可能通过 radix-ui 或独立的 @radix-ui/* 包引入,以生成文件为准。
下面新增独立组件,不覆盖已有 Board.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 (下拉选择)
npx [email protected] add selectimport {
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 (全局通知)
npx [email protected] add sonner旧 toast 组件已弃用,这里只安装 Sonner。Tailwind v4 迁移说明。在 RootLayout 中导入 Toaster,将 <Toaster theme={theme} /> 放在 main 后、根 div 内;theme 使用 Lesson 10 的 store 值:
import { Toaster } from '@/components/ui/sonner'随后修改 Lesson 11 的 AddProjectButton:导入 Button 与 toast,将原生按钮标签替换为 Button;替换 mutation 回调如下,保留已有 mutationFn 和其他逻辑:
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 })
},成功和失败应按实际请求结果分别提示,不能在同一个点击处理器中连续触发两种通知。
六、练习
- 使用
npx [email protected] add input下载输入框组件,替换项目中所有手写的<input>元素。 - 使用
npx [email protected] add card卡片组件,去包裹 Board 看板页面里的每一个 Task 任务。 - (进阶)去编辑
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 | 组合式组件的使用姿势 |