一句话项目 slogan:把组件库从依赖变成源码。
这是「一天一个开源项目」系列的一篇。今天讲 shadcn/ui。
做前端的几乎都踩过同一个坑:想改一个 Button 的圆角,却发现它在 node_modules 里,写不进、覆盖不掉,只能靠 !important 硬盖。想给组件加个 prop,得去翻 node_modules 里的 .d.ts,或者干脆 patch-package 一把。
更麻烦的是升级。上游发个 patch 版本,你的 node_modules 就换了一批文件;yarn.lock 里一冲突,团队几台机器装出不同版本,样式开始飘。
shadcn/ui 的解法是:它不是组件库,而是一套组件源码的生成器。 你执行的命令往你的仓库里写文件,而不是往 node_modules 里装包。
项目速览
⭐ 126,097 Stars | 🍴 13,139 Forks | 📄 MIT 协议 | 🔧 2,575 次提交 | 🐛 1,779 个 Open Issues
截至 2026-10-21。项目创建于 2023-01-04,三年多涨到 12.6 万星。维护者 shadcn 个人贡献了 1,400 次提交,占了全部提交的一半以上——这是一个典型的「个人主导、社区跟随」结构。
阅读价值与前置说明
读完你应该能回答这几个问题:
- 为什么 shadcn 敢把
ts-morph从生产依赖里删掉,自己手写一套 AST 改写? - 24 套视觉风格怎么做到零重复代码的?
- 组件之间有依赖关系时,CLI 怎么决定先装谁?
- 改用户代码这件事,怎么保证不把文件改坏?
前置知识:
- 了解 React 组件的基本写法
- 熟悉 Tailwind CSS 的 class 组织方式
- 知道 npm 依赖与源码进仓库的根本区别
- 可选:用过 Radix UI 或 Base UI 这类无样式组件库,会更容易理解「底座」这层概念
项目背景与完整档案
shadcn/ui 的官方定位在 README 里写得很直接:"Build your own component library." 它是一个由 pnpm + Turborepo 管理的 monorepo,包含三个主要系统:
| 系统 | 位置 | 职责 |
|---|---|---|
| 官网与 Registry | apps/v4/ | Next.js 应用,既是文档站也是组件元数据的分发 API |
| CLI 工具 | packages/shadcn/ | shadcn 命令,负责拉取组件并改写你的项目 |
| 核心库 | packages/registry/ | RegistryItem schema、Codemod 引擎、安全校验 |
基础信息:
- npm 包名:
shadcn,当前版本 4.22.1 - 协议:MIT
- 当前版本技术栈:Next.js 16 (Canary) + React 19 + Tailwind CSS v4
- CLI 内部依赖:
@babel/parser、@babel/core、commander、zod
项目数据(截至 2026-10-21):
- ⭐ Stars 126,097 | 🍴 Forks 13,139
- 🔧 提交 2,575 | 🐛 Open Issues 1,779
- 👥 贡献者 200+,Top 10 贡献 1,900+ 次
- 🗓️ 创建于 2023-01-04,最近推送 2026-10-20
- 💻 代码构成:TypeScript 18.7MB / MDX 2.2MB / CSS 0.7MB
它解决什么问题
组件库的通病:
- 源码在
node_modules里,改不了。想调一个圆角尺寸,只能翻配置或硬盖!important - 需求超出 API 范围时,没有扩展点。想在 Button 里加个 loading 态,只能包一层
- 升级是黑盒。上游发新版,你只能接受或锁定,diff 出来什么完全看不见
shadcn/ui 的解法路径是递进的:
先把组件变成源码,代码进你的仓库,改写权就还给你了。但光有源码不够——你还需要一个能持续供给新组件的机制,这就是 Registry;还需要在装组件时顺手改好你的 import、依赖、配置,这就要靠 CLI 改写引擎。
使用场景:
- 长期维护的内部系统:设计规范会演进,组件需要跟着改,源码在仓库里才好做 codemod 批量调整
- 高度定制的 SaaS:一个设计稿落地成 30 个业务组件,每个都要偏离默认值一点
- 想摆脱依赖地狱的团队:组件进了仓库,
package.json里少一堆 transitive dependencies
快速上手
前提条件:一个已有的 React 项目,用 Tailwind CSS。官方支持的模板包括 Next.js、Vite、React Router、Laravel、Astro。
安装并初始化:
npx shadcn@latest init这一步会装依赖、创建 cn 工具函数、配置主题 CSS 变量。常用参数:
# 换底座(Base UI / Radix UI / React Aria)
npx shadcn@latest init --base radix
# 用默认配置(Next.js 模板 + nova 风格)
npx shadcn@latest init --defaults
# 在空目录里直接创建新项目
npx shadcn@latest init --name my-app添加组件:
npx shadcn@latest add button
npx shadcn@latest add button card dialog
npx shadcn@latest add --all先看看会改哪些文件再动手:
npx shadcn@latest add button --dry-run
npx shadcn@latest add button --diff注意:CLI 没有
install这个命令。装组件用add,套用预设用apply。
核心特性拆解
Registry 机制——组件的元数据(依赖、文件、CSS 变量)以 JSON 形式从一个 HTTP API 提供,CLI 按需拉取。官方、社区、你自己建的私有 registry 用 @scope/name 区分,私有 registry 支持鉴权。
多底座支持——同一套组件抽象下有三套无样式实现:Base UI、Radix UI、React Aria Components。init --base 选一个,之后所有组件都基于它。
多风格预设——8 套视觉风格(nova、sera、vega、maia、lyra、mira、luma、rhea)× 3 套底座 = 24 种组合。
CLI 自动改写——装一个组件,CLI 会顺手处理你的 package.json 依赖、tailwind.config、components.json 别名、import 路径、依赖的依赖。
MCP Server——npx shadcn@latest mcp init --client claude,让 AI 助手能直接查询 registry、搜索组件、拿到安装命令和审计清单。
迁移工具——migrate cn(把 clsx + tailwind-merge 合成新的 cn)、migrate icons(换图标库)、migrate radix(把散装的 @radix-ui/react-* 合并成统一包)、migrate rtl(物理属性转逻辑属性)。
深度设计剖析
设计亮点一:把 ts-morph 删掉,手写一套「精确位置编辑」的改写引擎
常规方案:
改用户代码最直接的办法是拉一个 AST 库进去——ts-morph 或 TypeScript Compiler API——解析出树,改完再 getFullText() 把整个文件重写。ts-morph 是这里的经典选择,生态成熟。
shadcn 的做法:
他们新建了 packages/registry/src/codemod/,基于 @babel/parser + @babel/core,但不重建 AST、不重新打印,而是走「解析出位置 → 生成字节偏移的文本编辑 → 原地打补丁」这条路。
核心数据结构极简:
TextEdit { start, end, text }应用编辑时从文件末尾往前做,这个顺序不能反:
At the same start, the longer edit goes first, so an insertion lands before a removal there.
那怎么保证不把用户文件改坏?他们的做法不是靠 AST 保证,而是在编辑前后各数一次语法错误,变多就抛错:
class SyntaxErrorInsertedError extends Error {
super("Manipulation error: A syntax error was inserted.")
}这个注释直接承认了它在做什么——这是 ts-morph doManipulation 的等价物。解析阶段还有几处精细处理:TS 会跳过的多余逗号用空格占位,保证所有字节偏移不变再重解析;未闭合的字符串用 "0".padEnd(...) 填平;MDX 文件把非代码行掩成 {} 单独解析。
为什么这么做:
两个原因。一是体积——CHANGELOG 里明确记着「Load ts-morph only for addRegistryItems, so the read-only API bundles about 6 MB smaller」,只读接口(view、search、docs)根本不需要 AST。二是解耦——add 命令要串 11 级 transformer(transformImport → transformRsc → transformCssVars → transformTwPrefixes → transformIcons → transformMenu → transformAsChild → transformRtl → transformFont → transformCleanup),全部脱离 AST 意味着每一级都能单独测。
工程价值与代价:
价值是那 11 级改写全部可以独立测试,且只读 API 瘦了 6MB。
代价有两个。一是降级而非报错——改 tailwind.config 失败时,CLI 会跳过并告警,而不是硬改:
Skipped {file}: could not parse it / updating it would leave it with a syntax error. Add ... to it manually.
二是 ts-morph 并没有被彻底消灭,它仍在 migrations/cn/*、构建期的 style 改写(styles/transform.ts)和 registry 构建脚本里。CHANGELOG 记录了 8 个分步迁移的 PR。也就是说这是场进行中的持久战,不是毕其功于一役。
设计亮点二:底座 × 风格 正交,靠构建期代码生成拼出 24 套组合
常规方案:
一个组件库要出「圆角风格」「方角风格」「暗黑风格」,最省事的做法是每种风格 fork 一份源码。新增一种风格 = 复制全仓,然后手工同步所有修复。
shadcn 的做法:
他们把差异拆成三层正交的维度,在构建期拼装:
- 底座(base):
apps/v4/registry/bases/{base,radix,aria}/三套手写源码,代表不同的无样式交互方案 - 风格(style):
apps/v4/registry/styles/style-{nova,sera,...}.css,每个 token 写成.cn-xxx { @apply ... } - 组合:
build-registry.mts里
const STYLE_COMBINATIONS = Array.from(BASES).flatMap((base) =>
STYLES.map((style) => ({ name: `${base.name}-${style.name}` }))
)3 × 8 = 24 套,组件行为代码只写一遍。
那 CSS 里的 token 怎么变成真实的 Tailwind class?create-style-map.ts 用 postcss 解析样式文件,walkRules 取出 @apply 里的类,再用 postcss-selector-parser 找 selector 中最后一个 cn- 类,产出一张 { "cn-xxx": "tailwind classes" } 的映射表。构建时 transform-style-map.ts 把源码里的 cn-* 占位替换成真实 class,覆盖 cva() 调用、className 属性、className 赋值、mergeProps() 四类入口。
为什么这么做:
把「风格」压缩成纯 CSS token 差异后,新增一种风格的成本就等于写一份 CSS 文件,而不是复制一遍组件逻辑。24 套组合零重复代码。
工程价值与代价:
构建阶段还留了性能决策的痕迹。writeComponentShards 的注释记录了一次踩坑:
A single flat map put every style's dynamic-import edges into every consumer's module graph (~3,800 edges on a docs page), which made the dev server compile the whole registry universe per route.
修法是按 style 分片 + index.tsx dispatcher 按需加载。
代价是 cn-* 这个中间层需要维护 allowlist(代码里留着 // TODO: all cn-* classes to be allowedlisted.),而且构建期的 style 改写链和运行时的 codemod 是两套不同实现——前者还在 ts-morph 上。
项目细节补充
一个容易被忽略的细节:CSS 是故意最后写的。add-components.ts 里的注释解释了原因:
Write CSS last so the file watcher triggers a rebuild after all component files and dependencies are in place.
否则文件监听器会在组件文件还没写完时就触发一次构建,构建出一个半成品。
安全方面也做得比较细。isSafeTarget() 会拦下含 null byte 的路径、多轮 URL 解码后仍含 .. 的路径、Windows 盘符和绝对路径越界;assertSafeDependencies() 拒绝以 - 开头的依赖名,防的是参数注入。
最后一个容易被忽略的能力:MCP Server。CLI 内置了 MCP server(packages/shadcn/src/mcp/index.ts),暴露 7 个 tool,包括搜索 registry、查看组件、生成安装命令、拿审计清单。日志走 server.sendLoggingMessage 而不是 console.log——因为 stdout 被 MCP 协议占用了。
项目资源汇总
官方资源
- GitHub:shadcn-ui/ui
- 官网与文档:ui.shadcn.com
- CLI 文档:ui.shadcn.com/docs/cli
- 开源协议:MIT
- 更新日志:仓库内
packages/shadcn/CHANGELOG.md - 贡献指南:仓库内
CONTRIBUTING.md - npm 包:
shadcn(当前 4.22.1) - MCP 文档:ui.shadcn.com/docs/mcp
相关资源
- Base UI:React 无样式组件库,shadcn 的默认底座
- Radix UI:另一套无样式组件方案,shadcn 的兼容底座
- Tailwind CSS:样式底座,所有 token 最终落到它的 class 上
媒体报道与延伸阅读
- DeepWiki 架构导读(第三方自动生成,覆盖 monorepo 架构、CLI 与 registry 的交互流程、组件分发链路)
核心要点回顾
- shadcn/ui 不是组件库,是组件源码生成器——组件进你的仓库,改写权归你
- Registry 机制提供持续供给,CLI 负责拉取并自动改写你的 import、依赖、配置
- 它把 ts-morph 从生产依赖里删掉,自研「精确位置编辑 + 语法守卫」的改写引擎
- 底座 × 风格正交 + 构建期代码生成,3 × 8 = 24 套组合零重复代码
适合谁
- 想要组件完全可定制、且愿意自己承担维护成本的开发者
- 需要在内部系统里做批量组件改造的团队
- 关注「依赖 vs 源码」这个分发模型本身的架构设计者
一句话评价
它把组件库最难的取舍——「你到底需不需要改它」——变成了一个你可以自己回答的问题。
评论区未配置
在 .env 中填入NEXT_PUBLIC_GISCUS_REPO/NEXT_PUBLIC_GISCUS_REPOSITORY_ID/NEXT_PUBLIC_GISCUS_CATEGORY/NEXT_PUBLIC_GISCUS_CATEGORY_ID 后重新构建站点即可启用评论。
