📍 当前位置: 多设计系统兼容 (5/8) | 导航: ← 上一篇: API 设计 | → 下一篇: 概念地图 | 📚 目录


shadcn-ui 多设计系统兼容架构深度分析#

揭秘 shadcn-ui 如何同时支持 Radix UI 和 Base UI 两个底层组件库

分析日期: 2026-01-17 项目: shadcn-ui (commit 1c989f91)


目录#

  1. 核心问题
  2. 架构设计总览
  3. Base 系统详解
  4. Registry 构建流程
  5. API 差异抹平
  6. CLI 集成
  7. 设计决策分析

一、核心问题#

1.1 为什么需要支持多个设计系统?#

背景:

  • Radix UI: shadcn-ui 的原始基础,成熟稳定,但某些组件缺失(如 Combobox)
  • Base UI: MUI 团队开发的新一代无头组件库,API 更现代,功能更完整

挑战:

  1. 两个库的 API 不完全兼容(如 asChild vs render
  2. 组件命名差异(Overlay vs Backdrop
  3. 如何让用户无感知地切换?
  4. 如何维护两套代码?

shadcn-ui 的解决方案:

Base × Style 矩阵系统 - 将底层实现(2 个 Base)和视觉风格(5 个 Style)完全解耦,生成 10 种组合


二、架构设计总览#

2.1 核心概念#

┌─────────────────────────────────────────────────────────┐
│              shadcn-ui 多层架构                         │
└─────────────────────────────────────────────────────────┘

第 1 层: Base (底层实现)
├─ radix (基于 Radix UI)
│  └─ 55+ 组件实现
└─ base (基于 Base UI)
   └─ 55+ 组件实现

            ×  (笛卡尔积)

第 2 层: Style (视觉风格)
├─ vega (经典风格,默认)
├─ nova (紧凑布局)
├─ maia (柔和圆润)
├─ lyra (方正锐利)
└─ mira (密集界面)

            =

第 3 层: 最终组件 (Base × Style)
├─ radix-vega
├─ radix-nova
├─ radix-maia
├─ radix-lyra
├─ radix-mira
├─ base-vega
├─ base-nova
├─ base-maia
├─ base-lyra
└─ base-mira

2.2 目录结构#

apps/v4/registry/
├── bases/                           # 第 1 层:Base 实现
│   ├── radix/                       # Radix UI 实现
│   │   ├── registry.ts              # Registry 定义
│   │   ├── ui/                      # 55+ UI 组件
│   │   │   ├── button.tsx           # 基于 @radix-ui/react-slot
│   │   │   ├── dialog.tsx           # 基于 @radix-ui/react-dialog
│   │   │   ├── combobox.tsx         # 基于 @base-ui/react (!)
│   │   │   └── ...
│   │   ├── lib/utils.ts
│   │   └── hooks/use-mobile.ts
│   │
│   └── base/                        # Base UI 实现
│       ├── registry.ts
│       ├── ui/                      # 55+ UI 组件
│       │   ├── button.tsx           # 基于 @radix-ui/react-slot
│       │   ├── dialog.tsx           # 基于 @base-ui/react/dialog
│       │   ├── combobox.tsx         # 基于 @base-ui/react
│       │   └── ...
│       ├── lib/utils.ts
│       └── hooks/use-mobile.ts
│
├── styles/                          # 第 2 层:Style 定义
│   ├── style-vega.css               # 经典风格样式映射
│   ├── style-nova.css               # 紧凑布局样式映射
│   ├── style-maia.css               # 柔和圆润样式映射
│   ├── style-lyra.css               # 方正锐利样式映射
│   └── style-mira.css               # 密集界面样式映射
│
├── radix-vega/                      # 第 3 层:生成的组件 (构建时生成)
│   ├── registry.ts
│   └── ui/
│       ├── button.tsx               # = radix/button + vega 样式
│       ├── dialog.tsx
│       └── ...
│
├── base-vega/                       # 第 3 层:生成的组件 (构建时生成)
│   ├── registry.ts
│   └── ui/
│       ├── button.tsx               # = base/button + vega 样式
│       ├── dialog.tsx
│       └── ...
│
└── new-york-v4/                     # 别名(向后兼容 v4 legacy)
    └─ → 保留用于向后兼容

三、Base 系统详解#

3.1 Base 定义#

文件:apps/v4/registry/bases.ts

export const BASES: z.infer<typeof registryItemSchema>[] = [
  {
    name: "radix",
    type: "registry:style",
    title: "Radix UI",
    description: "Optimized for fast development, easy maintenance, and accessibility.",
    dependencies: ["radix-ui"],  // npm 包依赖
    meta: {
      logo: "<svg>...</svg>",    // Radix UI logo
    },
  },
  {
    name: "base",
    type: "registry:style",
    title: "Base UI",
    description: "Components for building accessible web apps and design systems.",
    dependencies: ["@base-ui/react"],  // npm 包依赖
    meta: {
      logo: "<svg>...</svg>",          // Base UI logo
    },
  },
]

关键点:

  1. type 为 “registry:style”: Base 被视为一种特殊的 “style”
  2. dependencies: 定义了需要安装的 npm 包
  3. name: 用于构建最终的组件名称(如 radix-new-york

3.2 Base 实现差异#

示例 1: Dialog 组件#

Radix UI 版本 (bases/radix/ui/dialog.tsx):

import { Dialog as DialogPrimitive } from "radix-ui"

// Radix UI 术语
function DialogOverlay({ ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
  return <DialogPrimitive.Overlay {...props} />
}

function DialogContent({ ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
  return (
    <DialogPrimitive.Content {...props}>
      {children}
      {showCloseButton && (
        // Radix UI 使用 asChild
        <DialogPrimitive.Close asChild>
          <Button>
            <XIcon />
          </Button>
        </DialogPrimitive.Close>
      )}
    </DialogPrimitive.Content>
  )
}

Base UI 版本 (bases/base/ui/dialog.tsx):

import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"

// Base UI 术语
function DialogOverlay({ ...props }: DialogPrimitive.Backdrop.Props) {
  return <DialogPrimitive.Backdrop {...props} />
}

function DialogContent({ ...props }: DialogPrimitive.Popup.Props) {
  return (
    <DialogPrimitive.Popup {...props}>
      {children}
      {showCloseButton && (
        // Base UI 使用 render prop
        <DialogPrimitive.Close
          render={<Button />}
        >
          <XIcon />
        </DialogPrimitive.Close>
      )}
    </DialogPrimitive.Popup>
  )
}

核心差异总结:

维度 Radix UI Base UI
导入路径 radix-ui @base-ui/react/dialog
Overlay 组件 Overlay Backdrop
Content 组件 Content Popup
多态性 asChild prop render prop
类型定义 React.ComponentProps<typeof Primitive> Primitive.Props

示例 2: Combobox 组件#

有趣的发现:两个 Base 的 Combobox 完全相同!

// bases/radix/ui/combobox.tsx 和 bases/base/ui/combobox.tsx 完全一样
import { Combobox as ComboboxPrimitive } from "@base-ui/react"

原因:

  • Radix UI 没有 Combobox 组件
  • 两个 Base 都使用 Base UI 的 Combobox 实现
  • 这展示了 Base 系统的灵活性:可以混合使用不同的底层库

四、Registry 构建流程#

4.1 构建脚本工作流程#

文件:apps/v4/scripts/build-registry.mts

// 主流程
console.log("🏗️ Building bases...")
await buildBasesIndex(Array.from(BASES))  // 构建 bases/__index__.tsx
await buildBases(Array.from(BASES))       // 构建所有 base × style 组合

const stylesToBuild = getStylesToBuild()
// 结果:["radix-vega", "radix-nova", "radix-maia", "radix-lyra", "radix-mira",
//        "base-vega", "base-nova", "base-maia", "base-lyra", "base-mira"]

console.log("💅 Building styles...")
for (const style of stylesToBuild) {
  await buildRegistryJsonFile(style.name)
  await buildRegistry(style.name)
  console.log(`   ✅ ${style.name}`)
}

4.2 getStylesToBuild 函数#

function getStylesToBuild() {
  const stylesToBuild: { name: string; title: string }[] = []

  // 笛卡尔积:BASES × STYLES
  for (const base of BASES) {
    for (const style of STYLES) {
      stylesToBuild.push({
        name: `${base.name}-${style.name}`,  // 如 "radix-vega"
        title: `${base.title} ${style.title}`,  // 如 "Radix UI Vega"
      })
    }
  }

  return stylesToBuild
}

4.3 buildBases 函数详解#

async function buildBases(bases: Base[]) {
  for (const base of bases) {
    // 1. 读取 base 的 registry
    const { registry: baseRegistry } = await import(
      `../registry/bases/${base.name}/registry.ts`
    )

    // 2. 验证 Zod schema
    const result = registrySchema.safeParse(baseRegistry)
    if (!result.success) {
      throw new Error(`Invalid registry schema for ${base.name}`)
    }

    const registryItems = result.data.items

    // 3. 为每个 style 生成组合
    for (const style of STYLES) {
      console.log(`   ✅ ${base.name}-${style.name}...`)

      // 4. 创建输出目录
      const styleOutputDir = `registry/${base.name}-${style.name}`
      await fs.mkdir(styleOutputDir, { recursive: true })

      // 5. 读取 style 的样式映射
      const styleContent = await fs.readFile(
        path.join(process.cwd(), `registry/styles/style-${style.name}.css`),
        "utf8"
      )
      const styleMap = createStyleMap(styleContent)

      // 6. 转换每个组件文件
      for (const registryItem of registryItems) {
        for (const file of registryItem.files) {
          // 6.1 读取源文件
          const source = await fs.readFile(
            `registry/bases/${base.name}/${file.path}`,
            "utf8"
          )

          // 6.2 应用样式转换
          const transformedContent = await transformStyle(source, {
            styleMap: styleMap,
          })

          // 6.3 写入目标文件
          const outputPath = `registry/${base.name}-${style.name}/${file.path}`
          await fs.writeFile(outputPath, transformedContent)
        }
      }
    }
  }
}

4.4 样式转换示例#

输入:bases/radix/ui/button.tsx

// cn-button 是样式占位符
<button
  className={cn("cn-button", className)}
  {...props}
/>

样式映射:styles/style-vega.css

.cn-button {
  @apply inline-flex items-center justify-center gap-2 rounded-md px-4 py-2;
}

输出:radix-vega/ui/button.tsx

<button
  className={cn(
    "inline-flex items-center justify-center gap-2 rounded-md px-4 py-2",
    className
  )}
  {...props}
/>

工作原理:

  1. createStyleMap 解析 CSS 文件,提取 .cn-* 类的映射
  2. transformStyle 使用 AST 转换,将 cn-* 类替换为实际的 Tailwind 类

五、API 差异抹平#

5.1 统一的包装层#

设计原则:

无论底层使用 Radix UI 还是 Base UI,对外暴露的 API 完全一致

示例:Dialog 组件的统一 API

// 用户代码(不需要知道底层是 Radix 还是 Base)
<Dialog>
  <DialogTrigger>Open</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Title</DialogTitle>
      <DialogDescription>Description</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <Button>Action</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

内部实现(Radix UI):

function DialogContent({ showCloseButton = true, children, ...props }) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Content {...props}>
        {children}
        {showCloseButton && (
          <DialogPrimitive.Close asChild>  {/* Radix UI 特性 */}
            <Button size="icon-sm">
              <XIcon />
            </Button>
          </DialogPrimitive.Close>
        )}
      </DialogPrimitive.Content>
    </DialogPortal>
  )
}

内部实现(Base UI):

function DialogContent({ showCloseButton = true, children, ...props }) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Popup {...props}>  {/* Base UI: Popup 而非 Content */}
        {children}
        {showCloseButton && (
          <DialogPrimitive.Close
            render={<Button size="icon-sm" />}  {/* Base UI: render prop */}
          >
            <XIcon />
          </DialogPrimitive.Close>
        )}
      </DialogPrimitive.Popup>
    </DialogPortal>
  )
}

5.2 关键差异处理#

差异 1: 多态组件实现#

Radix UI 方式:asChild prop

// Radix UI Button
<DialogPrimitive.Close asChild>
  <Button variant="ghost" size="icon-sm">
    <XIcon />
  </Button>
</DialogPrimitive.Close>

Base UI 方式:render prop

// Base UI Button
<DialogPrimitive.Close
  render={<Button variant="ghost" size="icon-sm" />}
>
  <XIcon />
</DialogPrimitive.Close>

shadcn-ui 的选择:

  • 在 Radix Base 中使用 asChild
  • 在 Base UI Base 中使用 render
  • 用户无需关心,API 保持一致

差异 2: 组件命名#

功能 Radix UI Base UI shadcn-ui 包装
背景遮罩 Overlay Backdrop DialogOverlay
内容容器 Content Popup DialogContent
触发器 Trigger Trigger DialogTrigger (一致)
关闭按钮 Close Close DialogClose (一致)

统一策略:

  • 使用 shadcn-ui 命名 作为对外 API
  • 内部映射到对应的 Primitive 组件

差异 3: 类型定义#

Radix UI 类型:

React.ComponentProps<typeof DialogPrimitive.Content>

Base UI 类型:

DialogPrimitive.Popup.Props

shadcn-ui 处理:

// Radix Base
function DialogContent({
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
  return <DialogPrimitive.Content {...props} />
}

// Base UI Base
function DialogContent({
  ...props
}: DialogPrimitive.Popup.Props) {
  return <DialogPrimitive.Popup {...props} />
}

六、CLI 集成#

6.1 Style 选择#

用户初始化时:

npx shadcn@latest init

CLI 提示:

? Which style would you like to use?
❯ Vega (Radix) - The classic shadcn/ui look (default)
  Nova (Radix) - Reduced padding for compact layouts
  Maia (Radix) - Soft and rounded
  Lyra (Radix) - Boxy and sharp
  Mira (Radix) - Made for dense interfaces
  Vega (Base) - The classic shadcn/ui look
  Nova (Base) - Reduced padding for compact layouts
  ...

components.json 配置:

{
  "style": "radix-vega",  // 或 "base-vega"
  "registries": {
    "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json"
  }
}

6.2 Registry URL 模板#

URL 模板:

https://ui.shadcn.com/r/styles/{style}/{name}.json

实际请求示例:

# 添加 button 组件(Radix UI 实现)
GET https://ui.shadcn.com/r/styles/radix-vega/button.json

# 添加 button 组件(Base UI 实现)
GET https://ui.shadcn.com/r/styles/base-vega/button.json

返回的 JSON(简化版):

{
  "name": "button",
  "type": "registry:ui",
  "dependencies": ["@radix-ui/react-slot"],  // Radix 版本
  // 或 "@base-ui/react" 对于 Base 版本
  "files": [
    {
      "path": "ui/button.tsx",
      "content": "...",  // 完整源代码
      "type": "registry:ui",
      "target": "components/ui/button.tsx"
    }
  ],
  "tailwind": {
    "config": {
      "plugins": ["tailwindcss-animate"]
    }
  }
}

6.3 CLI 工作流程#

// 1. 读取 components.json
const config = await getConfig()
const style = config.style  // "radix-vega" 或 "base-vega"

// 2. 构建 Registry URL
const registryUrl = config.registries["@shadcn"]
  .replace("{style}", style)
  .replace("{name}", "button")

// 3. 获取组件数据
const componentData = await fetch(registryUrl).then(r => r.json())

// 4. 安装 npm 依赖
if (componentData.dependencies) {
  await installDependencies(componentData.dependencies)
  // 对于 radix-vega: npm install @radix-ui/react-slot
  // 对于 base-vega: npm install @base-ui/react
}

// 5. 写入文件
for (const file of componentData.files) {
  const targetPath = path.join(config.resolvedPaths.components, file.target)
  await fs.writeFile(targetPath, file.content)
}

七、设计决策分析#

7.1 为什么要支持两个 Base?#

决策: 同时支持 Radix UI 和 Base UI

原因:

  1. 功能互补

    • Radix UI:成熟稳定,生态完善
    • Base UI:更现代的 API,某些组件(如 Combobox)更强大
  2. 降低风险

    • 不将所有鸡蛋放在一个篮子里
    • 如果某个库停止维护,可以快速切换
  3. 用户选择

    • 有些用户已经在使用 Radix UI
    • 有些用户更喜欢 Base UI 的 API
  4. 渐进迁移

    • 可以逐步将组件从 Radix 迁移到 Base UI
    • 不需要一次性重写所有组件

代价:

  • ❌ 需要维护两套代码
  • ❌ 构建时间增加(生成更多组合)
  • ❌ 文档复杂度增加

为什么值得:

  • ✅ 用户有选择权
  • ✅ 降低依赖风险
  • ✅ 可以利用两个库的优势

7.2 为什么使用 Base × Style 矩阵?#

决策: 将 Base 和 Style 完全解耦

原因:

  1. 关注点分离

    • Base:负责功能和无障碍
    • Style:负责视觉呈现
  2. 指数级复用

    • 1 个 Base + 5 个 Style = 5 种组合
    • 2 个 Base + 5 个 Style = 10 种组合
    • N 个 Base × M 个 Style = N×M 种组合
  3. 易于扩展

    • 添加新 Style:所有 Base 自动支持
    • 添加新 Base:所有 Style 自动支持

示例:

添加新 Style: "tokyo"
  ↓
自动生成:
  - radix-tokyo
  - base-tokyo

添加新 Base: "ariakit"
  ↓
自动生成:
  - ariakit-vega
  - ariakit-nova
  - ariakit-maia
  - ariakit-lyra
  - ariakit-mira

7.3 为什么不在组件内部做判断?#

错误做法(不采用):

// ❌ 在运行时判断使用哪个库
function Dialog({ useRadix = true, ...props }) {
  if (useRadix) {
    return <RadixDialog {...props} />
  } else {
    return <BaseDialog {...props} />
  }
}

为什么不好:

  • 运行时判断,增加 bundle 大小
  • 需要同时安装两个库
  • 类型定义复杂

shadcn-ui 做法:

// ✅ 构建时生成不同版本
// radix-vega/ui/dialog.tsx
import { Dialog as DialogPrimitive } from "radix-ui"

// base-vega/ui/dialog.tsx
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"

// 用户只会下载其中一个版本

优势:

  • 零运行时成本
  • 用户只安装需要的库
  • 类型定义清晰

7.4 为什么 Combobox 两个 Base 都用 Base UI?#

发现:

// bases/radix/ui/combobox.tsx
import { Combobox as ComboboxPrimitive } from "@base-ui/react"

// bases/base/ui/combobox.tsx
import { Combobox as ComboboxPrimitive } from "@base-ui/react"

原因:

  1. Radix UI 缺失 Combobox

    • Radix UI 只有 Command(类似但不完全相同)
    • Base UI 有完整的 Combobox 实现
  2. Base UI 的 Combobox 更强大

    • 支持多选(Chips)
    • 支持虚拟滚动
    • API 更现代
  3. 灵活性

    • Base 不是"纯粹"的,可以混合使用不同的库
    • 选择最适合的实现,而不是教条地遵循某个库

这展示了 shadcn-ui 的务实态度:

使用最好的工具完成任务,而不是限制在某一个库


八、实战示例#

8.1 用户切换 Base 的流程#

场景: 用户想从 Radix UI 切换到 Base UI

步骤 1: 修改 components.json

{
  "style": "base-vega"  // 从 "radix-vega" 改为 "base-vega"
}

步骤 2: 重新添加组件

npx shadcn@latest add button --overwrite

步骤 3: 更新 dependencies

# 自动安装 @base-ui/react
npm install @base-ui/react

# 如果不再使用 Radix UI 组件,可以卸载
npm uninstall @radix-ui/react-dialog @radix-ui/react-slot ...

结果:

  • components/ui/button.tsx 现在基于 Base UI 实现
  • 对外 API 完全一致,业务代码无需修改

8.2 创建自定义 Base#

场景: 团队想基于 Ariakit 创建自己的 Base

步骤 1: 创建 Base 目录

mkdir apps/v4/registry/bases/ariakit
cd apps/v4/registry/bases/ariakit

步骤 2: 创建 registry.ts

import { type Registry } from "shadcn/schema"

export const registry = {
  name: "shadcn/ui",
  homepage: "https://ui.shadcn.com",
  items: [
    {
      name: "button",
      type: "registry:ui",
      files: ["ui/button.tsx"],
      dependencies: ["ariakit"],
    },
    // ... 更多组件
  ],
} satisfies Registry

步骤 3: 实现组件

// apps/v4/registry/bases/ariakit/ui/button.tsx
import * as Ariakit from "ariakit"

function Button({ className, ...props }) {
  return (
    <Ariakit.Button
      className={cn("cn-button", className)}
      {...props}
    />
  )
}

步骤 4: 注册 Base

// apps/v4/registry/bases.ts
export const BASES = [
  { name: "radix", ... },
  { name: "base", ... },
  {
    name: "ariakit",
    type: "registry:style",
    title: "Ariakit",
    dependencies: ["ariakit"],
  },
]

步骤 5: 构建

pnpm run registry:build

结果:

  • 自动生成 ariakit-vega, ariakit-nova, ariakit-maia, ariakit-lyra, ariakit-mira
  • 用户可以选择使用 Ariakit 实现

九、总结#

9.1 架构优势#

优势 说明
可扩展性 添加新 Base 或 Style 无需修改现有代码
用户选择 用户可以选择最适合自己的 Base
风险分散 不依赖单一组件库
渐进迁移 可以逐步迁移到新 Base
零运行时 构建时生成,无性能损耗
类型安全 完整的 TypeScript 支持

9.2 核心设计原则#

  1. 关注点分离:Base(功能)和 Style(样式)解耦
  2. 构建时优化:所有组合在构建时生成,零运行时成本
  3. API 一致性:无论底层实现,对外 API 保持一致
  4. 务实主义:选择最好的工具,而不是教条地遵循某个库

9.3 回答核心问题#

问题 1: 如何兼容 Radix UI 和 Base UI?

答案:

  1. 创建两个独立的 Base 实现(bases/radixbases/base
  2. 通过统一的包装层抹平 API 差异(asChild vs render
  3. 使用 Base × Style 矩阵生成最终组件
  4. 用户通过 style 配置选择使用哪个 Base

问题 2: Registry 如何实现多源支持?

答案:

  1. 构建脚本自动生成所有 Base × Style 组合
  2. Registry URL 使用模板:/r/styles/{style}/{name}.json
  3. style 参数包含 Base 信息(如 radix-new-york
  4. CLI 根据 components.json 中的 style 选择正确的 Registry

附录#

相关文件#

关键源文件#

  • apps/v4/registry/bases.ts - Base 定义
  • apps/v4/registry/bases/radix/ - Radix UI 实现
  • apps/v4/registry/bases/base/ - Base UI 实现
  • apps/v4/scripts/build-registry.mts - 构建脚本
  • packages/shadcn/src/commands/init.ts - CLI 初始化

本分析报告由 Claude Code 生成,基于 shadcn-ui 源代码的深度研究。