前言
最近打算从头学习 Next.js,尝试凭借其卓越的 SSG 支持来构建自己的新网站,那提到 Next.js,不可避免地还要说说 shadcn/ui 这个 next 的好伙伴
诶,官网看起来很高级的样子,那这篇文章我们就来一起学学这个 不称自己为组件库的"组件库"

正文
先来翻译一下上面的文字:
shadcn/ui 是一套设计精美、高可访问性的组件,同时也是一个代码分发平台。它可与您喜爱的框架和AI模型协同工作。它秉持开源精神,并开放组件源代码。
它并非一个传统的组件库,而是您用以构建自己组件库的方法论与工具集。
那么,传统组件库与 "自命清高" 的 shadcn 有什么区别呢?

基于组合的统一 API
shadcn/ui 中每个组件都共享一个统一的、基于组合的接口模式,无论是官方组件 、第三方组件 还是自己开发的新组件 都能保持匹配与协同,开发者无需学习任何新组件的API
这与 Material-UI 或者 Ant Design 等传统组件库还略有不同,传统意义的"统一 API"通常意味着库的作者为所有组件定义了一套共享式统一的 Prop 接口,例如 variant
, color
, size
等
而 shadcn/ui 的"统一 API"并非指一套固定的 Prop 集合,而是一种统一的、基于组合的构建模式和交互惯例,所有组件都遵循 React 与前端社区已广泛接受的最佳实践。
深入来说,这种新型"统一性"的特点主要体现在两个方面:
⬤ 行为与状态的统一:Radix UI
shadcn/ui 的绝大多数组件在底层都基于 Radix UI 这个 Headless UI 库[1],Radix 提供了无样式、功能完备、高度可访问的组件原语。
1\] Headless UI 库是一种前端开发模式,其核心在于将组件的逻辑和样式分离。 这种开发模式允许开发者在保持组件功能性的同时,完全控制组件的外观和风格,而不受特定 UI 框架的限制。
在行为与状态上,shadcn/ui 遵循关注点分离 ,使用 Radix 将组件的行为、状态管理与视觉表现完全解耦。
shadcn/ui 仅负责视觉层,而 Radix 仅负责核心逻辑,从而确保所有官方组件在"骨架"层面是完全统一的。
详细来说,行为上 ,Radix 组件被拆解为多个逻辑部分,例如 Dialog.Root
, Dialog.Trigger
, Dialog.Content
, Dialog.Close
,无论开发什么具体组件,这些结构都是可复用的,开发者通过组合这些部分来构建完整的组件。这种构建模式是固定的、可预测的。
而状态上 ,Radix 为状态管理提供了统一模式,开发者可以让组件自我管理状态(非可控),也可以通过 open
, onOpenChange
等 props 来精确控制状态(可控)。这种模式在所有需要管理开关状态的组件中保持一致。
⬤ 样式定制的统一:Tailwind CSS + cva
在样式上,shadcn/ui 采用 Tailwind CSS 进行样式定义,并通过 cva
(Class Variance Authority) 和 tailwind-merge
来管理样式的变体和合并,从而达成样式 API 的统一。
提到 Tailwind,那就不得不提到 原子化 CSS 理念了,shadcn 通过 Tailwind 提供了一套功能性的 CSS 类名,开发者仅需通过 className
这个 React 最原生的 Prop 来为所有组件应用样式, className
变为一个直通属性。
除此之外,shadcn 还有效借助了 cva
,它允许你以结构化的方式创建组件的样式变体。例如,一个按钮可以有 variant(default, destructive, outline)
和 size(default, sm, lg)
的变体。cva
会生成一个函数,这个函数根据你传入的 props (variant
, size
) 返回对应的 Tailwind 类名字符串。这种模式可以被应用到任何自定义或第三方组件上,从而实现样式 API 的统一。
这时你可能会疑惑:诶那出现冲突怎么办呢?没关系,我们还有 tailwind-merge
,在组合 cva
的变体类名和用户传入的 className
时,tailwind-merge
能够智能地解决冲突,确保最终应用的样式符合预期。
示例
1. 内部统一:构建仿 macOS Spotlight 的命令面板 (⌘+K
)
JavaScript
import { Dialog, DialogContent, DialogTrigger, } from "@/components/ui/dialog"
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, } from "@/components/ui/command"
import { Button } from "@/components/ui/button"
// Command + Dialog = CommandPalette,that's why 组合优于继承
export function CommandPalette() {
return (
<Dialog>
<DialogTrigger asChild>
{/* `asChild` 让任何组件都能成为触发器 */}
<Button variant="outline">打开命令面板 (⌘+K)</Button>
</DialogTrigger>
<DialogContent className="p-0">
<Command>
<CommandInput placeholder="输入命令或搜索..." />
<CommandList>
<CommandEmpty>未找到结果.</CommandEmpty>
<CommandItem>个人设置</CommandItem>
<CommandItem>账单</CommandItem>
</CommandList>
</Command>
</DialogContent>
</Dialog>
)
}
注释的地方比较关键,我们一起来看看:
- 不使用
asChild
:如果我们直接写<DialogTrigger>打开</DialogTrigger>
,它会自己渲染成一个默认的,样式简单的<button>
元素 - 使用
asChild
:asChild
属性告诉DialogTrigger
:"不要自己渲染成一个按钮,而是把你所有的功能(比如onClick
事件处理器来打开对话框)附加到你的直接子组件上"。
这里的子组件是 <Button variant="outline">
。因此,最终渲染到页面上的结果就是:这个 <Button>
组件获得了"点击后打开对话框"的能力。
这样做的好处是什么?
- 组合性 : 开发者可以把任何可交互的组件(自定义按钮、图标、一段文字等)作为
onClick
等触发器,而无需改变 Radix 原语Dialog
的逻辑。 - 样式控制 : 开发者可以完全自由地定制触发器的样式。这里我们使用了
<Button>
组件自带的variant="outline"
样式,非常方便。
2. 外部统一(第三方):为图表库 Recharts
封装带标题和边框的容器
JavaScript
import { BarChart, Bar, XAxis, YAxis } from 'recharts'; // 假设这是第三方库
import { Card, CardContent, CardHeader, CardTitle, CardDescription} from "@/components/ui/card"
// 首先创建一个统一的图表容器,它的 API 是 shadcn 风格的
export function ChartContainer({ title, description, chartData, className }) {
return (
<Card className={className}> // 统一接收 className
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
{/* 无缝置入第三方组件,属性已简化,实际场景可通过 Props 传入 */}
<BarChart data={chartData} width={400} height={200}>
<XAxis dataKey="name" />
<YAxis />
<Bar dataKey="value" fill="#8884d8" />
</BarChart>
</CardContent>
</Card>
)
}
// 使用封装后的组件
const myData = [{ name: 'A', value: 400 }, { name: 'B', value: 300 }];
<ChartContainer
title="月度收入"
description="最近六个月的收入趋势"
chartData={myData}
/>
这段样例展示了设计模式中的 封装与适配器模式 ,将一个第三方库(recharts
)的 API 和样式,封装成了符合我们自己项目设计规范的组件。
<BarChart data={chartData} ...>
体现了适配器模式的核心------转换接口。
最终,我们达成了以下优势:
-
易使用 : 如果不封装,开发者需要每次都写一遍
Card
,CardHeader
,BarChart
... 的所有模板代码。而现在只需要调用ChartContainer
并传入三个清晰明了的属性。 -
视觉一致 : 所有的图表都被
Card
包裹,并拥有统一的Header
样式,用户体验++。 -
高可维护性 & 低耦合 : 如果未来团队决定不再使用
recharts
,而是想换成一个性能更好的新图表库,只需要替换每一个<ChartContainer />
,而不用手动更换所有BarChart
的 API。 -
使用者无需得知封装实现细节 : 使用者只需要"声明"你想要一个标题为"月度收入"的图表,数据是
myData
,而无需关心它内部是如何用Card
和BarChart
实现的。
3. 外部统一(自建):创建一个带"显示/隐藏"切换功能的密码输入框
JavaScript
import React from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Eye, EyeOff } from "lucide-react"
import { cn } from "@/lib/utils"
export function PasswordInput({ className, ...props }) {
const [show, setShow] = React.useState(false)
return (
<div className={cn("relative", className)}>
{/* 使用 属性透传 传递所有 Input 的原生 props */}
<Input type={show ? "text" : "password"} {...props} />
{/* 使用 Button 的标准变体和尺寸 */}
<Button
type="button" // 避免触发表单提交
variant="ghost"
size="icon"
className="absolute top-1/2 right-2 -translate-y-1/2"
onClick={() => setShow(!show)}
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
)
}
// 使用起来就像一个原生组件口牙
<PasswordInput placeholder="请输入密码" />
还是先看注释部分,通过注释处的 属性透传 ,PasswordInput
继承了 <Input>
的几乎所有能力。你可以像使用普通 <Input>
一样给它传递 placeholder
, id
, name
, value
, onChange
, onFocus
, disabled
等等,无需在 PasswordInput
组件内部对这些属性做任何额外的处理。
按钮相关这都是一些基础操作,但也不妨碍我们感受 shadcn/ui 的美。
type="button"
覆盖了 <form>
标签内按钮的默认 type "submit"
,防止意外触发表单
variant="ghost"
和 size="icon"
是shadcn/ui
的标准化 API,简单实用就能确保视觉效果美观,不用进行额外 CSS Hack,leader 再也不用担心我的 Css 样式 bug && bug
啦。
高度可定制
shadcn/ui 将实际的组件代码交到开发者手中,开发者对代码拥有完全的控制权,可以根据自己的
1. 源码即文档
使用传统组件库开发遇到问题时,我们往往需要查阅文档、搜索 issue、抓耳挠腮(划掉),但在 shadcn/ui 中,我们可以通过源代码获得解决问题所需的庞大信息,把 "黑盒猜测 + 排除" 变为了 "白盒溯源"。
示例
eg:诶,Dialog
组件在点击页面某个特定区域时怎么没像预期一样关闭?
无需查阅文档 prop,(暂时)无需调试,(暂时)无需 issue,JUST Go to defination
JavaScript
// components/ui/dialog.tsx
<AlertDialogPrimitive.Content
ref={ref}
// ... 其他 props
onPointerDownOutside={(event) => {
// 由底层 Radix UI 控制
// 如果外部元素调用了 event.preventDefault(),这里的逻辑就不会触发
console.log("外部点击事件源: ", event.target);
}}
// ...
/>
结论 :哦~ 问题很可能是我点击的那个区域执行了 event.preventDefault()
2. 轻松定制组件
喜报!对组件的个性化定制再也不用 强行覆盖 CSS 或者 !important
启动! 了
示例
eg:为 AlertDialog
注入 framer-motion
动画,这 prop 找不到,CSS keyframes
和选择器覆盖样式冲突 都不行啊
JavaScript
// 文件: components/ui/alert-dialog.tsx (修改后)
import { motion } from "framer-motion";
// ... 其他 import
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPortal>
{/* ... */}
{/* 将 AlertDialogPrimitive.Content 用 motion.div 包裹起来 */}
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 20 }}
>
<AlertDialogPrimitive.Content ref={ref} className={cn(/*...*/, className)} {...props} />
</motion.div>
</AlertDialogPortal>
))
个性化修改组件库内的组件,实则更是对组件的一种增强,这正是 shadcn/ui 所说的:我们不是组件库。而是构建组件库的工具集。
3. AI-Friendly
告别复杂提示词,just say:这是一个基于 cva
的 shadcn/ui 组件,我需要xx
代码分发
shadcn/ui 也是一个代码分发系统。它为组件定义了一套规约,并提供了一个命令行界面(CLI)来分发它们。
⬤ 规约:与包管理一样科学易用
规约 是一种扁平化的文件结构,用以定义组件、它们的依赖项和属性。使用规约添加与管理组件即方便又科学。
eg:为自建的 PasswordInput
创建 Schema
JavaScript
// components.json (部分)
{
"tsx": {
"$schema": "...",
"components": {
// ... 官方组件
// 为自建组件设置规约
"password-input": {
"name": "PasswordInput",
// 依赖于项目内已有的 shadcn 组件
"dependencies": ["input", "button"],
// 依赖于需要从 npm 安装的包
"registryDependencies": ["lucide-react"],
// 组件的源文件路径
"files": ["components/ui/password-input.tsx"]
}
}
}
}
⬤ 命令行界面 (CLI):组件来我身边
CLI 不是简单的 npm install
,npm install
安装的是一个黑盒包,而 shadcn-ui add
运行的是一个安装脚本。
CLI 将源代码 复制到你项目的 components
文件夹,并检查、安装所有依赖。
后记
写到这里差不多就结束啦,其实还有很多能够深入的东西可以写,反响好的话再出嘿嘿👉👈
诶,可能有同学会问:老师老师我不会写 TypeScript 是不是没法用这个东西呀?
有的兄弟有的,往期回顾:
[前端] Leader:可以不用但要知道😠一文速查 TypeScript 基础知识点,字典式速查,全文干货! - 掘金
到这里就真的结束啦,我是 Sawtone,前端新手一枚,祝你开心。
相关官方网站
Next.js --> www.nextjs.cn
shadcn/ui --> www.shadcn-ui.cn
Tailwind Css --> www.tailwindcss.cn
Radix UI --> radix.zhcndoc.com