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 这个函数你会反复见到,它是 clsx 加 tailwind-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 要你自己拼。