全面解读 ShadCN UI:揭秘复制粘贴的组件库如何重新定义开发体验

点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群

1. 什么是 「ShadCN UI」

自 2023 年 3 月发布第一个版本以来,「ShadCN UI」(官方称呼为 shadcn/ui,下称 shadcn)迅速在前端圈掀起了一股热潮。截至今天(2025 年 1 月 9 日),它在 GitHub 上的 Star 数已经突破了 「78.1k」,成为近年来增长最快的前端 UI 库之一。这一惊人的数据,不仅体现了其技术和设计理念的成功,也证明了它在开发者群体中的受欢迎程度。

从下方的 Star History 可以清晰地看出,shadcn 的受欢迎程度有多么高,超过老大哥 MUI 和 antd 成为最流行的组件库似乎只是时间问题:

e2bfd035f011454f9a764bc9ab9c1675.jpeg

在 Best Of JS 的最受欢迎项目(Most Popular Projects Overall)中,shadcn 更是连续两年(2023、2024)拿下第一名。(题外话:上一次做到这一成就的是 Vue2,2016-2019 连续四年占据榜首)

709f85f6d39b61ed3df2387b84084484.jpeg afa2011bd047335b3fb5c8de30b914bd.jpeg

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 会将对应的组件放到源代码项目本身中:

b2a2f784d1282d1fc7d55b53910dd11c.jpeg

开发者可以对组件的逻辑和样式进行任意的修改,或者对组件进行扩展,来开发更多复杂的组件。

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 的样式优势,通过“复制粘贴”提供了开箱即用且带样式的组件,同时保留了灵活定制的能力,大幅降低了开发门槛与重复工作。

f55c25ee6b0b14ee1b0b85c242344c15.jpeg

从上面这幅图我们看到:

  • 传统组件库包含了 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 等少数组件基于其他库进行开发

dc5d24dde61e3e3d86145b4b46ee1732.jpeg

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 组件的代码存在本地,我们可以轻易地扩展变体的类型:

79d1e031f58e1282927d891c882c1dd8.jpeg
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 命令,我们的使用方式一般有三种:

  1. 命令行指定:pnpm dlx shadcn add [components]

0e1c13841841c8f230d32a52fca3cf4f.jpeg
  1. 全量安装:pnpm dlx shadcn add --all

b72b62b35c7c8681084587eb4af8b795.jpeg
  1. 交互式选择:pnpm dlx shadcn add

e8a09b3e830488d4be2c3406078b4084.jpeg

在代码当中,为了处理「全量安装」和「交互式选择」的情况,我们会判断 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 为例):

4600b949b8ff3d73c3a5efb60b34f0f7.jpeg

当我们使用 URL 作为参数来安装组件时,shadcn 会通过 resolveDependencies 函数来解析这个 URL,并根据不同的情况采取不同的处理方式:

  1. 如果参数是一个 URL:

shadcn 会直接将该 URL 作为自定义的 registry。然后向这个 URL 发起请求,尝试下载对应的 JSON 文件。通过解析 JSON 文件中的内容,获取组件的相关信息(例如组件的依赖、样式、配置等)。

  1. 如果参数不是 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 效果:

47f36e5cdb3a2ade84ac0a00b45cfc31.jpeg

4. 总结:为什么 「ShadCN UI」 如此流行?

这里我想引用最近读的一本书——《创造:用非传统方式做有价值的事》中的一段话:

有可能改变世界的企业,往往具有以下 5 个特征。

......

  1. 它正在用一种你从未听说的方式思考问题和用户需求,你在了解之后,会觉得那种方式非常合理。

在我看来,shadcn 就是这样一个产品。它颠覆了传统组件库通过 npm 安装的方式,而是通过“复制粘贴”来安装组件。乍听之下,这种方法简直闻所未闻,但当你真正尝试后,会发现它意外地合理且高效。

如果我们从第一性原理出发,分析我们究竟需要什么样的组件库,就会得出这样的结论:

  1. 它需要能够快速满足基础开发需求;

  2. 它需要允许开发者灵活地定制,以适应不同场景。

长期以来,组件库的开发范式都是封装化和黑盒化,它们满足了「快速开发」却不具有良好的「可扩展性」。开发者被迫使用复杂的 API 或选项去“定制”组件,而这种设计往往伴随着陡峭的学习曲线和更高的开发成本。

而 shadcn 的特别之处在于,它几乎没有发明任何新东西,它只是组合了已有的事物(Radix、tailwind)。重要之处在于它换了一种思考方式,改变了对于组件库的传统认知。如果你还没有尝试过 「shadcn」,不妨现在就去试一试。

Node 社群

我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

   “分享、点赞、在看” 支持一波👍
### 大模型前端页面配置方法 大模型的前端页面配置可以通过多种方式实现,具体取决于实际需求和技术栈的选择。以下是几种常见的配置方法及其特点: #### 图形化配置工具 图形化配置是一种直观的方法,允许开发者通过拖拽组件来构建页面布局。这种方法通常用于低代码平台,用户无需编写大量代码即可完成复杂界面的设计[^3]。 #### 使用专用框架或库 一些专门针对前端开发的大模型应用提供了特定的框架或库支持。例如,在 `Transformers.js` 的案例中,开发者可以直接利用其功能在浏览器环境中加载和处理大型语言模型的数据[^1]。这种方式适合熟悉 JavaScript 生态系统的开发者。 #### API 调用与自定义界面 对于像 Ollama 这样的工具,虽然本身不提供现成的图形界面,但它开放了丰富的 API 接口供第三方集成。这意味着你可以根据业务场景设计个性化的前端页面,并通过这些接口获取所需的结果[^2]。 #### 自动生成代码方案 近年来出现了不少能够依据自然语言描述自动生产 HTML/CSS/JS 文件的服务。「gpt-frontend-code-gen」就是这样一个例子,它不仅支持主流 UI 库如 Chakra UIShadcnUI ,还能结合本地部署的大规模预训练模型一起工作,极大提高了效率[^4]。 ```javascript // 示例:简单的React组件生成逻辑 function generateComponent(prompt){ const apiEndpoint = 'http://localhost:8000/generate'; fetch(apiEndpoint, { method:'POST', headers:{ 'Content-Type':'application/json' }, body:JSON.stringify({prompt}) }) .then(response=>response.json()) .then(data=>{ console.log('Generated Component:',data.code); eval(`(${data.code})`); }); } generateComponent("Create a button that says Hello World"); ``` 上述代码片段展示了如何向后端发送请求以获得由AI生成的新组件源码,并即时执行该脚本创建动态内容。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值