构建现代化的组件库:shadcn + Bun 带来的前端开发革命

作者:

🇬🇧 English

传统组件库是「装一个 npm 包,用库作者给的 API」;shadcn/ui 反其道而行——组件代码直接复制进你的项目,你拥有全部源码、可任意改。当我们要在多个应用间复用这套组件时,需要一个「注册表(registry)」来集中分发它们。本文用 Bun 承担依赖安装、构建与注册表服务,分享一套可复制、可演进的组件库工程化方案。

示例工程:一个 shadcn 自定义注册表,Next.js + Storybook 文档,部署到 GitHub Pages。下面的代码片段均取自该工程真实源码。

一、为什么是 shadcn + 注册表

shadcn/ui 的核心不是「库」,而是「源码归属」:

  • 组件进入你的 components/ui 后,就是你自己的代码,不再受上游版本约束;
  • 但团队内多项目共享同一套组件时,需要一处可信源来「复制」——这就是注册表;
  • shadcn CLI 通过 registry.json 清单把组件打包成标准 JSON,用户一条命令即可拉取到本地。

Bun 在这里负责三件事:作为包管理器(packageManager: bun@1.3.5)、跑 Next.js 构建、跑 shadcn build 生成注册表产物。

二、初始化:components.json

注册表的入口是 components.json,它告诉 CLI 用什么风格、是否 RSC、CSS 变量放哪、路径别名如何映射:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "iconLibrary": "lucide"
}

两点值得注意:tailwind.config 为空字符串,说明用的是 Tailwind v4 的 CSS-first 配置(样式变量直接写在 app/globals.css 里,不再需要 JS 配置文件);cssVariables: true 表示主题色走 CSS 变量,天然支持暗色模式。

三、注册表清单:registry.json

registry.json 描述「这个注册表提供哪些组件」。每个 item 有 nametypetitledescription,以及 files[](指向真实源码路径)。下面是该工程真实的几个条目:

{
  "$schema": "https://ui.shadcn.com/schema/registry.json",
  "name": "erishen",
  "homepage": "https://erishen.github.io/shadcn-registry/",
  "items": [
    {
      "name": "hello-world",
      "type": "registry:component",
      "title": "Hello World",
      "description": "A simple hello world component",
      "registryDependencies": ["button"],
      "files": [
        {
          "path": "registry/new-york/blocks/hello-world/hello-world.tsx",
          "type": "registry:component"
        }
      ]
    },
    {
      "name": "example-form",
      "type": "registry:component",
      "title": "Example Form",
      "description": "A contact form with Zod validation.",
      "dependencies": ["zod"],
      "registryDependencies": ["button", "input", "label", "textarea", "card"],
      "files": [
        {
          "path": "registry/new-york/blocks/example-form/example-form.tsx",
          "type": "registry:component"
        }
      ]
    },
    {
      "name": "complex-component",
      "type": "registry:component",
      "title": "Complex Component",
      "description": "A complex component showing hooks, libs and components.",
      "registryDependencies": ["card"],
      "files": [
        { "path": "registry/new-york/blocks/complex-component/page.tsx", "type": "registry:page", "target": "app/pokemon/page.tsx" },
        { "path": "registry/new-york/blocks/complex-component/components/pokemon-card.tsx", "type": "registry:component" },
        { "path": "registry/new-york/blocks/complex-component/components/pokemon-image.tsx", "type": "registry:component" },
        { "path": "registry/new-york/blocks/complex-component/lib/pokemon.ts", "type": "registry:lib" },
        { "path": "registry/new-york/blocks/complex-component/hooks/use-pokemon.ts", "type": "registry:hook" }
      ]
    }
  ]
}

一个 item 可以只包含一个文件(如 hello-world),也可以像 complex-component 那样拉起一整个多文件功能块:它一次带来 page、两个 component、一个 lib 和一个 hook,并通过 target 指定页面落到 app/pokemon/page.tsxregistryDependencies 则会自动把所依赖的基础 UI 一并拉取。

四、真实的 UI 原语:Button 与 Card

组件本体就是标准 shadcn 源码。以 registry/new-york/ui/button.tsx 为例,用 class-variance-authority(cva)表达变体:

