第九章. ui 启动:shadcn/ui 与企业级主题融合
第九章. ui 启动:shadcn/ui 与企业级主题融合
Prorise第九章. src/ui 启动:shadcn/ui 与企业级主题融合
在第八章中,我们以“手工作坊”的模式,从零打造了 Button 原子组件。这个过程的价值在于让我们深刻理解了 shadcn/ui 模式的“灵魂”:cva 的变体管理、forwardRef 的引用传递、Slot 的多态能力以及 cn 的样式合并。
现在,我们已经掌握了“如何造轮子”,是时候切换到“如何高效、规模化地生产轮子”了。
在本章中,我们将正式引入 shadcn/ui CLI——它不是一个库,而是一个强大的 代码生成器和工作流工具。但我们将面临一个真实的企业级挑战:Prorise-Admin 已经拥有 一个基于 Vanilla Extract 和 Ant Design Token 的复杂主题系统。
我们的核心任务 不是 简单地运行 npx shadcn init 并接受它的默认 HSL 变量,这将导致我们的项目中出现 两套设计规范,造成灾难性的“主题分裂”。
相反,我们将扮演 项目架构师 的角色,执行一个 精密的集成策略:我们将“劫持”shadcn/ui 的 CSS 变量系统,通过一个巧妙的“CSS 桥接层”,使其组件 100% 消费我们已有的 Vanilla Extract 主题 Token。
9.1. 战略制定:从“手工”到“模式”,再到“融合”
9.1.1. 回顾:Button 带给我们的启示
在第八章,我们花费了大量精力手动创建了 src/components/ui/button/button.tsx。我们学到了:
cva如何帮助我们用声明式的方式管理variant和size的组合。@radix-ui/react-slot和asChildprop 如何让我们的组件具备“多态”能力,既能是button也能是a。forwardRef对于表单、浮层等组件的必要性。cn(tailwind-merge+clsx) 如何解决样式覆盖和冲突。
这四个要素,就是 shadcn/ui 模式的全部技术核心。shadcn/ui 所做的,就是把这个模式封装成一个 CLI 工具,让我们不再需要为每一个 Input, Dialog, Card… 去重复编写这些“样板代码”。
9.1.2. 挑战:shadcn/ui 的“特洛伊木马”
当我们准备执行 npx shadcn init 时,我们必须意识到它会做什么:
安装依赖:
@radix-ui/*,cva,tailwind-merge等。(这很好)创建
components.json:一个配置文件,告诉 CLI 我们的路径别名等。(这很好)注入
cn工具:在src/utils/cn.ts。(我们已经有了,可以覆盖)【核心问题】注入 CSS 变量:它会打开我们的全局 CSS 文件(在
prorise-admin项目中是src/index.css),并 注入一大堆 HSL 格式的 CSS 变量,像这样:1
2
3
4
5
6
7
8/* 这是 shadcn 的默认输出 (错误的做法) */
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* ...等等几十个 HSL 变量 */
}
这就是“特洛伊木马”。如果我们接受了这些变量,我们的项目就完了。
为什么?因为在 第五章 和 第六章,我们已经呕心沥血地用 Vanilla Extract 构建了一套完整的主题系统(src/theme/theme.css.ts)。我们所有的颜色(如 colors.palette.primary.default)都来自 Ant Design 的 Token,并且是 动态的(支持明暗模式切换)。
shadcn 默认的 HSL 变量是 静态的。这会导致:
- 我们的
Button(第八章)使用 VE 变量,颜色正确且能切换主题。 - 我们的
Input(本章)使用shadcnHSL 变量,颜色 错误,且 无法 切换主题。 - 项目中同时存在
var(--colors-palette-primary-default)和var(--primary)两种变量,维护性为零。
9.1.3. 架构决策:构建“CSS 桥接层”
我们的策略不是二选一,而是 融合。
shadcn 组件(如 Input)的样式是这样写的:bg-background border-input。tailwind.config.ts 会把它们翻译成:
background-color: hsl(var(--background))border-color: hsl(var(--input))
shadcn 并不关心 var(--background) 的 值 是什么,它只关心这个 变量名。
这就给了我们一个绝佳的“劫持”机会。我们的解决方案是:
- 允许
shadcn init运行,但 立即删除 它注入到src/index.css的所有:root { ... }变量。 - 取而代之,我们将手动适配那段“桥接层 CSS 写入
src/index.css。
让我们来看看我们后续提供的这段 CSS 代码,它正是我们架构的完美体现:
文件路径: src/index.css (重构后)
1 | /* ... (import 语句) ... */ |
战略总结:
通过这层“桥接”,我们实现了一个 三赢 的局面:
shadcn/ui(获胜):npx shadcn add可以正常工作。它生成的组件(如Input)所依赖的bg-background等 Tailwind 类依然有效。- Vanilla Extract 主题 (获胜):我们 唯一 的设计规范来源仍然是
src/theme/theme.css.ts。我们所有的颜色定义(--colors-common-white等)都被正确消费了。 - 开发者 (获胜):我们获得了
shadcn/ui的开发效率,同时保持了项目主题的绝对统一和动态切换能力。
9.2. 【核心】shadcn/ui 初始化 (init)
这是我们将 shadcn/ui 模式集成到 Prorise-Admin 项目中的 最关键一步。init 命令不仅会安装依赖,更重要的是,它会创建 components.json 配置文件,并尝试修改我们现有的 tailwind.config.ts 和 src/index.css。
我们的目标是:引导 init 命令,使其“为我所用”,为我们 9.1 节制定的“CSS 桥接层”战略铺平道路。
9.2.1. 执行 init 命令
在项目根目录打开终端,执行:
1 | pnpm dlx shadcn@latest init |
我们使用 pnpm dlx 来执行 npx 命令,这是 pnpm 中推荐的执行包的方式。它会临时下载 shadcn-ui CLI 包并运行它,而不会将其作为项目的永久依赖。
9.2.2. 交互式向导
执行后,shadcn-ui 会启动一个交互式向导。这是至关重要的一步,我们的选择将决定它如何与我们现有的项目集成。
以下是我们在 Prorise-Admin 项目中 必须 填写的“标准答案”:
1 | ? Which color would you like to use as the base color? » - Use arrow-keys. Return to submit. |
9.2.3. 审查 init 的“三板斧” (The Three Outputs)
init 命令执行完毕后,它会对我们的项目做三件(或四件)事。我们必须像架构师一样逐一审查。
A. components.json (新大脑)
init 在我们的项目根目录创建了一个新文件 components.json。
文件路径:./components.json (示例)
1 | { |
架构解析:
- 这是
shadcnCLI 的“大脑”。未来我们运行npx shadcn add时,CLI 会读取这个文件。 tailwind.css: 告诉add命令要去哪里 注入新的 CSS 变量(如果我们不阻止它)。aliases.components: 告诉add命令,Input组件应该被创建在src/components/ui/input.tsx。aliases.utils: 告诉add命令,当Input依赖cn函数时,它的import路径应该是import { cn } from '@/utils'。
B. src/index.css (执行 “适配”)
init 命令会在我们 src/index.css 中注入默认的、基于 neutral 颜色的、静态的 OKLCH 变量。
1 | /* src/index.css (被 init 修改后) */ |
这就是我们需要适配的地方。我们要将这些静态值替换为对我们 Vanilla Extract 主题令牌的引用。
执行我们的战略:
- 打开
src/index.css。 - 替换
init命令添加的静态颜色值,改为引用我们的主题令牌。 - 修改 暗色模式选择器,从
.dark改为:root[data-theme-mode="dark"],与我们的ThemeProvider保持一致。 - 添加
@theme inline块,将 shadcn 变量桥接到 Tailwind 的颜色系统。
最终的 src/index.css 结构应该是这样的:
1 | /* ======================================== |
关键适配要点:
变量命名规则:Vanilla Extract 生成的变量名遵循
--{path}-{property}格式- 颜色值:
--colors-palette-primary-default-value - 颜色通道:
--colors-palette-primary-default-channel - 注意使用连字符
-而不是驼峰命名
- 颜色值:
暗色模式选择器:使用
:root[data-theme-mode="dark"]而不是.dark类名- 这与我们的
ThemeProvider使用data-theme-mode属性保持一致 - 确保暗色模式切换机制统一
- 这与我们的
透明度支持:充分利用
channel变量rgba(var(--colors-palette-gray-500-channel) / var(--opacity-border))- 这使得边框和焦点环可以根据主题色动态变化
Tailwind 颜色前缀:使用
@theme inline添加--color-前缀- Tailwind v4 需要
--color-前缀才能识别为颜色 - 这确保了
bg-background、text-foreground等类名正常工作
- Tailwind v4 需要
任务 9.2 已完成!
我们成功地“驯服”了 shadcn init。我们利用了它的自动化配置(components.json, tailwind.config.ts),并 替换 了它的默认主题,注入 了我们自己的企业级“CSS 桥接层”。
现在,Prorise-Admin 已经准备好,可以 安全地 添加 shadcn 组件了。
9.3. Input 组件的集成与定制 (CDD 驱动)
我们的“CSS 桥接层”已经就位。现在是收获成果的时候了。我们将添加 Input 组件,并立即对其进行定制,使其完美融合我们的设计系统。
9.3.1. 添加 Input 组件
在终端中,我们执行 shadcn add 命令:
1 | pnpm dlx shadcn@latest add input |
shadcn CLI 会读取 components.json,找到我们的配置:
- 目标路径:
aliases.ui->@/components/ui - 工具路径:
aliases.utils->@/utils
它会为我们创建 src/components/ui/input.tsx。这个“原始”文件会包含 cva 变体和大量 shadcn 默认的 Tailwind 类。
然而,在 Prorise-Admin 中,我们有自己更精细的设计规范。我们将 用我们自己的定制版本覆盖 这个文件。
9.3.2. 架构决策:样式分离与 cva 管理
重要说明:由于 shadcn/ui 生成的组件质量很高,通常不会有问题,因此我们 可以省略之前 TDD 流程中的 test 文件编写步骤。我们只需要在生成的基础上进行增删和定制即可。
在 Prorise-Admin 中,我们采用与 Button 组件一致的架构:将样式抽离到单独的 variants 文件中,使用 cva 进行管理。
回顾我们的 Button 组件结构:
1 | src/components/ui/button/ |
这种结构的优势在于:
- 关注点分离:组件逻辑与样式定义分离,代码更清晰。
- 可维护性:样式集中管理,修改时不需要在组件文件中搜索。
- 可扩展性:使用
cva可以轻松添加新的变体和尺寸。 - 一致性:所有 UI 组件遵循相同的架构模式。
我们将为 Input 组件采用相同的架构,即使它当前只有一种基础形态。这为未来可能的扩展(如添加 size 变体或 variant 变体)预留了空间。
9.3.3. 编码实现:创建 input.variants.tsx
首先,我们创建样式定义文件,将所有 Tailwind 类抽离到 cva 中。shadcn 生成的默认样式已经很好,但我们需要引入我们自己的设计系统样式,并与 Vanilla Extract 主题系统完美融合。
文件路径:src/components/ui/input/input.variants.tsx
1 | import { cva } from "class-variance-authority"; |
架构深度解析:
- 使用数组格式:更易读,每个样式分类一目了然。
- CSS 桥接变量:
border-input、text-foreground、ring-ring、aria-invalid:border-destructive等类完美消费了我们在 9.2 节中定义的桥接变量。 - 暗色模式:
dark:bg-input/30利用了我们在src/index.css中定义的@variant dark规则。它会在暗色模式下,使用我们的var(--input)变量(在暗色模式下被桥接到rgba(var(--colors-common-white-channel) / 0.15))并应用 30% 的透明度。 - 预留扩展空间:虽然当前只有一种形态,但
cva的variants和defaultVariants配置已经就位,未来可以轻松添加size: ['sm', 'default', 'lg']或variant: ['default', 'filled', 'outlined']等变体。 - 完整的状态覆盖:聚焦、失效、禁用、文件上传等所有状态都有对应的样式定义。
9.3.4. 编码实现:重构 input.tsx
现在,我们重构组件主体文件,使其与 Button 组件保持一致的结构:
文件路径:src/components/ui/input/input.tsx
1 | import * as React from "react"; |
架构深度解析:
VariantProps<typeof inputVariants>:虽然当前没有变体,但这个类型定义为未来扩展预留了空间。一旦我们在inputVariants中添加size或variant,TypeScript 会自动推断出对应的 props 类型。React.forwardRef:与Button组件一致,支持 ref 传递,这对于表单库(如react-hook-form)的集成至关重要。data-slot="input":为组件的根元素添加稳定的data-属性,便于测试和调试。cn(inputVariants({ className })):使用cva生成的inputVariants函数,并通过cn合并外部传入的className。displayName:在 React DevTools 中显示清晰的组件名称。
结论:经过重构,Input 组件现在完全遵循了我们项目的架构规范,与 Button 组件保持一致。所有样式都由我们自己的 Vanilla Extract 主题系统驱动,并且为未来的扩展预留了灵活的空间。
9.3.5. CDD 实践:在 Storybook 中验证融合
现在,我们在 Storybook 中创建 input.stories.tsx,亲眼见证 我们的主题融合。
注意:关于 .mdx 文档文件和 .test.tsx 测试文件,我们暂时不创建。.mdx 文档我们会在后续专门的章节中统一处理;而测试方面,由于 shadcn/ui 生成的组件质量已经很高,我们可以省略基础测试,只在需要时针对特定功能编写测试。
文件路径:src/components/ui/input.stories.tsx
1 | import type { Meta, StoryObj } from '@storybook/react'; |
启动 Storybook:
1 | pnpm storybook |
验证我们的架构:
- 基础样式:打开
Default故事。Input的边框颜色border-input现在是由我们的var(--colors-palette-gray-500-channel)决定的。 - 焦点样式:点击
Input。focus-visible:ring-ring的颜色现在是我们Prorise-Admin的 主色(var(--colors-palette-primary-default)),而不是shadcn的slate色。 - 失效样式:打开
Invalid故事。aria-invalid:ring-destructive的颜色现在是我们Prorise-Admin的 错误色(var(--colors-palette-error-default))。 - 【终极验证】主题切换:
- 点击 Storybook 工具栏的“月亮”图标,切换到暗色模式。
- 你会看到
Input的背景变成了dark:bg-input/30(即我们定义的rgba(var(--colors-common-white-channel) / 0.15)),边框颜色也相应改变。 - 所有焦点色、失效色均 正确地切换到了暗色模式下的对应值。
最终架构:
现在,我们的 Input 组件结构与 Button 组件完全一致:
1 | src/components/ui/input/ |
这个结构展示了我们项目的核心架构原则:
- 样式分离:使用
cva将样式抽离到独立文件,代码更清晰。 - 主题集成:所有颜色和状态样式都消费我们的 CSS 桥接变量,完美融合 Vanilla Extract 主题系统。
- 一致性:所有 UI 组件遵循相同的架构模式。
- 可扩展性:预留了
variants配置空间,未来可以轻松添加新的变体。 - 类型安全:使用
VariantProps和 TypeScript 接口,提供完整的类型推断。
任务 9.3 已完成!
我们成功地添加并 深度定制 了 Input 组件,使其与 Button 组件保持一致的架构规范,并通过 Storybook (CDD) 完全验证 了我们的 “CSS 桥接层” 架构。
9.4. Label 组件的集成与融合 (a11y 驱动)
Input 组件很少单独存在,它需要一个可访问的 Label。在本节中,我们将添加 Label 组件,并将其重构为我们项目的标准架构,重点关注它如何通过 Radix UI 原语实现可访问性。
9.4.1. 添加 Label 组件依赖
首先,我们运行 shadcn add 命令。我们执行此操作的主要目的 不是为了获取代码(因为我们将重写它),而是为了确保 shadcn 为我们自动安装 核心依赖,即 @radix-ui/react-label。
1 | pnpm dlx shadcn@latest add label |
CLI 会在 src/components/ui/ 目录下创建 label.tsx。我们将 忽略 这个文件的默认内容,并立即开始重构。
9.4.2. 编码实现:创建 label.variants.tsx
与 Input 一样,我们首先创建样式定义文件。我们从 Shadcn 提供的 Label 代码中提取所有 Tailwind 类,并将它们放入 cva 中,病整理好
文件路径:src/components/ui/label/label.variants.tsx
1 | import { cva } from "class-variance-authority"; |
架构深度解析:
peer-disabled(核心):这是Label组件实现可访问性的关键样式。当Label通过htmlFor属性指向一个Input(即 “peer” - 同级元素)时,如果该Input处于disabled状态,Label会自动应用peer-disabled:的样式(通常是半透明和禁止光标),为用户提供清晰的视觉反馈。group-data-[disabled=true]:这是另一种禁用状态,用于当Label和Input被包裹在一个共同的父元素(如FormItem)中,并且该父元素被标记为data-disabled=true时。
9.4.3. 编码实现:重构 label.tsx
现在我们重构组件主体,使其导入 labelVariants,并基于 Radix UI 的 LabelPrimitive 构建。
文件路径:src/components/ui/label/label.tsx
1 | import * as React from "react"; |
架构深度解析:
@radix-ui/react-label:我们封装的 不是 原生的<label>标签,而是 Radix UI 的LabelPrimitive.Root。- 为什么选择 Radix? Radix
Label解决了原生<label>的一个痛点:当用户点击Label时,它不仅会聚焦(focus)到关联的Input,还会在某些浏览器上触发一次额外的点击(click)事件。RadixLabel阻止了这种默认行为,确保点击Label只会 触发focus,提供了更可预测的交互。 - Ref 传递:我们使用
React.forwardRef并正确键入了ElementRef,这允许父组件获取对 RadixLabelDOM 节点的引用。 displayName:我们直接从LabelPrimitive.Root.displayName继承displayName,这是封装 Radix 组件的最佳实践。
9.4.4. CDD 实践:验证 Label 与 Input 的联动
Label 组件的真正价值在于它与 Input 的 交互。我们将在 Storybook 中创建一个 联合故事 来验证这种可访问性 (a11y) 行为,并使用 play 函数来自动化测试它。
文件路径:src/components/ui/label.stories.tsx
1 | import type { Meta, StoryObj } from '@storybook/react'; |
启动 Storybook:
1 | pnpm storybook |
验证我们的架构:
- A11y 验证:导航到
UI/Label/A11y: Click to Focus Input故事。 - 手动测试:用鼠标点击 “邮箱地址” 这个
Label。你会发现Input框 立即获得了焦点。这就是htmlFor="email-input"和id="email-input"配合Radix UI原语带来的可访问性。 - 自动化测试:查看 “Interactions” 选项卡。你会看到
play函数的每一步都已成功执行:expect(not.toHaveFocus)->click(label)->expect(toHaveFocus)。 peer-disabled验证:(可选) 你可以在WithInput故事的render函数中为<Input>添加disabled属性,你会立即看到Label的样式变为半透明,证明peer-disabled样式已生效。
任务 9.4 已完成!
我们成功地将 Label 组件重构为了我们项目的标准架构(variants + component),并使用 Storybook 的 play 函数验证了它与 Input 之间的核心可访问性(a11y)交互。
9.5. 本章小结 & 代码入库
在本章中,我们完成了一次至关重要的架构升级,从第八章的“手工作坊”模式演进到了企业级的“自动化 + 定制化”工作流。我们不仅引入了 shadcn/ui CLI,更关键的是,我们 驯服 了它,使其完美服务于我们已有的、基于 Vanilla Extract 的复杂主题系统。
回顾本章,我们取得了以下核心进展:
制定了“CSS 桥接”战略 (9.1):我们识别出
shadcn init默认注入的 HSL/OKLCH 变量与我们 VE 主题的根本冲突。我们制定了“劫持”策略:用我们自己的 VE 令牌(如var(--colors-palette-primary-default))去 喂给shadcn期望的 CSS 变量(如--primary)。“驯服”了
shadcn init(9.2):我们精确地执行了init命令,正确配置了components.json中的别名(@/ui,@/utils),并用我们准备好的“CSS 桥接层”替换 了shadcn注入到src/index.css的默认变量。同时,我们审查并接受了它对tailwind.config.ts的良性扩展。确立了组件架构规范 (9.3):在
add input后,我们 拒绝了默认的“面条码”,而是遵循您制定的规范,将其重构为与Button一致的 样式分离架构(input.variants.tsx+input.tsx)。验证了主题融合 (9.3.5):我们在 Storybook 中通过 CDD 验证了 最终成果:我们定制的
Input组件,其所有状态(焦点、失效、暗色模式)100% 由我们自己的 VE 主题驱动。验证了 A11y 交互 (9.4.4):我们用同样的架构重构了
Label组件,并利用 Storybook 的play函数自动化测试了它与Input之间的 可访问性(a11y)联动。
代码入库:shadcn/ui 模式落地
我们已经完成了 shadcn/ui 的初始化和两个核心原子组件的集成。这个提交标志着我们 src/ui 层的开发工作流已正式定型。
第一步:检查代码状态
使用 git status 查看变更。
1 | git status |
你会看到大量的新增文件和修改:
- 新增 (New):
components.json(shadcn 的“大脑”)src/components/ui/input/(包含input.tsx和input.variants.tsx)src/components/ui/input/input.stories.tsxsrc/components/ui/label/(包含label.tsx和label.variants.tsx)src/components/ui/label.stories.tsx
- 修改 (Modified):
src/index.css(注入了我们完整的“CSS 桥接层”)tailwind.config.ts(被init自动扩展了theme和plugins)src/utils/cn.ts(被init覆盖为类型更安全的版本)package.json/pnpm-lock.yaml(新增了@radix-ui/react-label,tailwindcss-animate等依赖)
第二步:暂存所有变更
1 | git add . |
第三步:执行提交
我们编写一条符合 “约定式提交” 规范的 Commit Message。这是一个重大的功能(feat),范围是 ui。
1 | git commit -m "feat(ui): init shadcn/ui and build CSS bridge adapter" |













