原创 dragonZhang 2025-01-22 08:30 重庆
点击关注公众号,“技术干货”及时达!
点击关注公众号,“技术干货” 及时达!
1. 什么是 「ShadCN UI」?
自 2023 年 3 月发布第一个版本以来,「ShadCN UI」(官方称呼为 shadcn/ui,下称 shadcn)迅速在前端圈掀起了一股热潮。截至今天(2025 年 1 月 9 日),它在 GitHub 上的 Star 数已经突破了 「78.1k」,成为近年来增长最快的前端 UI 库之一。这一惊人的数据,不仅体现了其技术和设计理念的成功,也证明了它在开发者群体中的受欢迎程度。
从下方的 Star History 可以清晰地看出,shadcn 的受欢迎程度有多么高,超过老大哥 MUI 和 antd 成为最流行的组件库似乎只是时间问题:
在 Best Of JS 的最受欢迎项目(Most Popular Projects Overall)中,shadcn 更是连续两年(2023、2024)拿下第一名。(题外话:上一次做到这一成就的是 Vue2,2016-2019 连续四年占据榜首)
shadcn 能获得如此巨大的成功,主要是由于其复制/粘贴的特性。
❝Accessible and customizable components that you can copy and paste into your apps. Free. Open Source. Use this to build your own component library.
您可以将可访问且可自定义的组件复制并粘贴到您的应用程序中。自由并且开源。用它来构建您自己的组件库。
❞
相比于传统的组件库,shadcn 的组件不是通过安装 npm 包使用,而是通过 CLI 进行安装:
$ pnpm dlx shadcn@latest add badge
CLI 会将对应的组件放到源代码项目本身中:
开发者可以对组件的逻辑和样式进行任意的修改,或者对组件进行扩展,来开发更多复杂的组件。
2. 为什么是复制粘贴?
在 shadcn 官网我们可以看到这样一段话:
❝Why copy/paste and not packaged as a dependency?
为什么复制/粘贴而不打包为依赖项?
The idea behind this is to give you ownership and control over the code, allowing you to decide how the components are built and styled.
其背后的想法是赋予您对代码的所有权和控制权,允许您决定如何构建组件和设计样式。
Start with some sensible defaults, then customize the components to your needs.
从一些合理的默认值开始,然后根据您的需要自定义组件。
One of the drawbacks of packaging the components in an npm package is that the style is coupled with the implementation. The design of your components should be separate from their implementation.
将组件打包在 npm 包中的缺点之一是样式与实现耦合。组件的设计应该与其实现分开。
❞
在组件开发中,「设计」与「实现」的分离是一个重要的理念。其中,设计可以理解为「样式」,而实现则对应组件的逻辑或 API。这一理念在我们讨论组件库设计的第一期、介绍 「Radix UI」 时就已经提出过。
传统组件库通常将样式与实现紧密耦合,并通过有限的 API 向外暴露样式和逻辑的修改接口(例如一个 Button 组件可能通过 size 属性来调整按钮的大小)。但当开发者需要满足更复杂的需求时,往往会面临样式覆盖成本过高的问题。
Radix 为了把设计和实现分离,采用了 unstyled components 的方式,只提供具有可访问性的无样式组件,开发者可以根据自己的设计系统或 CSS 工程化方案(如 Tailwind CSS、CSS Modules、Styled Components 等),为这些无样式组件添加自定义的样式封装,从而实现设计和实现的解耦。
虽然理想很丰满,但 Radix 的开发体验实在是差强人意,特别是对于没有成熟设计系统的小团队 or 独立开发者而言,设计 token system 的复杂性和为每个组件编写样式的高成本让人望而却步。
而 shadcn 则巧妙融合了 Radix UI 的逻辑灵活性与 Tailwind CSS 的样式优势,通过“复制粘贴”提供了开箱即用且带样式的组件,同时保留了灵活定制的能力,大幅降低了开发门槛与重复工作。
从上面这幅图我们看到:
传统组件库包含了 behavior 和 style,但是严重耦合,定制化能力有限
Radix 专注于 behavior,但完全不提供 style,需要开发者自行设计封装
中间的 Chakra UI 同时涵盖了 behavior、style 和 css 工程化方案,提供了更均衡的体验,这部分我会在下期详细介绍
而 shadcn 则不仅包含了图中的所有内容,还具备比 Chakra UI 更强的可定制性,让开发者既能享受开箱即用的体验,又能灵活调整组件以满足不同需求。可以说,ShadCN 是目前最接近“完美”的组件库,这也是它如此流行的重要原因之一。
3. 总体设计:架构和实现
3.1. 组件
ShadCN 的整体设计可以分为「组件设计」和「CLI 设计」两部分。其中,组件设计部分的理念和架构可以参考 The anatomy of shadcn/ui。组件设计的内容基于这篇文章进行整理和补充。
简单来说,shadcn 可以分为:
Style Layer:主要负责对组件进行类名合并和变体管理
Structure & Behavior Layer:shadcn 所依赖的一系列无头组件库,大部分组件基于 Radix UI 进行封装,但 Table、Form、Date Pickers 等少数组件基于其他库进行开发
3.1.1. 变体管理
每个组件都可能存在多种变体,在大小、颜色、状态等方面存在差异。shadcn 是如何进行变体管理的?我们以较为简单的 Button 组件为例:
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
我们注意到组件通过 cva 库声明了 button 的变体属性 buttonVariants,包含变体:
variant:button 的类型。分为 default、destructive、outline、secondary、ghost 和 link
size:button 的大小。分为 default、sm、lg 和 icon
cva 这个库的作用就是根据 props 或 state 来动态切换类名,从而切换组件的样式。并且通过集中定义样式规则,使变体管理更加规范化。
由于 Button 组件的代码存在本地,我们可以轻易地扩展变体的类型:
const App = () => {
return (
<>
<div>
<Button variant={'default'}>Click me</Button>
<Button variant={'destructive'}>Click me</Button>
<Button variant={'ghost'}>Click me</Button>
<Button variant={'link'}>Click me</Button>
<Button variant={'outline'}>Click me</Button>
<Button variant={'secondary'}>Click me</Button>
{/* 我们可以轻松地扩展变体,只需要修改 buttonVariants 即可!*/}
<Button variant={'disabled'}>我是按钮的禁用变体</Button>
</div>
<div>
<Button size={'default'}>Click me</Button>
<Button size={'icon'}>Click me</Button>
<Button size={'lg'}>Click me</Button>
<Button size={'sm'}>Click me</Button>
</div>
</>
);
};
3.1.2. 类名合并
我们注意到 shadcn 使用到了 cn 这个 util 来合并类名:
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
这里使用 clsx 来合并类名。除此之外还使用了 twMerge,这是由于 CSS 级联的工作方式,最终样式是由 css 规则的优先级决定的,而不受类名出现顺序影响,因此有些时候会发生样式冲突的情况。这种时候就需要用到 twMerge 来做类名合并,使得后出现的类名能够覆盖先出现的类名(比如后出现的 p-3 覆盖先出现的 px-2)。更多内容详见 tailwind-merge 文档。
3.1.3. 基于无头组件库的封装
如前所述,shadcn 的大部分组件都是基于 Radix UI 进行搭建的。以 Tooltip 为例:
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
我们可以看到,shadcn 只是基于 Radix 用 tailwind 添加了样式,用 cn 做类名合并, 并做了 props 合并和 ref 转发,几乎没有做什么特别的事情。
3.2. CLI
3.2.1. 使用 commander 库创建 CLI
通过使用 CLI,我们可以轻松的将组件添加到项目当中。CLI 的入口在 packages/shadcn/src/index.ts 中:
process.on("SIGINT", () => process.exit(0))
process.on("SIGTERM", () => process.exit(0))
async function main() {
const program = new Command()
.name("shadcn")
.description("add components and dependencies to your project")
.version(
packageJson.version || "1.0.0",
"-v, --version",
"display the version number"
)
program
.addCommand(init)
.addCommand(add)
.addCommand(diff)
.addCommand(migrate)
.addCommand(info)
.addCommand(build)
program.parse()
}
main()
当我们执行 npx shadcn init/add/... 时,就会调用 main 函数,使用 commander.js 来创建命令行界面,从而添加并解析用户命令。
这里我们以 add 命令为例来解析 CLI 的设计。add 命令也是使用 commander.js 创建的 CLI:
export const add = new Command()
.name("add")
.description("add a component to your project")
.argument(
"[components...]",
"the components to add or a url to the component."
)
.option("-y, --yes", "skip confirmation prompt.", false)
.option("-o, --overwrite", "overwrite existing files.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-a, --all", "add all available components", false)
.option("-p, --path <path>", "the path to add the component to.")
.option("-s, --silent", "mute output.", false)
.option(
"--src-dir",
"use the src directory when creating a new project.",
false
)
.action(async (components, opts) => {
// 核心代码
})
这里通过 argument 方法来声明 components 参数,通过 option 方法来声明选项,通过 action 方法来根据参数和选项执行具体命令。
commander 内部会对选项名称进行映射,例如命令 npx shadcn add button --all --yes
,--all
和--yes
都会被处理为对象的键,即:
const components = ['button']
const opts = {
all: true,
yes: true,
overwrite: false,
// ...其他选项默认值
}
3.2.2. 使用 zod 动态校验类型
之后 shadcn 使用 zod 库对 components 和 opts 的类型做动态校验,如果类型有误则通过 logger 打印错误:
export const addOptionsSchema = z.object({
components: z.array(z.string()).optional(),
yes: z.boolean(),
overwrite: z.boolean(),
cwd: z.string(),
all: z.boolean(),
path: z.string().optional(),
silent: z.boolean(),
srcDir: z.boolean().optional(),
})
try {
// 校验参数和选项类型
const options = addOptionsSchema.parse({
components,
cwd: path.resolve(opts.cwd),
...opts,
})
// ...
} catch (error) {
logger.break()
handleError(error)
}
export function handleError(error: unknown) {
// ...
if (error instanceof z.ZodError) {
// 通过 logger 打印错误。logger 本质就是 console.log,不过是用了 highlighter 做高亮
logger.error("Validation failed:")
for (const [key, value] of Object.entries(error.flatten().fieldErrors)) {
logger.error(`- ${highlighter.info(key)}: ${value}`)
}
logger.break()
process.exit(1)
}
// ...
}
3.2.3. 使用 prompts 库展示交互提示
对于 add 命令,我们的使用方式一般有三种:
命令行指定:pnpm dlx shadcn add [components]
全量安装:pnpm dlx shadcn add --all
交互式选择:pnpm dlx shadcn add
在代码当中,为了处理「全量安装」和「交互式选择」的情况,我们会判断 components 参数是否为空。如果为空,则进行全量安装,或是交互式选择,即通过 prompts 库来展示上图的「提问」和「多选列表」:
if (!options.components?.length) {
options.components = await promptForRegistryComponents(options)
}
async function promptForRegistryComponents(
options: z.infer<typeof addOptionsSchema>
) {
// ...
// 全量安装,返回 registry 中的所有组件
if (options.all) {
return registryIndex.map((entry) => entry.name)
}
// 使用 prompts 库来展示上图的「提问」和「多选列表」
const { components } = await prompts({
type: "multiselect",
name: "components",
message: "Which components would you like to add?",
hint: "Space to select. A to toggle all. Enter to submit.",
instructions: false,
choices: registryIndex
.filter((entry) => entry.type === "registry:ui")
.map((entry) => ({
title: entry.name,
value: entry.name,
selected: options.all ? true : options.components?.includes(entry.name),
})),
})
// ...
const result = z.array(z.string()).safeParse(components)
if (!result.success) {
logger.error("")
handleError(new Error("Something went wrong. Please try again."))
return []
}
return result.data
}
3.2.4. 从 registry 获取组件信息
shadcn CLI 用到了一个很常见的概念:registry。例如我们用 npm 安装包时,一般就是从 npm 的官方 registry 安装的。
那么 shadcn 为什么要抽象出 registry 这个概念?实际上这是为了实现远程安装组件。比如通过命令 npx shadcn add https://acme.com/registry/navbar.json
,只需要给出一个 url,就可以从远程的 registry 安装组件到本地,这也就是为什么有人会说 shadcn 是组件界的 npm。
当我们使用 shadcn 安装组件时,实际上就是从官方 registry 中安装组件(以 accordion 为例):
当我们使用 URL 作为参数来安装组件时,shadcn 会通过 resolveDependencies 函数来解析这个 URL,并根据不同的情况采取不同的处理方式:
如果参数是一个 URL:
shadcn 会直接将该 URL 作为自定义的 registry。然后向这个 URL 发起请求,尝试下载对应的 JSON 文件。通过解析 JSON 文件中的内容,获取组件的相关信息(例如组件的依赖、样式、配置等)。
如果参数不是 URL:
说明传入的是一个组件名称,shadcn 会从官方的 registry (即 https://ui.shadcn.com/r/styles/${组件 style}/${组件名}.json)拉取组件信息。shadcn 根据组件的 style 和名称动态生成 URL,下载对应的 JSON 文件,从而获得组件信息。
async function resolveRegistryDependencies(
url: string,
config: Config
): Promise<string[]> {
const visited = new Set<string>()
const payload: string[] = []
async function resolveDependencies(itemUrl: string) {
const url = getRegistryUrl(
isUrl(itemUrl) ? itemUrl : `styles/${config.style}/${itemUrl}.json`
)
// 示例说明:
// 1. npx shadcn install --url https://example.com/custom-button.json
// 这种情况会解析 https://example.com/custom-button.json 并根据 JSON 中的内容安装组件。
// 常用于团队内部的私有组件库,或者从其他非官方来源获取组件。
//
// 2. npx shadcn install button
// 这里 button 是组件名称,默认从官方 registry 下载对应的 JSON 文件
// ...
try {
const [result] = await fetchRegistry([url])
const item = registryItemSchema.parse(result)
payload.push(url)
if (item.registryDependencies) {
for (const dependency of item.registryDependencies) {
await resolveDependencies(dependency)
}
}
} catch (error) {
console.error(
`Error fetching or parsing registry item at ${itemUrl}:`,
error
)
}
}
await resolveDependencies(url)
return Array.from(new Set(payload))
}
3.2.5. 根据组件信息完成组件安装
当我们从 registry 中获取到组件的信息后,信息会被存储在 tree 变量中,之后我们就可以通过对应的方法完成组件的安装:
async function addProjectComponents(
components: string[],
config: z.infer<typeof configSchema>,
options: {
overwrite?: boolean
silent?: boolean
isNewProject?: boolean
}
) {
const registrySpinner = spinner(`Checking registry.`, {
silent: options.silent,
})?.start()
const tree = await registryResolveItemsTree(components, config)
if (!tree) {
registrySpinner?.fail()
return handleError(new Error("Failed to fetch components from registry."))
}
registrySpinner?.succeed()
// 更新 tailwind config
await updateTailwindConfig(tree.tailwind?.config, config, {
silent: options.silent,
})
// 更新 css 变量
await updateCssVars(tree.cssVars, config, {
cleanupDefaultNextStyles: options.isNewProject,
silent: options.silent,
})
// 更新组件依赖:通过 execa 库执行 npm install/yarn/pnpm install 来安装组件依赖
await updateDependencies(tree.dependencies, config, {
silent: options.silent,
})
// 更新组件文件:根据组件 content 创建并写入文件
await updateFiles(tree.files, config, {
overwrite: options.overwrite,
silent: options.silent,
})
if (tree.docs) {
logger.info(tree.docs)
}
}
顺便一提,这里使用到了 ora 库来实现安装组件时 CLI 的 loading 效果:
4. 总结:为什么 「ShadCN UI」 如此流行?
这里我想引用最近读的一本书——《创造:用非传统方式做有价值的事》中的一段话:
❝有可能改变世界的企业,往往具有以下 5 个特征。
......
❞
它正在用一种你从未听说的方式思考问题和用户需求,你在了解之后,会觉得那种方式非常合理。
在我看来,shadcn 就是这样一个产品。它颠覆了传统组件库通过 npm 安装的方式,而是通过“复制粘贴”来安装组件。乍听之下,这种方法简直闻所未闻,但当你真正尝试后,会发现它意外地合理且高效。
如果我们从第一性原理出发,分析我们究竟需要什么样的组件库,就会得出这样的结论:
它需要能够快速满足基础开发需求;
它需要允许开发者灵活地定制,以适应不同场景。
长期以来,组件库的开发范式都是封装化和黑盒化,它们满足了「快速开发」却不具有良好的「可扩展性」。开发者被迫使用复杂的 API 或选项去“定制”组件,而这种设计往往伴随着陡峭的学习曲线和更高的开发成本。
而 shadcn 的特别之处在于,它几乎没有发明任何新东西,它只是组合了已有的事物(Radix、tailwind)。重要之处在于它换了一种思考方式,改变了对于组件库的传统认知。如果你还没有尝试过 「shadcn」,不妨现在就去试一试。
点击关注公众号,“技术干货” 及时达!