import * as React from "react"
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-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
        destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
        outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground shadow-xs 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 has-[>svg]:px-3",
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
)

function Button({
  className, variant, size, asChild = false, ...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
  const Comp = asChild ? Slot : "button"
  return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
}

export { Button, buttonVariants }

Card 则是组合式结构(registry/new-york/ui/card.tsx),拆成 CardHeader / CardTitle / CardDescription / CardContent / CardFooter / CardAction 等子组件,各自带 data-slot 便于外层样式定位:

import * as React from "react"
import { cn } from "@/lib/utils"

function Card({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card"
      className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
      {...props}
    />
  )
}

function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-header"
      className={cn("grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)}
      {...props}
    />
  )
}

export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

注意这些组件里大量出现 bg-primarytext-card-foreground 这类语义化色值——它们不是写死的颜色,而是下面 globals.css 里定义的 CSS 变量。

五、多文件块的真实形态

complex-component 的页面是一个 RSC(React Server Component),用 React 的 cache() 做请求去重:

import { cache } from "react"
import { PokemonCard } from "@/registry/new-york/blocks/complex-component/components/pokemon-card"
import { getPokemonList } from "@/registry/new-york/blocks/complex-component/lib/pokemon"
const getCachedPokemonList = cache(getPokemonList)

export default async function Page() {
  const pokemons = await getCachedPokemonList({ limit: 12 })
  if (!pokemons) return null
  return (
    <div className="mx-auto w-full max-w-2xl px-4">
      <div className="grid grid-cols-2 gap-4 py-10 sm:grid-cols-3 md:grid-cols-4">
        {pokemons.results.map((p) => (
          <PokemonCard key={p.name} name={p.name} />
        ))}
      </div>
    </div>
  )
}

这一条 item 之所以能「一次装好一整个功能」,正是因为 registry.json 里把 page / component / lib / hook 四类文件都列进了 files,shadcn CLI 会按 target 和类型把它们落到正确位置。

六、主题:Tailwind v4 的 CSS 变量

由于 components.jsontailwind.config 为空,主题色全部声明在 app/globals.css,用 Tailwind v4 的 @theme inline + oklch() 色彩空间:

@import "tailwindcss";
@custom-variant dark (&:is(.dark *));

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-border: var(--border);
  --color-ring: var(--ring);
  --radius-lg: var(--radius);
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --border: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
}

oklch() 提供感知均匀的色彩过渡,暗色模式只需在 .dark 下重定义同一组变量——按钮里写的 bg-primary 会自动跟着换肤,无需任何条件类名。

七、构建注册表产物

注册表 JSON 由官方 CLI 本地生成(shadcn build 读取 registry.json,把每个 item 的文件内联打包成 public/r/<name>.json),产物提交进仓库,再由 GitHub Pages 提供下载:

# 本地生成 public/r/*.json(标准 shadcn build 输出,schema: registry-item.json)
pnpm dlx shadcn@latest build
# 或
npx shadcn@latest build

package.json 里所有脚本都跑在 Bun 上,并声明了 Bun 作为包管理器:

{
  "packageManager": "bun@1.3.5",
  "scripts": {
    "dev": "bun next dev",
    "prod": "bun next build",
    "start": "bun next start",
    "lint": "bun next lint",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build -o docs",
    "deploy": "bun next build && touch out/.nojekyll"
  }
}

CI(.github/workflows/deploy.yml)的部署步骤也很直白:

