npm i antd 和 npx shadcn add button 看起来都在"拿一个按钮",但两者的所有权完全不同:前者你拿到的是运行时依赖,后者你拿到的是一份可以随便改的源码。
这个差别决定了后面三年的升级、定制与维护成本。这篇不劝谁,只把账算清楚。
1. 两种模式的本质
| 维度 | 装依赖(AntD / Element Plus) | 复制源码(shadcn/ui) |
|---|---|---|
| 产物形态 | node_modules 里的编译产物 |
仓库里的 .tsx 文件 |
| 升级方式 | 改 package.json 版本 |
重新跑 CLI,逐文件 diff 合并 |
| 定制深度 | 受 CSS 变量与 token 约束 | 想改哪行改哪行 |
| 破坏性变更 | 大版本可能改 API | 上游变化不影响你的代码 |
| 包体积 | 按需引入仍需引依赖 | 只引你复制的那几个 |
| 出问题找谁 | 提 issue 等维护者 | 自己改,改完就是你的 |
关键区别是"谁负责"。装依赖是把维护责任外包,复制源码是把维护责任收回来。前者省人力、后者省束缚。
2. shadcn/ui 到底做了什么
它不是组件库,是一组代码生成器 + 一套约定。CLI 从注册表拉取源码写进你的仓库,同时装两类依赖:
// components.json
{
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
npx shadcn@latest init
npx shadcn@latest add button dialog table form
npx shadcn@latest add button --overwrite # 覆盖本地改动,慎用
| 组成 | 提供什么 | 归属 |
|---|---|---|
components/ui/*.tsx |
组件源码 | 你的仓库 |
radix-ui primitives |
行为、状态机、无障碍 | 依赖 |
tailwindcss |
样式与主题变量 | 依赖 |
class-variance-authority |
变体(variant)体系 | 依赖 |
tailwind-merge / clsx |
类名合并 | 依赖 |
components.json |
生成器配置 | 你的仓库 |
生成出来的组件几乎都靠一个 cn() 拼类名,它是所有样式覆盖的入口:
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); // 后者覆盖前者,冲突类名才不乱
}
它没有提供的东西要清楚:没有全局主题 Provider、没有统一的数据表格、没有表单校验引擎(form 只是把 react-hook-form 包了一层)。
3. 收益:四条实打实的
// 复制进来的 Button,直接改
const buttonVariants = cva(
'inline-flex items-center rounded-md text-sm font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
ghost: 'hover:bg-accent hover:text-accent-foreground',
brand: 'bg-[oklch(0.62_0.19_255)] text-white', // 加自己的变体
},
size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-12 px-6' },
},
defaultVariants: { variant: 'default', size: 'md' },
},
);
| 收益 | 具体表现 |
|---|---|
| 无黑盒 | 样式冲突时直接改源码,不用 !important 或 :global 覆盖 |
| 无破坏性升级 | 上游改 API,你不升级就不受影响 |
| 按需引入 | 没用到的组件根本不存在于仓库,包体积等于实际使用量 |
| 设计一致性强 | 所有组件共享同一套 CSS 变量,改一处全局生效 |
第三条常被误解:按需引入在成品库里也能做到(babel-plugin-import),但那个"按需"是构建期摇树,源码仍在依赖里;shadcn 的"按需"是仓库里干脆没有。
4. 代价:四条必须提前认
| 代价 | 具体表现 | 缓解 |
|---|---|---|
| 升级要自己合并 | CLI 重新生成会覆盖你的改动 | 用 git diff 手工挑,别 --overwrite |
| 组件数量落在自己头上 | 30 个组件就是 30 份要维护的代码 | 只 add 真正用到的 |
| 一致性靠自觉 | 两个人各改各的,风格会漂 | 变体集中在 cva,禁止内联样式 |
| 缺复杂组件 | 没有 DataGrid、没有树形选择器 | 复杂件单独选型,别硬改 |
"升级要自己合并"是最容易被低估的一条。上游修了一个 Dialog 的焦点 bug,你要自己找到对应 diff 再合进你那已经改花的版本。这意味着你得定期回看上游,否则等于永久 fork。
5. 与成品库的对照
| 维度 | shadcn/ui | Ant Design | Element Plus |
|---|---|---|---|
| 开箱即用度 | 低(要自己组合) | 高(组件齐全) | 高 |
| 设计一致性 | 取决于你 | 强(有成体系规范) | 强 |
| 主题定制成本 | 低(改源码/变量) | 中(token + less 变量) | 中(CSS 变量 + scss) |
| 升级风险 | 低(不升就不变) | 中(大版本 API 变动) | 中 |
| 包体积 | 等于实际用量 | 按需引入后中等 | 中等 |
| 无障碍 | 强(Radix 承担) | 中上 | 中 |
| 团队熟悉度门槛 | 需懂 Tailwind + Radix 心智 | 低 | 低 |
| 复杂组件(表格/穿梭框) | 无,需另配 | 齐全 | 齐全 |
| 适合的团队规模 | 2–10 人,有前端设计能力 | 任意 | 任意 |
没有"更好",只有"更合适"。后台管理系统要 40 个表格与表单,用成品库能省两个月;面向用户的产品要把设计做到像素级,成品库的覆盖样式会成为主要工作量。
6. 选型判据
| 问题 | 答"是" → 倾向 | 答"否" → 倾向 |
|---|---|---|
| 设计稿会大量偏离通用组件形态吗? | shadcn | 成品库 |
| 需要 DataGrid、树、穿梭框等重型组件吗? | 成品库(或混用) | shadcn |
| 团队有人能维护 30 份组件源码吗? | shadcn | 成品库 |
| 升级节奏必须跟随上游安全修复吗? | 成品库 | shadcn |
| 首屏体积是核心指标吗? | shadcn | 无所谓 |
| 项目寿命小于 6 个月吗? | 成品库 | —— |
混合方案是可行的:成品库做后台,shadcn 做面向用户的那几个页面。代价是两套设计语言,需要一套 token 桥接(同一组 CSS 变量喂给两边)。这在同一 monorepo 里是可控的。
7. Tailwind 的绑定风险
shadcn 的组件源码里写满了 Tailwind 类名。不用 Tailwind 的话,改造量不是"换个 class",而是重写样式层。
/* globals.css —— 主题变量是唯一稳定的接口 */
@layer base {
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.55 0.2 262);
--radius: 0.5rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.7 0.16 262);
}
}
| 改造目标 | 工作量 | 说明 |
|---|---|---|
| 保留 Tailwind,只换配色 | 小 | 只改 CSS 变量 |
| 保留 Tailwind,换构建(Vite 插件版) | 小 | 换 @tailwindcss/vite |
| 换成 CSS Modules | 大 | 每个组件的类名全要重写 |
| 换成 UnoCSS | 中 | 预设兼容层能覆盖大部分类名 |
| 换成 styled-components | 大 | 结构要重排 |
先确认团队接受 Tailwind 再选 shadcn。反过来做,等于把 30 个组件重写一遍,还不如一开始就用成品库。
8. Radix 承担了什么
无障碍不是加两个 aria-* 就完事。一个 Dialog 要处理:焦点捕获、Esc 关闭、打开时禁止背景滚动、关闭后焦点还给触发元素、aria-modal 与标题关联、层级与 inert 的配合。
| 交互细节 | Radix 提供 | 自己写要处理 |
|---|---|---|
| 焦点陷阱 | 内建 | Tab 循环、初始聚焦、退出归还 |
Esc / 点击外部关闭 |
内建 | 事件冒泡与嵌套弹层冲突 |
| 滚动锁定 | 内建 | 移动端 position: fixed 回弹 |
| 键盘导航(菜单、选择器) | 内建(方向键、Home/End) | 每个组件单独实现 |
| 读屏播报 | 内建(aria-* 与 live region) |
状态变化时的播报时机 |
| 受控/非受控双模式 | 内建 | 两套状态同步 |
这是 shadcn 最被低估的价值:你复制的是样式,白拿的是行为。把这层换掉,等于重新实现一遍 WAI-ARIA 模式。
9. 排错:六种"用着用着不对了"
| 现象 | 原因 | 处理 |
|---|---|---|
| 改的样式被上游覆盖 | 跑了 add --overwrite |
用 git diff 恢复,之后别加该参数 |
| 暗色模式有两套样式 | 同时用了 dark: 前缀与 CSS 变量 |
只用变量,dark 类只切变量值 |
| 主题变量散落各处 | 有人直接写死颜色 | 统一在 :root / .dark 定义,禁止硬编码 |
| 复制了 30 个组件只用 5 个 | 一次 add 全量 |
删掉未使用的,或从注册表按需加 |
| 组件间样式互相污染 | tailwind-merge 没配自定义类 |
在 twMerge 里注册自定义前缀 |
| 表单校验不触发 | form 组件只是 react-hook-form 的皮 |
校验规则要写在 useForm 里 |
第一条值得展开:--overwrite 是"我确定不要本地改动"的意思,很多人当成"更新一下"。它没有 diff、没有备份,直接覆盖。
10. 边界:什么时候不该用它
| 场景 | 更合适 | 理由 |
|---|---|---|
| 后台管理系统、交付周期短 | Ant Design / Element Plus | 表格、表单、权限组件齐全 |
| 需要成熟设计规范背书 | 成品库 | shadcn 不提供设计系统 |
| 团队无 Tailwind 经验 | 成品库 | 学习成本会摊到每个页面 |
| 需要长期跟随上游安全修复 | 成品库(或定期回看上游) | 复制模式默认不升级 |
| 需要 DataGrid 级别表格 | 专用表格库(TanStack Table 等) | shadcn 的 table 只是样式壳 |
| 多端(小程序/RN) | 各自生态的组件库 | Tailwind + Radix 不跨端 |
一句话:shadcn/ui 卖的是"控制权",不是"省事"。要控制权就接受维护成本,要省事就别选它。
11. 自测
- 装依赖与复制源码,哪一个把维护责任转移给了你?体现在哪一步?
npx shadcn add button --overwrite会做什么?为什么它危险?- 为什么说 shadcn 的"按需引入"与成品库的按需引入不是一回事?
- 不用 Tailwind 时改造量为什么远大于"换 class"?
- Radix 在
Dialog上具体承担了哪几件事? - 什么情况下混合使用成品库与 shadcn 是合理的?代价是什么?