shadcn/ui 这种「把代码抄进项目」的组件方案,用半年是什么感受

第一次同事跟我说"用 shadcn 吧",我下意识去 npm install shadcn-ui,结果包根本不存在。后来才知道这东西压根不是个组件库,它是个 CLI——你跑一条命令,它把组件的源码文件直接写进你的项目目录。我第一反应是抗拒的:组件代码散落在我自己仓库里,那不就是把维护责任全甩给我了吗?

半年过去,我们一个中后台项目从头到尾用的就是 shadcn/ui。现在让我重新选,我还是会选它,但对它的代价也有了更具体的认识——好处、坑、以及我们为它定下的几条治理规矩,给还在 Antd/MUI 和 shadcn 之间犹豫的人一个参考。

先说传统组件库到底卡在哪

我用 Antd 和 MUI 都做过项目,它们最大的好处是开箱即用,最大的痛苦是改样式。

设计稿来了,要一个圆角小一点、padding 紧一点、hover 颜色换成品牌色的按钮。在 Antd 里你要么用它给的 token 体系凑(凑不出来设计师要的精确效果),要么写 CSS 去覆盖它的类名。覆盖这条路是个无底洞:

1/* 你永远在和库的默认样式打架 */
2.ant-btn-primary {
3  border-radius: 4px !important; /* 又一个 !important */
4  background: #5b21b6;
5}
6.ant-btn-primary:hover {
7  background: #6d28d9 !important;
8}

!important 越堆越多,库一升级类名结构变了,你的覆盖全失效。更难受的是深度定制——比如想改某个组件的内部 DOM 结构,加个图标到特定位置,库没给你这个插槽,你就只能放弃,或者去翻它的内部实现想歪招。

还有升级被锁死的问题。一个项目用了 Antd 4,想升 5 是个大工程,因为破坏性改动多、你的覆盖样式全要重写。结果就是很多项目永远停在某个老版本,背着技术债跑。

根子在于:组件代码不是你的,你只能从外面戳它。

shadcn/ui 的思路:代码归你

shadcn/ui 反过来。它不发运行时的包,组件源码是 Radix UI primitives(管交互逻辑和无障碍)加 Tailwind(管样式)写的,CLI 的作用就是把这些源码复制到你项目里。

初始化:

1npx shadcn@latest init

它会问你几个问题(用哪个样式风格、基础色、CSS 变量还是直接 Tailwind 类),然后生成一个 components.json

1{
2  "$schema": "https://ui.shadcn.com/schema.json",
3  "style": "new-york",
4  "rsc": true,
5  "tsx": true,
6  "tailwind": {
7    "config": "",
8    "css": "app/globals.css",
9    "baseColor": "neutral",
10    "cssVariables": true
11  },
12  "aliases": {
13    "components": "@/components",
14    "ui": "@/components/ui",
15    "utils": "@/lib/utils"
16  }
17}

这个文件记录了你的偏好,后面加组件时 CLI 照着它生成。然后要哪个组件加哪个:

1npx shadcn@latest add button

跑完之后,components/ui/button.tsx 就实实在在躺在你项目里了,大概长这样:

1import * as React from "react"
2import { Slot } from "@radix-ui/react-slot"
3import { cva, type VariantProps } from "class-variance-authority"
4import { cn } from "@/lib/utils"
5
6const buttonVariants = cva(
7  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50",
8  {
9    variants: {
10      variant: {
11        default: "bg-primary text-primary-foreground hover:bg-primary/90",
12        outline: "border border-input bg-background hover:bg-accent",
13        ghost: "hover:bg-accent hover:text-accent-foreground",
14      },
15      size: {
16        default: "h-9 px-4 py-2",
17        sm: "h-8 px-3 text-xs",
18        lg: "h-10 px-8",
19      },
20    },
21    defaultVariants: { variant: "default", size: "default" },
22  }
23)
24
25function Button({
26  className,
27  variant,
28  size,
29  asChild = false,
30  ...props
31}: React.ComponentProps<"button"> &
32  VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
33  const Comp = asChild ? Slot : "button"
34  return (
35    <Comp
36      className={cn(buttonVariants({ variant, size, className }))}
37      {...props}
38    />
39  )
40}
41
42export { Button, buttonVariants }

关键在于:那个圆角小一点的需求,现在我直接打开这个文件改 rounded-md 就行。没有 !important,没有和库打架,因为这就是我自己的代码。

这里多说一句 add 之后的工作机制。CLI 不只是复制一个文件,它会顺带把这个组件依赖的 Radix 包装进你的 package.json、把用到的工具函数(比如 cn)补到 lib/utils.ts、必要时往 globals.css 里加 CSS 变量。也就是说,组件运行需要的运行时依赖(Radix)还是真实的 npm 包,shadcn 复制进来的是"胶水层"——把 Radix 的行为和 Tailwind 的样式缝在一起的那部分代码。这个分界很重要,后面讲无障碍和同步时都绕不开它。