- run: bun install
- run: bun run prod            # = bun next build (静态导出到 out/)
- run: bun run build-storybook # 生成组件文档到 docs/
- run: mkdir -p out/storybook && cp -r docs/* out/storybook/
- run: touch out/.nojekyll

八、消费组件:一条命令拉取

用户不再 npm install,而是用 shadcn CLI 直接从注册表「复制」组件到自己的工程:

# 拉取 example-form(含 Zod 依赖,自动带上 button/input/label/textarea/card)
pnpm dlx shadcn@latest add https://erishen.github.io/shadcn-registry/r/example-form.json

# 拉取 hello-world
pnpm dlx shadcn@latest add https://erishen.github.io/shadcn-registry/r/hello-world.json

app/page.tsx 首页就直接把这些安装命令展示出来,并按下「Tailwind / SCSS / styled-components / 进阶」分类分组,每个组件配实时预览(ComponentPreview)与 Storybook 链接。

九、文档与多风格变体

工程用 Storybook 做组件文档(.storybook/ + stories/bun run storybook 本地起 6006 端口,bun run build-storybook 产出 docs/)。同时 registry/ 下并排维护了四种样式路线,全部登记进同一个 registry.json

  • registry/new-york/:主样式源(Tailwind + new-york 风格,ui/ 原语 + blocks/ 功能块);
  • registry/demo/:源自 Storybook 的纯 CSS 演示组件(demo-with-button/header/page);
  • registry/styled-components/:CSS-in-JS 路线(依赖 styled-components);
  • registry/scss-components/:SCSS Module 路线(.module.scss)。

这四种路线同一份清单下发,使用者按项目技术栈自行选择。

十、小结

shadcn + Bun 的组合把「组件库」重新定义为一份可复制的源码分发

  1. components.json 定义风格与别名,registry.json 定义分发清单——两者都是纯声明;
  2. UI 原语(Button/Card)用 cva 表达变体,主题走 Tailwind v4 CSS 变量,暗色模式零成本;
  3. 多文件功能块(page + component + lib + hook)一次装好,靠 files/target 落位;
  4. shadcn build 生成 public/r/*.json,Bun 负责依赖与构建,GitHub Pages 解决分发;
  5. 使用者一条 shadcn add 命令即可把源码「复制进自己项目」,从此完全拥有、自由改造。

如果你也在维护多应用共享的 UI,不妨试试把组件收进一个 shadcn 注册表——「复制即拥有」带来的可控性,往往比「装包即用」更经得起长期演进。

十一、源码导航

完整工程在 github.com/erishen/shadcn-registry。关键文件:

文件 作用
components.json shadcn CLI 配置:风格(new-york)、RSC、tailwind/alias 与 registry URL
registry.json 注册表分发清单:hello-world / example-form / complex-component 等条目
registry/new-york/ui/button.tsx Button 原语,class-variance-authority 表达变体
registry/new-york/ui/card.tsx Card 原语,组合式子组件
registry/demo/Button/Button.tsx 演示组件(源自 Storybook)
registry/new-york/blocks/complex-component/page.tsx 多文件功能块示例(page + component + lib + hook)
app/globals.css Tailwind v4 主题:oklch 变量 + 暗色模式
app/page.tsx 演示站首页,按分类展示安装命令与实时预览

十二、常见问题

shadcn 注册表和普通 npm 组件库有什么本质区别?

普通组件库发到 npm 的是编译产物,你装的是”黑盒”,要改就得 fork 或覆盖样式;shadcn 注册表分发的是源码——shadcn add.tsx 直接复制进你的工程,从此你完全拥有、可自由改造,不再被版本升级绑架。

为什么用 Bun 而不是 npm / pnpm?

这个注册表工程本身用 Bun 承担依赖管理、Next.js 开发与 shadcn build 构建:安装与启动更快,单文件二进制也省去 Node 版本管理。但它只决定”注册表怎么跑”,使用者消费组件时仍用各自的包管理器,互不干扰。

一条 shadcn add 怎么把多个文件装进我的项目?

注册表条目用 files 字段声明要分发的文件清单,每个文件用 target 指定落位路径(如 components/ui/button.tsx)。shadcn add 拉取对应 public/r/*.json 后,按 files/target 把文件写到你工程里对应位置,所以多文件功能块(page + component + lib + hook)能一次装好。

主题和暗色模式是怎么做的?

主题走 Tailwind v4 的 CSS-first 配置,颜色以 oklch 变量定义在 app/globals.css:root.dark 下(如 --background--primary)。组件只用语义化变量,切暗色只需给根节点加 dark 类,零额外代码。

registry 里并排的四套样式(new-york / demo / styled-components / scss)怎么选?

它们都登记进同一个 registry.json,按需取用:new-york 是主样式(Tailwind + new-york);demo 是源自 Storybook 的纯 CSS 演示;styled-components 走 CSS-in-JS;scss-components 走 SCSS Module。按你项目已有的技术栈选一套即可,互不影响。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

首页 简历 商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号