2762 字
约 9 分钟
13
S14-X14 · shadcn/ui 与组件库的取舍:复制代码还是装依赖

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. 自测

  1. 装依赖与复制源码,哪一个把维护责任转移给了你?体现在哪一步?
  2. npx shadcn add button --overwrite 会做什么?为什么它危险?
  3. 为什么说 shadcn 的"按需引入"与成品库的按需引入不是一回事?
  4. 不用 Tailwind 时改造量为什么远大于"换 class"?
  5. Radix 在 Dialog 上具体承担了哪几件事?
  6. 什么情况下混合使用成品库与 shadcn 是合理的?代价是什么?
S14-X14 · shadcn/ui 与组件库的取舍:复制代码还是装依赖
https://www.clxhxhhr.top/posts/5829/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。