cn 这个函数你会反复见到,它是 clsxtailwind-merge 的组合,作用是合并 className 并解决 Tailwind 类冲突(比如外面传 px-6 进来会正确覆盖默认的 px-4,而不是两个都留着):

1// lib/utils.ts
2import { clsx, type ClassValue } from "clsx"
3import { twMerge } from "tailwind-merge"
4
5export function cn(...inputs: ClassValue[]) {
6  return twMerge(clsx(inputs))
7}

正因为有它,外部传 className 进来能可靠地覆盖组件默认样式,这是 shadcn 组件能被"从外面微调"的基础。

好处:可改、无黑盒、按需

用了半年,最大的实感是三点。

完全可改。 设计师任何刁钻需求,我都能直接进源码改,不存在"库不支持"。要给 button 加个 loading 态、加个内置图标位、改交互逻辑,改就是了。

没有运行时黑盒。 出 bug 的时候,我能一路读到组件最底层,看清楚每个 className 怎么来的、Radix 的状态怎么流转。Antd 出问题我得去 node_modules 里翻压缩过的源码连蒙带猜,shadcn 的组件就在我编辑器里,断点随便打。

按需取。 我只 add 用得到的组件,没用的根本不进项目。不存在装一个库进来几百个组件都打包的情况。bundle 里只有我真正用的那几个。

代价:维护责任真的转移了

但天下没有白吃的午餐。代码归你,意味着维护它的责任也归你。

没有版本号。 你没法说"我们用的是 shadcn 1.2.3"。组件是某个时间点 add 进来的快照,之后官方仓库里这个组件改了、修了 bug、改进了无障碍,你这边纹丝不动——除非你手动去同步。

同步靠手动 diff。 官方更新了 dialog 组件,我想拿到改进,得自己去对比新旧源码,手动把改动 merge 进我已经改过的本地版本。如果我本地改得多,这个 merge 会很痛。CLI 现在 add 时会提示文件已存在、要不要覆盖,但它不会智能三方合并你的本地修改。

1# 重新 add 会提示覆盖,但你的本地改动会被冲掉
2npx shadcn@latest add dialog
3# 实践中我更倾向手动 diff,而不是盲目覆盖

多人改同一个组件容易乱。 这是我们踩得最实的坑。button.tsx 是公共基础组件,A 为了一个页面给它加了个 variant,B 不知道又改了 size 的逻辑,几周后这个文件谁都不敢动,因为不清楚改它会影响哪些页面。基础组件变成了一个没人负责的公地。

我们后来定的治理规矩

针对上面的问题,团队定了几条约定,落地后顺畅很多。

规矩一:不直接改 components/ui 里的基础组件,要扩展就包一层。 components/ui 下的文件视为"准官方",只在初始化定制(比如统一圆角、间距)时改一次,之后冻结。业务需要变体就在外面包:

