shadcn/ui:我到底是不是组件库啊😭图文 + 多个场景案例详解 shadcn + tailwind 颠覆性组件开发,小伙伴直呼高端

前言

最近打算从头学习 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> 元素
  • 使用 asChildasChild 属性告诉 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,而无需关心它内部是如何用 CardBarChart 实现的。

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 installnpm 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

相关推荐
cc蒲公英4 分钟前
uniapp x swiper/image组件mode=“aspectFit“ 图片有的闪现后黑屏
java·前端·uni-app
前端小咸鱼一条7 分钟前
React的介绍和特点
前端·react.js·前端框架
王中阳Go18 分钟前
分库分表之后如何使用?面试可以参考这些话术
后端·面试
谢尔登19 分钟前
【React】fiber 架构
前端·react.js·架构
哈哈哈哈哈哈哈哈85323 分钟前
Vue3 的 setup 与 emit:深入理解 Composition API 的核心机制
前端
漫天星梦25 分钟前
Vue2项目搭建(Layout布局、全局样式、VueX、Vue Router、axios封装)
前端·vue.js
ytttr8731 小时前
5G毫米波射频前端设计:从GaN功放到混合信号集成方案
前端·5g·生成对抗网络
水鳜鱼肥1 小时前
Github Spark 革新应用,重构未来
前端·人工智能
前端李二牛1 小时前
现代CSS属性兼容性问题及解决方案
前端·css
贰月不是腻月1 小时前
凭什么说我是邪修?
前端