1// components/ui/button.tsx 保持接近官方,方便将来同步
2// components/loading-button.tsx 是我们自己的
3import { Button } from "@/components/ui/button"
4import { Loader2 } from "lucide-react"
5
6export function LoadingButton({
7  loading,
8  children,
9  ...props
10}: React.ComponentProps<typeof Button> & { loading?: boolean }) {
11  return (
12    <Button disabled={loading || props.disabled} {...props}>
13      {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
14      {children}
15    </Button>
16  )
17}

这样基础组件保持干净、好同步官方更新,业务定制都在自己的封装层里,谁负责哪个封装清清楚楚。

规矩二:用 registry 自建私有组件源。 shadcn 支持自定义 registry——你把团队内部的复合组件(比如带搜索的下拉、统一的数据表格)按它的格式发布到一个 URL,别的项目就能 npx shadcn@latest add 你的私有组件。我们公司几个项目共享一套业务组件,就是靠这个,不用各自重复抄。

1npx shadcn@latest add https://ui.internal.company.com/r/data-table.json

规矩三:基础组件改动走 review,并记录改了什么。 因为没有版本号,我们在仓库里维护一个简短的清单,记下每个 components/ui 文件相对官方改了哪些地方,将来同步官方更新时照着这个清单合并。

具体做法是在仓库根放一个 COMPONENTS_NOTES.md,每条记两样东西:组件名、相对官方源码的改动点。比如:

button.tsx:默认 rounded-md 改成 rounded-lg;加了 size: "xl"
dialog.tsx:未改,保持官方
input.tsx:focus ring 颜色绑到 --color-primary

同步官方更新时,我先把官方最新源码拉到一个临时文件,和本地 diff,对照这份笔记把我们的改动重新应用上去。听起来麻烦,但实际上基础组件改动很少,半年也就同步过两三次,每次十几分钟。比起 Antd 大版本升级动辄几天的迁移,这点成本可以接受。

规矩四:约束 add 的随意性。 我们不让每个人想加组件就 add。新增基础组件要在群里说一声、走一次简单 review,避免出现两个人各自 add 了功能重叠的组件、或者把同一个组件 add 了两遍互相覆盖。CLI 太方便反而需要一点流程上的克制。

无障碍是 Radix 给的

有个常被误解的点:很多人以为 shadcn 既然让你随便改代码,那无障碍(a11y)是不是也得自己操心?其实不是。交互和无障碍这块是底层的 Radix primitives 负责的——焦点管理、键盘导航、ARIA 属性、屏幕阅读器支持,都在 Radix 那层做好了。shadcn 的源码只是在 Radix 外面套了 Tailwind 样式。

所以你改的是外观(className),动不到 Radix 的可访问性内核,除非你刻意去改组件用的 Radix 部分。这点比从零手写组件强太多——自己写一个符合 WAI-ARIA 的下拉菜单或对话框,那是相当专业的活,Radix 帮你扛了。

和 Tailwind v4 的配合

shadcn 现在和 Tailwind v4 配合得很顺。v4 把配置挪到了 CSS 里,主题用 @theme 和 CSS 变量定义,shadcn 的颜色 token 正好就是一组 CSS 变量:

1/* app/globals.css */
2@import "tailwindcss";
3
4@theme {
5  --color-primary: oklch(0.55 0.22 264);
6  --color-primary-foreground: oklch(0.98 0 0);
7}

组件里用 bg-primary,颜色由 CSS 变量驱动。要换主题、做暗色模式,改变量就行,组件源码一个字不用动。暗色模式我们就是在 .dark 选择器下重定义那组变量:

1.dark {
2  --color-primary: oklch(0.65 0.2 264);
3  --color-primary-foreground: oklch(0.18 0 0);
4}

切换 <html class="dark"> 整套组件就跟着变,零改动。这套和 shadcn"代码归你、样式靠 Tailwind"的理念是天然契合的。

有一个升级时踩到的小坑:从 Tailwind v3 迁到 v4 时,shadcn 早期生成的组件用的是 hsl() 包裹的颜色变量写法,v4 推荐 oklch,新旧 add 进来的组件颜色定义风格不一致,得统一一遍。如果你项目跨了 Tailwind 大版本,记得检查一下 globals.css 里颜色变量的格式有没有混用。

和 Antd/MUI 摆在一起算笔账

把三者放一起,差别其实是"控制权在谁手里"这条轴。

Antd/MUI 是黑盒交付:你 install 一个版本,享受开箱即用,代价是定制能力受限、升级有迁移成本、bundle 里有你用不到的东西。组件代码不归你,出深层问题你只能等官方修或者想歪招绕。

shadcn 是源码交付:组件进你仓库,定制无上限、无运行时黑盒、按需取,代价是你接管了维护和同步的责任,需要团队约定来防止公地悲剧。

我们项目里还真同时用过两种。初期赶进度时为了一个复杂的可编辑表格,临时引了一个第三方的表格库(类似 Antd Table 那种),因为自己用 shadcn 拼一个成本太高。后来这块稳定了、定制需求变多,又把它拆掉换成基于 TanStack Table 加 shadcn 自己拼的版本。这说明一件事:不一定非要二选一,核心交互密集、需要深度掌控的组件用 shadcn 自己拼,边角的、一次性的复杂组件该用现成库就用,别为了"纯粹"硬抗。

衡量标准我会压成一句话:这个组件你未来会反复改吗?会,就值得用 shadcn 把代码拿到手里;不会、只是用一次,那现成库省下的时间更划算。

到底适不适合你的项目

用下来我的判断是这样。

适合: 设计要求高、需要大量定制的产品;团队熟悉 React 和 Tailwind;项目长期维护、愿意为掌控力承担一点同步成本;想避免被某个组件库版本锁死的。中后台、自有产品、设计驱动的项目,shadcn 很合适。

不太适合: 团队不熟 Tailwind(那 shadcn 的源码对你就是另一种黑盒,改起来照样懵);快速验证的原型、外包短平快的活,这种场景"开箱即用、不用管维护"的 Antd 反而省事;以及强烈依赖现成复杂业务组件(成套的可编辑表格、复杂日期范围选择器等)的项目,Antd 这类大库现成的更多,shadcn 要你自己拼。