第八章. UI 原子:TDD/CDD 驱动构建 src/ui 与文档先行
第八章. UI 原子:TDD/CDD 驱动构建 src/ui 与文档先行
Prorise第八章. TDD/CDD 驱动构建 src/ui
src/ui 目录将存放我们项目中 最基础、与业务无关、高度可复用 的 UI 原子组件,借鉴 Headless UI 和 shadcn/ui 的理念,将样式完全交给 Tailwind。本章我们将以 Button 为例,完整实践 TDD/CDD 开发流程:先写 Story -> 再写 Test -> 最后编码实现,并引入 cva 作为核心构建工具。
我们还将 在构建第一个组件后,立即引入 VitePress,搭建项目文档站的基础,并建立起 组件开发与文档编写同步进行 的核心工作流。我们将为 Button 组件编写第一篇交互式文档,让您了解目前市面上开源库的底层工作原理!
8.1. 设计哲学:src/ui (Headless) vs Ant Design
在我们开始编写第一个自定义 UI 组件之前,必须明确 src/ui 目录的设计哲学及其在项目中的战略定位。这将直接指导我们后续的技术选型和开发决策。
8.1.1. 职责划分:原子性、无样式与组合性
src/ui 目录并非要取代 Ant Design,而是与其形成 互补 关系,共同构成 Prorise-Admin 的 UI 基础。
src/ui 组件的核心特征:
- 原子性:此目录下的组件应代表设计系统中最基础的、不可再分的 UI 单元,例如
Button,Input,Avatar,Badge等。它们是构成更复杂组件(无论是在src/components还是页面中)的基石。 - Headless / Unstyled (无样式或最小化样式):受到 Headless UI 和 shadcn/ui 等库的启发,
src/ui组件的核心职责是提供 结构 (HTML)、功能 (JS Logic)、状态管理 (e.g., controlled/uncontrolled) 和无障碍性 (ARIA attributes)。它们 故意不包含 或只包含极少量的内置视觉样式(例如,可能只包含必要的display: block或position: relative)。 - 样式由外部注入:视觉呈现 完全 由外部通过 Tailwind CSS 原子类 来定义。这可以通过两种方式实现:
- 直接应用:在使用组件时,直接通过
classNameprop 传入 Tailwind 类。 - 预定义变体:使用
class-variance-authority(cva) 库(我们将在 8.2 节引入)在组件内部预定义一组样式变体 (variants),使用者通过 props (如variant="primary",size="lg") 来选择应用哪一套原子类组合。
- 直接应用:在使用组件时,直接通过
与 Ant Design 的对比与决策依据:
| 特性 | Ant Design (antd) | src/ui (Headless + Tailwind) |
|---|---|---|
| 样式 | 内置完整、精美的样式体系 | 无样式或最小化样式,样式由 Tailwind 定义 |
| 功能 | 功能丰富,通常包含复杂交互逻辑(如 Table 排序筛选) | 聚焦核心功能,提供基础结构和状态 |
| 灵活性 | 样式定制相对受限,需遵循 Antd Token 体系 | 样式完全灵活,由项目设计系统 (Tailwind) 驱动 |
| 原子性 | 通常是较复杂的“分子”或“有机体”组件 | 纯粹的“原子”组件 |
| 开发速度 | 对于标准场景,开箱即用,开发速度快 | 需要自行组合样式,初始开发稍慢,但长期维护性好 |
| 一致性保证 | 保证 Antd 组件间的一致性 | 保证 项目全局(包括非 Antd 部分)样式的一致性 |
何时选择 Antd?
- 需要 功能复杂、开箱即用 的组件,例如
Table,Form(及其复杂表单项),Modal,Drawer,DatePicker等。Ant Design 在这些领域提供了大量预置的最佳实践。 - 组件的 视觉样式与 Ant Design 默认风格高度吻合,或者可以通过
ConfigProvider进行简单的 Token 级定制就能满足需求。
何时选择自建 src/ui 组件?
- 需要构建 设计系统中最基础的原子,例如
Button,Input,Checkbox,Badge,Avatar,Skeleton等。这些是构成所有 UI 的通用基石。 - 需要对组件的 视觉样式有 100% 的掌控权,确保其严格遵循项目的设计规范(由 Tailwind 配置体现),并且不希望引入 Antd 的样式权重或覆盖成本。
- 需要实现 高度定制化的交互或非标准 UI 元素,Antd 没有提供或难以定制。
- 希望构建一套 与具体 UI 库无关(除了 Tailwind)的基础组件库,未来可能应用于其他项目或进行技术栈迁移。
src/ui 的最终目标:
建立一套 专属于 Prorise-Admin 的、高度 一致、灵活 且 可组合 的基础 UI 原语 (Primitives)。它们将作为我们设计系统的代码实现,确保无论是开发者自行构建的界面,还是对 Antd 组件的补充,都能保持统一的视觉风格和交互模式。
8.2. 技术选型:cva 与 tailwind-merge
在 8.1 节中,我们确立了 src/ui 组件的设计哲学:Headless 结构 辅以 Tailwind 驱动样式。这一决策赋予我们极大的灵活性,但也立刻带来了工程上的挑战:如何系统化地管理组件在不同状态(variant, size, disabled 等)下的 Tailwind 类组合?
直接在组件内部使用条件判断和字符串拼接来管理类名(如 if (variant === 'primary') className += ' bg-primary...')会迅速导致代码混乱、难以维护,并且无法优雅地处理外部传入 className 可能带来的样式冲突。
为了解决这些问题,我们需要引入两个在 Tailwind CSS 生态中广泛应用的、专门为此设计的库:class-variance-authority (cva) 和 tailwind-merge。
8.2.1. 引入 class-variance-authority (cva)
cva 是一个用于创建 样式变体 的实用工具库。它的核心目标是将定义组件不同视觉变体(如按钮的颜色、尺寸)所需的 Tailwind 类组合的逻辑,从组件的渲染代码中 分离 出来,使其更具声明性、类型安全性和可维护性。
cva 的核心能力包括:
- 声明式变体定义:允许你清晰地定义基础样式和基于不同属性(如
variant,size)的样式变体。 - 类型安全:与 TypeScript 深度集成,可以自动推断或显式定义变体属性的类型,并在使用时提供类型检查和自动补全。
- 复合变体:支持定义当多个属性同时满足特定条件时应用的额外样式。
- 默认变体:可以指定在未提供相应属性时默认应用的变体。
基础 API 示例 (cva(base?, config?)):
1 | import { cva } from "class-variance-authority"; |
项目集成确认:我们可以在项目的 package.json 文件中确认 class-variance-authority 已经作为依赖项被安装:
1 | // package.json (部分) |
8.2.2. 引入 tailwind-merge
cva 解决了如何根据 props 生成 正确的类名组合,但它没有解决另一个关键问题:如何处理这些生成的类名与 外部传入的 className 之间的 冲突和冗余。
例如,cva 可能根据 size="lg" 生成了 px-8,但使用者可能传入了 className="px-4"。最终应该应用哪个?
tailwind-merge 库专门用于解决这个问题。它能够智能地合并 Tailwind 类名字符串(或数组),并根据 Tailwind 的内在逻辑 解决冲突(后定义的覆盖先定义的同类属性)并 移除冗余(p-4 p-4 变为 p-4)。
API 示例 (twMerge(...)):
1 | import { twMerge } from 'tailwind-merge'; |
项目集成确认:同样,我们在 package.json 中确认 tailwind-merge 已安装:
1 | // package.json (部分) |
8.2.3. 两者结合:创建 cn 工具函数
在实际组件开发中,我们几乎总是需要同时处理:
- 根据组件的
props使用cva生成基础和变体类名。 - 接收外部传入的
classNameprop 以允许用户进行定制。 - 确保这两部分类名能够被 智能地、无冲突地 合并。
- (可选)有时还需要根据组件的内部状态或其他条件 动态地 添加类名。
社区的最佳实践是创建一个名为 cn (classnames 的缩写) 的工具函数,它通常结合了以下两个库的能力:
clsx: 一个用于 条件性 地拼接类名的微型库。它可以接收字符串、对象(键是类名,值是布尔值)、数组等多种输入。tailwind-merge: 用于对clsx生成的初步结果进行最终的 冲突解决和优化。
项目集成确认:package.json 显示 clsx 也已安装:
1 | // package.json (部分) |
cn 工具函数的实现:常见的做法是将其放在一个专门的文件 src/utils/cn.ts 中,以保持工具函数的职责单一性。我们将遵循规范的方式来讲解。
文件路径: src/utils/cn.ts (推荐创建此文件)
1 | import { type ClassValue, clsx } from "clsx"; |
工作流程与价值:
clsx(inputs)负责将所有输入(无论是什么格式,无论是否满足条件)转换为一个单一的类名字符串。twMerge(...)接收这个字符串,并应用其对 Tailwind 类的理解,智能地移除冲突项(保留优先级更高的)和重复项。
最终使用模式 (结合 cva):在我们的 src/ui 组件中,最常见的 className 应用方式将是:
1 | import { cn } from "@/utils/cn"; // 导入 cn 工具函数 |
阶段性成果:我们为构建 src/ui 组件库奠定了坚实的技术基础。通过引入 cva 来管理样式变体,引入 tailwind-merge 来解决类名冲突,并创建一个结合两者的 cn 工具函数,我们现在拥有了一套专业、高效、类型安全的工具链来处理 Tailwind CSS 类的复杂组合。这为我们在下一节开始实战 Button 组件做好了充分准备。
8.3. 从零到一,构建企业级 Button 组件
在本节中,我们将以项目中最基础也最重要的原子组件——Button——为例,完整地、一步一步地实践我们在本章开头定下的 TDD/CDD (测试/组件驱动开发) 核心工作流。我们的目标不只是写出一个按钮,而是要内化一种 设计先行、测试驱动、逐步实现 的现代化前端开发思维。我们将深入 shadcn/ui 的设计哲学,手动实现其核心模式,从而真正掌握它,而不仅仅是会用它的命令行工具。
8.3.1. 谋定而后动:可扩展的组件文件结构
在开始编写任何代码之前,我们首先要解决一个架构问题:我们的组件文件应该放在哪里?
一个常见的做法是在 src 下创建一个 ui 或 components 目录,然后将所有组件文件(如 Button.tsx, Input.tsx)直接放在里面。当组件数量稀少时,这看起来很整洁。但随着项目规模扩大,这个文件夹会迅速变得混乱不堪,一个组件相关的逻辑、样式、故事和测试文件散落各处,难以维护。
因此,我们将采用一种更具扩展性的 “独立文件夹” 模式。
设计决策:
每一个独立的 UI 组件,都应该拥有自己的文件夹。该文件夹将内聚与此组件相关的所有文件:实现 (
.tsx)、故事 (.stories.tsx) 和测试 (.test.tsx)。
实践步骤:
- 在
src/components目录下,创建一个名为ui的子目录,专门用于存放我们的基础 UI 组件库。 - 在
src/components/ui内部,为我们的Button组件创建一个专属的文件夹。
现在,打开您的终端,执行以下命令:
1 | mkdir -p src/components/ui/button |
接着,我们预先创建好这个组件所需的核心文件:
1 | touch src/components/ui/button/button.tsx |
1 | src/ |
这种结构的好处是显而易见的:高内聚、易查找、易删除/迁移。当您想了解 Button 的一切时,只需进入这个文件夹即可。这也为未来使用 shadcn-ui 的 add 命令自动生成组件代码打下了良好的基础。
8.3.2. Story 先行:在 Storybook 中“设计”组件 API
我们开发流程的第一步,不是打开 button.tsx 写代码,而是打开 button.stories.tsx。我们将在这里,利用 Storybook 这个可视化画布,设计并定义 Button 组件的 API(即它应该接受哪些 props),以及它在不同 props 组合下的所有视觉状态。
第一步:编写故事元数据 (Meta)
打开 src/components/ui/button/button.stories.tsx,我们将使用 Component Story Format 3.0 (CSF 3.0) 格式来编写故事。
什么是 CSF 3.0?
简单来说,它是一种用 ES6 模块来编写 Storybook 故事的标准化格式。
核心有两个部分:一个 default export (默认导出) 的 Meta 对象,用来描述组件的元信息;以及多个 named export (具名导出) 的 Story 对象,每个代表组件的一个具体状态。
明白了,就是用标准化的 JS 对象来定义一切。
现在,让我们编写 Meta 对象。
文件路径: src/components/ui/button/button.stories.tsx (阶段一)
1 | import type { Meta, StoryObj } from '@storybook/react-vite'; |
第二步:用 argTypes 设计组件的“契约”
argTypes 是 Meta 对象中一个至关重要的属性。它不仅能配置 Storybook 中 Controls 面板的交互方式,更核心的是,它就是我们 设计和定义组件 props 的地方。
让我们为 Button 设计几个核心 props:
variant: 按钮的视觉风格(例如,默认、危险、描边等)。size: 按钮的尺寸(例如,大、中、小)。children: 按钮显示的内容。
继续编辑 meta 对象,添加 argTypes:
文件路径: src/components/ui/button/button.stories.tsx (阶段二)
1 | // ... (imports) |
第三步:编写第一个可交互的“主故事” (Primary)
现在,我们来编写第一个具名导出的 Story 对象。这个故事通常被称为 Primary 或 Default,它将作为用户在 Controls 面板中进行交互和实验的“主模板”。
文件路径: src/components/ui/button/button.stories.tsx (阶段三)
1 | // ... (meta 对象) |
第四步:编写代表各种状态的“文档故事”
为了让其他人能一目了然地看到 Button 的所有核心变体,我们继续编写更多的故事,每个故事固定一种关键的 props 组合。
文件路径: src/components/ui/button/button.stories.tsx (阶段四)
1 | // ... (Primary Story) |
Story 先行完成! 此刻,button.stories.tsx 已经成为我们 Button 组件的一份 动态设计规约。我们已经清晰地定义了它的 API 和视觉状态,尽管我们一行实现代码都还没写。
如果你现在尝试运行 Storybook ( pnpm storybook ),它会因为找不到 ./button 模块而构建失败。
预期中的失败! 这正是 TDD/CDD 流程的关键一步。我们先用 Story 和 Test 定义了“目标”,下一步的任务就是通过编码,让这个“目标”得以实现。
8.3.3. Test 驱动:用测试定义组件的行为
在进入编码实现之前,我们还有一步:编写测试。测试用例会把我们在 Storybook 中设计的“视觉规约”转化为更严格的“行为规约”。
第一步:搭建 Vitest 测试环境
我们的项目需要一些库来支持 React 组件的测试。
安装依赖
1
pnpm add -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
vitest: 核心的测试运行器。@vitest/ui: 为 Vitest 提供一个可视化的测试界面。jsdom: 模拟浏览器 DOM 环境,让我们的组件可以在没有真实浏览器的 Node.js 环境中运行。@testing-library/react: 提供了渲染 React 组件和查询 DOM 的核心工具。@testing-library/jest-dom: 为 Vitest 的expect断言增加了许多方便的 DOM 相关匹配器(如toBeInTheDocument)。@testing-library/user-event: 模拟真实用户交互(点击、输入等),比底层事件触发更可靠。
配置 Vitest
在项目根目录修改
vitest.config.ts文件,这个文件是我们创建 vite 项目默认自带的,后续被 Storybook 新增了属于他的测试文件路径:
vitest.config.ts1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25test: {
projects: [
// 保留现有的 Storybook 测试
{
extends: true,
plugins: [storybookTest({ configDir: path.join(dirname, ".storybook") })],
test: {
name: "storybook",
browser: { /* ... 现有配置 ... */ },
setupFiles: [".storybook/vitest.setup.ts"],
},
},
// 新增单元测试项目
{
// 这一行很重要!单元测试需要继承顶层的 tsconfigPaths() 插件,才能识别到路径别名@
extends: true,
test: {
name: "unit",
globals: true,
environment: "jsdom",
setupFiles: "./vitest.setup.ts",
},
},
],
},创建测试设置文件
我们需要一个设置文件来引入
@testing-library/jest-dom的匹配器。文件路径:
vitest.setup.ts1
import '@testing-library/jest-dom';
配置 TypeScript 类型声明
为了让 TypeScript 识别
toBeInTheDocument等匹配器,需要添加类型引用。文件路径:
vitest.shims.d.ts(如果文件已存在则追加)1
/// <reference types="@testing-library/jest-dom" />
然后在
tsconfig.json的include数组中添加此文件:1
2
3{
"include": ["src", ".storybook", "auto-imports.d.ts", "vitest.shims.d.ts"]
}
第二步:编写第一个失败的测试 (RED)
环境就绪,打开 src/components/ui/button/button.test.tsx,编写第一个测试用例:验证按钮能否被正常渲染。
文件路径: src/components/ui/button/button.test.tsx (阶段一)
1 | import { render, screen } from '@testing-library/react'; |
第三步:配置测试快捷指令
在 package.json 中添加测试相关的脚本:
文件路径: package.json
1 | { |
第四步:运行测试并拥抱失败
在终端中,启动 Vitest 的 UI 界面,它会监听文件变化并自动重跑测试。
1 | pnpm test:unit |
Vitest UI 会在浏览器中打开,你会看到我们的测试 Button Component > should render correctly with children 处于 失败 (RED) 状态。将鼠标悬停在失败的测试上,你会看到错误信息,通常是:"Failed to resolve import "./button “”。
RED! 这是 TDD 循环中至关重要的一步。这个失败的测试现在成为了我们编码的 驱动力。我们的下一个,也是唯一的目标,就是编写最少的代码,让这个测试变绿。
8.3.4. 编码实现:让测试和故事通过 (GREEN)
现在,我们终于可以打开 button.tsx 文件了。
第一步:编写最小化实现,让第一个测试通过
我们的目标很明确:导出一个名为 Button 的 React 组件,它能渲染一个原生的 <button> 元素并显示其 children。
我们将使用 React.forwardRef。这是一个高阶组件,能让我们的函数组件接收一个 ref 并将其向下传递给子元素。这对于构建可复用的 UI 组件库至关重要,因为它允许使用者直接获取底层 DOM 节点的引用。
文件路径: src/components/ui/button/button.tsx (阶段一)
1 | import * as React from 'react'; |
在你保存这个文件的瞬间,切换到 Vitest UI 的浏览器窗口,你会惊喜地发现:
GREEN! 我们的第一个测试用例已经通过了!我们通过编写最少的代码,满足了测试的规约。
第二步:引入 CVA,为样式变体做准备
现在,我们需要处理在 Storybook 中设计的 variant 和 size。如果用 if/else 或三元运算符来拼接 Tailwind CSS 类,代码会变得混乱不堪。这时,我们需要一个强大的工具:class-variance-authority (CVA)。
CVA 是做什么的?
它是专门用来管理组件样式变体的。你给它定义好基础类、不同的变体(如 variant, size)以及对应的 Tailwind 类,它就会返回一个函数。
调用这个函数并传入 props (如 { variant: 'destructive' }),它就会智能地返回正确的 class 字符串。
听起来像是样式的状态机,输入 props,输出 class。
完全正确!它让样式管理变得声明式和可预测。
首先,安装它:
1 | pnpm add class-variance-authority |
然后,在 button.tsx 中定义我们的 buttonVariants。
文件路径: src/components/ui/button/button.variants.tsx (阶段二)
1 | import { cva } from "class-variance-authority"; |
第三步:回到测试,为样式变体编写新测试 (RED)
我们的代码已经有了处理变体的能力,但组件本身还没使用它。现在,回到 button.test.tsx,添加一个新的测试用例来“驱动”我们完成下一步。
文件路径: src/components/ui/button/button.test.tsx (阶段二)
1 | // ... (imports) |
保存后,Vitest UI 会显示一个新的 失败 (RED) 测试。这是因为它渲染的 Button 还没有应用任何 CVA 生成的类。
第四步:集成 CVA,让第二个测试通过 (GREEN)
目标明确:修改 Button 组件,让它使用 buttonVariants 函数。
文件路径: src/components/ui/button/button.tsx (阶段三)
1 | import type { VariantProps } from "class-variance-authority"; |
保存文件,再看 Vitest UI。
GREEN AGAIN! 第二个测试也通过了!我们的 TDD 循环正在健康地运转。
8.3.5. 最终验证与 asChild 的魔法
现在,我们代码的核心功能已经完成,并且有测试覆盖。是时候回到 Storybook 看看我们的劳动成果了。
启动 Storybook:
1 | pnpm storybook |
打开浏览器,导航到 UI/Button。你会看到,我们在 button.stories.tsx 中定义的所有故事现在都 正确地渲染 了出来!你可以在 Controls 面板中自由组合 variant 和 size,组件会如预期般变化。
实现 asChild 多态特性
还有一个 shadcn/ui 的常见模式我们没有实现:asChild prop。有时候,我们希望一个组件拥有按钮的样式,但其本身是一个链接 (<a>) 或路由组件 (<Link>)。asChild prop 就是为了解决这个问题。当 asChild={true} 时,Button 组件将不会渲染自己的 <button> 标签,而是会把它所有的 props(包括计算好的 className)“克隆”给它的直接子元素。
这需要借助一个小巧而强大的库:@radix-ui/react-slot。
安装依赖
1
pnpm add @radix-ui/react-slot
修改 Button 实现
文件路径: src/components/ui/button/button.tsx (最终阶段)
1 | import * as React from 'react'; |
现在,你可以回到 Storybook,添加一个新的故事来验证 asChild 的功能:
文件路径: src/components/ui/button/button.stories.tsx (补充)
1 | // ... |
刷新 Storybook,你会看到一个新的 AsLink 故事。使用浏览器的开发者工具检查它,你会发现渲染出来的 DOM 元素是一个 <a> 标签,但它完美地应用了我们 variant: 'link' 的样式。
任务完成!
我们不仅从零开始构建了一个功能完备、样式灵活、类型安全的 Button 组件,更重要的是,我们完整地体验了企业级的 TDD/CDD 开发流程:
- 规划了 可扩展的文件结构。
- 用 Storybook 作为设计工具,定义了组件的 API 和视觉状态。
- 用 Vitest 作为质量保障,编写了失败的测试来驱动开发。
- 逐步编码,让测试逐一变绿,最终让 Storybook 中的所有设计得以实现。
这个过程看似比直接写代码要慢,但它构建的组件质量、可维护性和健壮性都远超前者。现在,您已经真正掌握了 shadcn/ui 模式的核心思想。
8.4. 文档深化:使用 MDX 编写专业组件文档
在本节中,我们将把我们的组件开发提升到“文档驱动”的更高层次。我们将学习如何使用 MDX(一种允许在 Markdown 中混合使用 JSX 的强大格式)来创建一份专业的、叙事性的组件文档。这份文档将消费我们在 8.3.2 节中创建的 Stories,将它们作为可交互的示例嵌入到我们的指南中。
我们已经通过一个严格的 TDD/CDD 循环,成功构建了一个功能完备、经过测试、且在 Storybook 中可视化的 Button 组件。
现在,我们 button.stories.tsx 文件中的 “Canvas” 标签页已经非常出色了,它允许开发者和设计师像在实验室里一样交互和审查组件。
然而,一个企业级的组件库还需要一个更重要的交付物:专业的产品文档。这份文档需要提供“为什么”和“如何”的背景知识,而不仅仅是“是什么”的交互式示例。
在 8.3.2 节中,我们在 meta 对象中添加了 tags: ['autodocs']。这告诉 Storybook 自动为我们生成一个 “Docs” 标签页。这个自动生成的页面已经很好了,它包含了 argTypes 的描述和自动生成的 Props 表。
但是,如果我们想添加更丰富的叙事内容,比如 设计理念、使用指南、最佳实践(Do’s and Don’ts),或者 嵌入特定的故事组合 呢?
这就是 MDX 发挥作用的地方。我们将用一个自定义的 MDX 文件来 完全接管 自动生成的 “Docs” 页面,从而获得对文档内容的 100% 控制权。
8.4.1. 理念:CSF 与 MDX 的协同模式
在开始编写之前,我们必须理解 Storybook 9 中关于文档的 核心设计理念:
.stories.tsx(CSF 文件): 这是我们组件状态的 “SSOT”。它像一个数据库,以纯粹、类型安全的 ES 模块形式,导出一系列可交互的组件状态(Stories)。我们在8.3.2节中创建的文件就是它。.mdx(MDX 文件): 这是 “叙事层”。它是一个 Markdown 文件,负责编写关于组件的介绍、使用指南和设计哲学。它本身 不定义 任何新的 Story,而是像一个消费者一样,从.stories.tsx文件中 导入 并 嵌入 已经定义好的 Story 作为示例。
这种职责分离的模式(示例归示例,文档归文档)是现代 Storybook 的最佳实践,它使得我们的 Stories 保持了高度的可复用性(例如在测试中),同时也让文档编写者可以专注于内容创作。
8.4.2. 创建 button.mdx 文档文件
我们的第一步是创建 MDX 文件。按照约定,它应该与我们的组件和故事文件放在同一个目录中。
在终端中,创建这个新文件:
1 | touch src/components/ui/button/button.mdx |
8.4.3. 编写 MDX 文档内容
现在,打开 src/components/ui/button/button.mdx,我们将分三步来构建这份文档。
第一步:关联 Meta 并撰写简介
我们需要做的第一件事就是 连接 这个 MDX 文件和我们的 button.stories.tsx 文件,我们需要下载如下的插件以便适配 MDX
1 | pnpm add -D @storybook/blocks |
文件路径: src/components/ui/button/button.mdx (阶段一)
1 | import { Meta, Story, Controls } from '@storybook/blocks'; |
代码深度解析:
import { Meta, Story, Controls } ...: 我们从 Storybook 的文档插件中导入了三个核心的 MDX 组件。import * as ButtonStories from ...: 我们将button.stories.tsx中 所有 的导出(包括meta和所有具名的 Story)导入为一个名为ButtonStories的对象。<Meta of={ButtonStories} />: 这是最关键的一行。它将这个 MDX 文档与我们的 CSF 文件(button.stories.tsx)关联起来。
第二步:嵌入“主故事”和“变体故事”
现在,我们可以像写博客一样编写 Markdown,并在任何需要的地方,使用 <Story> 标签嵌入我们在 button.stories.tsx 中定义的 交互式示例。
文件路径: src/components/ui/button/button.mdx (阶段二)
1 | {/* ... 此前内容 ... */} |
代码深度解析:
<Story of={...} />: 我们使用<Story>标签,并通过of属性精确地指向ButtonStories对象中的特定 Story(例如ButtonStories.Primary)。- 自动 Controls: 当我们嵌入
PrimaryStory 时,Storybook 会自动在它下方附带上 Controls 面板(因为Primary是一个可交互的主故事)。 - 静态展示: 当我们嵌入
Destructive、Large等 Story 时,Storybook 默认只显示组件画布,这非常适合用于在文档中并列展示特定状态。
第三步:自动生成 API 参考
最后,一份专业的文档必须有一个完整的 API 属性列表。我们不需要手动编写这个表格,@storybook/addon-docs 提供了 <Controls> 组件来自动完成这项工作。
文件路径: src/components/ui/button/button.mdx (最终)
1 | {/* ... 此前内容 ... */} |
代码深度解析:
<Controls />: 当在 MDX 文件中 顶层使用 时(即不在<Story>标签内部),<Controls />标签会切换到它的“文档模式”。它会自动读取通过<Meta of={...} />关联的组件,并渲染出一个 完整的 API 属性表格,包含属性名、描述(来自argTypes)、类型和默认值。
8.4.4. 禁用 autodocs
我们已经创建了自定义的 MDX 文档,现在必须告诉 Storybook 停止 自动生成文档页,而是使用我们的 button.mdx 文件。
非常简单,只需回到 button.stories.tsx 文件,将 tags: ['autodocs'] 这一行 删除或注释掉 即可。
文件路径: src/components/ui/button/button.stories.tsx (修改)
1 | // ... (imports) |
8.4.5. 最终验证
现在,让我们重启 Storybook(如果它正在运行,它可能会自动热更新)。
1 | pnpm storybook |
再次访问浏览器中的 UI/Button。你会发现 “Docs” 标签页的内容已经 完全被我们的 button.mdx 文件所取代。
你现在拥有了一个:
- 叙事清晰:包含了我们用 Markdown 编写的介绍和指南。
- 交互式:嵌入了可实时操作的
PrimaryStory 和 Controls。 - 图文并茂:并列展示了
Destructive、Large等关键变体。 - 自动同步:拥有一个根据代码自动生成的 API 参考表格。
这标志着我们 Button 组件的 TDD/CDD 工作流已经完美闭环:从 Story(设计) -> Test(测试) -> Code(实现) -> Docs(文档),每一个环节都已完成。
8.5. 质量保障:Storybook 交互式测试 (play 函数)
在本节中,我们将为 Prorise-Admin 的 TDD/CDD 工作流补上最后一块拼图:自动化交互测试。我们将学习如何使用 Storybook 的 play 函数,为我们的组件编写在 真实浏览器环境 中运行的交互脚本。这不仅能为我们的组件提供强大的质量保障,还能在 Storybook 中实现可重放、可视化的交互调试。
8.5.1. 理念:单元测试 vs 交互测试
在我们 8.3.3 节的 button.test.tsx 中,我们编写了这样的测试:
1 | it("应该需要有一个子元素且能够被渲染", () => { |
这被称为 单元测试。它运行在 jsdom(一个 Node.js 中的模拟浏览器环境)中,速度极快,非常适合用来验证:
- 组件是否能成功渲染(
toBeInTheDocument)。 - 组件是否根据
props应用了正确的className(toHaveClass)。
然而,它 无法 验证:
- 组件在真实浏览器布局中的视觉表现。
- CSS
hover或focus-visible状态是否生效。 - 用户真实的
click事件(包含mousedown,mouseup等)是否正确触发了回调。
为了弥补这一差距,Storybook 引入了 交互测试。
交互测试:
- 运行在 真实的浏览器(通过 Playwright)中。
- 使用
play函数,在 Story 渲染完成后,以编程方式模拟用户操作(如点击、输入、悬停)。 - 它不仅是“测试”,更是一种 可重放的交互文档。
8.5.2. play 函数与 @storybook/test
play 函数是您可以附加到任何 Story 对象上的一个异步函数。它会在组件渲染到画布后自动执行。
为了在 play 函数中编写测试,我们需要 storybook init 时已经为我们安装好的 @storybook/test 包。这个包整合了行业标准的测试工具:
within: 来自@testing-library/react,用于将查询范围限定在当前 Story 的画布内。userEvent: 来自@testing-library/user-event,用于模拟高保真的用户交互。expect: 来自vitest,我们的断言库。fn: 来自vitest,用于创建“间谍函数”(Spy),以便我们追踪回调是否被调用。
8.5.3. 为 Button 编写第一个交互测试
我们的目标是:验证当用户点击按钮时,onClick 回调函数是否被正确调用。
1 |
我们将回到 button.stories.tsx 文件,添加一个新的、专门用于测试交互的 Story。
文件路径: src/components/ui/button/button.stories.tsx (补充)
1 | // [1. 导入测试工具] |
代码深度解析:
argTypes: { onClick: ... }:我们首先在meta中告诉 Storybook,onClick是一个我们关心的 “action”。args: { onClick: fn() }:在WithClickInteractionStory 中,我们必须传入一个真实的函数作为onClickprop。fn()创建了一个 Vitest 间谍函数,它能记录所有对它的调用。play: async ({ canvasElement, args }) => ...:这是play函数的主体。within(canvasElement):获取当前 Story 的 “画布”,确保我们的测试不会意外查询到 Storybook UI 的其他部分。userEvent.click(button):模拟一次完整的、高保真的用户点击。await expect(args.onClick).toHaveBeenCalled():断言。我们检查传入的args.onClick(也就是我们的fn()间谍函数)是否确实被userEvent.click触发了。
8.5.4. 双重验证:UI 调试与自动化测试
play 函数的真正魔力在于它提供了 双重价值:
1. 可视化调试 (在 Storybook UI 中)
- 运行
pnpm storybook。 - 导航到
UI/Button/WithClickInteraction这个 Story。 - 你会看到组件被渲染,然后底部的 “Interactions” 选项卡会被自动选中。
- 它会 一步一步地 显示
play函数的执行过程:click->expect。 - 所有步骤都带有绿色对勾,表示测试通过。你甚至可以来回拖动时间轴,查看每一步交互前后的 DOM 快照。

这是一个极其强大的 可视化调试工具,你可以亲眼看到你的交互脚本是如何执行的。
2. 自动化测试 (在 CI/CD 中)
这个 play 函数同时也是一个 完整的自动化测试用例。
在
8.3.3节中,我们已经配置了vitest.config.ts来运行storybook项目,并添加了pnpm test:storybook脚本。现在,在你的终端中运行这个脚本:
1
pnpm test:storybook
Vitest 会启动 Playwright,在 后台无头浏览器 中加载你所有的 Stories。
当它加载到
WithClickInteractionStory 时,它会自动执行play函数并运行其中的断言。你会在终端中看到测试通过的报告,确认
onClick的交互行为在自动化测试中也得到了验证。
交互测试完成! 我们现在不仅拥有了验证渲染的单元测试(button.test.tsx),还拥有了验证真实用户交互的交互测试(button.stories.tsx 中的 play 函数)。我们的 Button 组件现已具备坚实的质量保障。
8.6. 集成 VitePress:构建项目级“用户手册”
在本节中,我们将为我们的项目模板搭建一个独立的、内容优先的静态文档网站。我们将引入 VitePress,Vite 官方的静态站点生成器。这个文档站的定位不是重复 Storybook 的工作,而是作为项目的“用户手册”和“架构蓝图”,服务于所有即将使用 Prorise-Admin 模板来构建其业务应用的开发者。
8.6.1. 战略定位:VitePress vs Storybook MDX
在我们开始安装之前,必须再次明确我们确立的战略定位:
| 工具 | Storybook + MDX (我们在 8.4 节完成的) | VitePress (我们本节要构建的) |
|---|---|---|
| 定位 | 组件的“技术 API 实验室” | 项目的“官方用户指南” |
| 受众 | 组件的开发者/维护者(我们自己) | 模板的最终使用者(我们的同事/客户) |
| 回答的问题 | “这个 Button 有哪些 props?” | “我该 如何使用 这个模板添加一个新页面?” |
“Button 在 variant='destructive' 时什么样?” | “项目的 权限系统 是如何设计的?” | |
| 核心价值 | 与组件代码并置,提供交互式 API 调试。 | 内容优先,提供教程、指南和架构说明。 |
这两者 完美互补。Storybook 是我们 src/ui 组件库的“API 参考”,而 VitePress 是我们整个 Prorise-Admin 模板的“产品说明书”。
8.6.2. 快速启动:使用官方 CLI 初始化
VitePress 提供了强大的 CLI 工具,可以通过一个交互式向导快速搭建整个文档站点,这是官方推荐的最佳实践。
第一步:安装依赖
首先,我们将 VitePress 作为项目的开发依赖项进行安装。
1 | pnpm add -D vitepress |
第二步:运行初始化向导
接下来,在你的项目根目录下运行 init 命令。
1 | # 在项目根目录执行 |
这个命令会启动一个交互式的设置向导,它会询问你几个问题来配置你的文档站:
- Where to create the documentation? (文档创建位置) - 使用
./docs。 - Site title (站点标题) - 输入
Prorise Admin。 - Site description (站点描述) - 输入
企业级后台管理系统模板,基于 React 19、Vite 与 Ant Design。 - Theme (主题) - 选择
│ ● Default Theme + Customization (Add custom CSS and layout slots)。 - Use TypeScript for config file? (使用 TypeScript 配置文件吗?) - 选择
Yes。 - Add VitePress NPM scripts to package.json? (添加到 NPM 脚本吗?) - 选择
Yes。
向导完成后,VitePress 会自动为你创建所有必需的文件和目录,包括示例页面。现在的目录结构会比手动创建更加完善:
1 | / |
8.6.3. 定制化配置 (config.ts)
初始化向导已经为我们生成了一个功能齐全的 docs/.vitepress/config.ts 文件。现在我们只需要根据项目需求对其进行微调。
打开 docs/.vitepress/config.ts,并将其修改为以下内容:
文件路径: docs/.vitepress/config.ts
1 | import { defineConfig } from 'vitepress' |
代码深度解析:
defineConfig: 提供了开箱即用的 TypeScript 类型支持。title&description: 站点元数据,这些内容是我们在初始化向导中输入的,对 SEO 和用户识别非常重要。themeConfig.nav: 配置顶部的导航栏链接。我们放了一个首页、一个指南链接,以及一个 外链 到我们本地运行的 Storybook,将两者关联起来。themeConfig.sidebar: 配置侧边栏。VitePress 支持基于路径的侧边栏,这里我们定义了所有在/guide/路径下的页面都会显示这个“指南”侧边栏。
8.6.4. 定制化首页 (index.md)
CLI 工具同样为我们创建了一个包含示例内容的 docs/index.md。我们可以用项目专属的内容替换它,以启用特殊的“首页”布局(Hero Layout)。
文件路径: docs/index.md
1 | --- |
代码深度解析:
--- ... ---:这是 “Frontmatter”。VitePress 通过读取这里的layout: home来决定使用“首页布局”。hero: 配置了首页大标题(Hero)区域的全部内容,包括名称、标语和两个 CTA(Call to Action)按钮。features: 配置了首页下方的三个特色功能卡片。
8.6.5. 运行开发服务器
最后一步,由于我们在初始化向导中选择了 Yes,VitePress 已经自动在 package.json 中添加了必要的 NPM 脚本。
文件路径: package.json (由 vitepress init 自动添加)
1 | { |
现在,万事俱备。在终端中运行:
1 | pnpm docs:dev |
VitePress 会启动一个开发服务器(通常在 http://localhost:5173)。打开浏览器,你将看到一个功能齐全、样式精美、支持明暗模式切换的专业文档站首页!
VitePress 集成完毕! 我们现在拥有了两个强大的文档系统:
- Storybook:
pnpm storybook,用于组件 API 调试与交互测试。 - VitePress:
pnpm docs:dev,用于项目架构指南与用户教程。
我们已经为 Prorise-Admin 模板建立了一个专业对外的窗口。在后续的章节中,我们将不断地在这个文档站中添加内容,例如 “如何配置路由”、“如何使用主题” 等。
8.6.6. 创建核心文档:快速开始指南
现在我们的 VitePress 框架已经搭建完成,接下来我们需要为项目创建第一份真正的用户文档:快速开始指南。这份文档将帮助模板的使用者快速上手项目,了解所有可用的功能和命令。
第一步:创建指南目录结构
首先,我们需要在 docs 目录下创建一个 guide 子目录,用于存放所有的指南类文档。
1 | mkdir docs/guide |
这个目录结构与我们在 config.ts 中配置的侧边栏路径 /guide/ 相对应,形成了清晰的文档组织架构。
第二步:编写快速开始文档
创建 docs/guide/getting-started.md 文件,这将是用户接触项目的第一份文档。
文件路径: docs/guide/getting-started.md
1 | # 快速开始 |
文档普遍编写流程::
- 结构化的章节设计:文档按照用户的使用流程组织,从环境准备、安装、启动到深入使用,层层递进。
- VitePress 特性使用:
::: tip和::: warning:使用 VitePress 的自定义容器功能,将重要提示以醒目的方式呈现。- Markdown 表格:清晰地展示技术栈信息,便于用户快速了解项目使用的工具。
- 完整的脚本清单:将
package.json中所有可用的命令按功能分类展示,这对于新用户了解项目能力至关重要。 - 技术栈对照表:不仅列出了使用的技术,还附带了官方文档链接和简短说明,降低了学习成本。
- 项目特性概览:用简洁的条目列举项目的核心能力,让用户快速了解项目价值。
- 引导式的"下一步":通过内部链接引导用户继续探索,形成文档的导航网络。
第三步:更新配置文件
确保 docs/.vitepress/config.ts 中的侧边栏配置包含了新创建的快速开始页面:
文件路径: docs/.vitepress/config.ts
1 | import { defineConfig } from "vitepress"; |
配置解析:
- 导航栏链接:在顶部导航栏中添加了"快速开始"的直达链接,提升了文档的可访问性。
- 侧边栏配置:使用路径前缀
/guide/来匹配所有指南页面,确保在浏览指南时,侧边栏始终显示指南的导航结构。 - 可扩展性:配置结构清晰,便于后续添加更多文档页面,只需在
items数组中追加新项即可。
第四步:验证文档
现在重启文档开发服务器(如果已经在运行):
1 | # 如果服务器正在运行,按 Ctrl+C 停止,然后重新启动 |
打开浏览器访问 http://localhost:5173,你将看到:
- 首页的导航栏中出现了"快速开始"链接
- 点击进入后,左侧显示指南的侧边栏导航
- 主内容区域展示了完整的快速开始文档,包含所有格式化的提示框、代码块和表格
文档系统的协同效应:
至此,我们的文档生态系统已经形成了完整的闭环:
| 文档类型 | 工具 | 访问地址 | 目标用户 |
|---|---|---|---|
| 组件 API 文档 | Storybook | http://localhost:6006 | 组件开发者 |
| 项目使用指南 | VitePress | http://localhost:5173 | 模板使用者 |
这两个系统通过配置文件中的链接相互关联,为不同角色的用户提供了各自需要的信息入口。当开发者需要了解某个组件的 API 时,可以访问 Storybook;当新用户需要了解如何使用整个项目模板时,可以访问 VitePress 文档站。
下一步计划:
现在我们有了第一份文档,接下来可以继续扩展文档体系,例如:
docs/guide/directory-structure.md- 项目结构说明docs/guide/theme-customization.md- 主题定制指南docs/guide/development.md- 开发指南docs/guide/faq.md- 常见问题
每添加一份新文档,只需在 config.ts 的 sidebar.items 中添加相应的链接即可。VitePress 的约定式路由会自动处理页面的渲染和导航,我们的项目还没有完全构建完毕,所以这些文档相关的我们以后单开一个章节来做,现在先放着吧!
8.7. 本章小结 & 代码入库(含bug修复)
在本章中,我们以构建 Prorise-Admin 的第一个原子组件 Button 为载体,完整地实践了一套现代化、高标准的 TDD/CDD/DDD 联合工作流。我们建立的这套流程,将成为后续所有 src/ui 组件开发的“黄金标准”。
回顾本章,我们取得了以下核心进展:
确立了设计哲学 (
8.1):- 我们明确了
src/ui目录(Headless, Tailwind 驱动)与Ant Design(功能复杂, 开箱即用)的职责边界,为项目的 UI 体系奠定了战略基础。
- 我们明确了
构建了核心工具链 (
8.2):- 我们引入了
class-variance-authority (cva)来声明式地管理样式变体。 - 引入了
tailwind-merge和clsx,并封装了cn工具函数,完美解决了 Tailwind 类名合并与冲突的工程难题。
- 我们引入了
实践了 TDD/CDD 循环 (
8.3):- Story 先行 (
8.3.2): 我们在button.stories.tsx中首先“设计”了Button的 API (argTypes) 和所有视觉状态,将其作为开发的“动态规约”。 - Test 驱动 (
8.3.3): 我们在button.test.tsx中编写了单元测试(基于 Vitest +jsdom),通过“红-绿”循环(RED/GREEN)驱动了组件的基础功能实现。 - 编码实现 (
8.3.4): 我们使用React.forwardRef、cva和cn函数,逐步完成了Button组件的编码,使所有测试通过。 - 功能增强 (
8.3.5): 我们引入@radix-ui/react-slot,实现了asChild多态渲染,极大提升了组件的灵活性。
- Story 先行 (
建立了双层文档系统 (
8.4,8.6):- Storybook MDX (
8.4): 我们创建了button.mdx,构建了面向开发者/维护者的“API 技术实验室”。我们学会了分离 CSF (.stories.tsx) 和叙事层 (.mdx),并使用<Meta>,<Story>,<Controls>将两者无缝结合。 - VitePress (
8.6): 我们独立搭建了docs目录,构建了面向模板使用者的“项目用户手册”。这为我们沉淀项目级架构和教程提供了平台。
- Storybook MDX (
实现了自动化交互测试 (
8.5):- 我们利用 Storybook 的
play函数和@storybook/test工具集,为Button编写了运行在真实浏览器中的交互测试,验证了onClick行为,为组件质量提供了坚实的保障。
- 我们利用 Storybook 的
代码入库:组件开发工作流蓝图
我们已经完成了 src/ui 的第一个完整实例,以及两大文档系统的基础搭建。这个提交具有里程碑意义,它为后续所有组件开发提供了可复制的蓝图。
第一步:检查代码状态
使用 git status 查看变更。
1 | git status |
你会看到大量的新增文件和修改:
- 组件实现:
src/components/ui/button/目录下的所有文件 (.tsx,.variants.tsx,.test.tsx,.stories.tsx,.mdx)。 - 工具函数:
src/utils/cn.ts(如果这是第一次创建)。 - 测试配置:
vitest.config.ts,vitest.setup.ts,vitest.shims.d.ts以及tsconfig.json的修改。 - 文档系统:
docs/目录下的所有文件 (index.md,.vitepress/config.ts)。 - 依赖项:
package.json/pnpm-lock.yaml(新增了cva,clsx,tailwind-merge,vitepress,@testing-library/*,@radix-ui/react-slot等)。
第二步:暂存所有变更
将所有新文件和修改添加到暂存区。
1 | git add . |
第三步:配置 Biome 忽略规则
在执行提交前,我们需要正确配置 Biome,以避免对自动生成的文件和第三方库产生的缓存文件进行检查。Biome 2.x 使用 files.includes 配合排除模式(! 前缀)来实现文件忽略。
文件路径: biome.json
1 | { |
配置要点解析:
vcs.enabled: true: 启用版本控制系统集成,让 Biome 能够识别 Git 仓库。vcs.useIgnoreFile: true: 让 Biome 遵守项目的.gitignore文件规则。files.includes: 使用**匹配所有文件,然后用!前缀排除特定文件和目录。- 关键忽略项:
auto-imports.d.ts:unplugin-auto-import自动生成的类型声明文件。docs/.vitepress/cache: VitePress 的缓存目录,包含大量编译后的 JavaScript 文件。docs/.vitepress/dist: VitePress 的构建输出目录。node_modules,dist,build: 标准的依赖和构建产物目录。storybook-static: Storybook 的静态构建输出。
文件路径: .gitignore
同时,我们需要在 .gitignore 中添加相应的规则,确保这些文件不会被提交到 Git 仓库:
1 | # Auto-generated files |
常见陷阱提示:
- Biome 2.x 不再支持
files.ignore配置项,必须使用files.includes配合排除模式。 - 文件夹路径不需要
/**后缀,直接写文件夹名即可,如!node_modules而不是!node_modules/**。 - 启用
vcs.useIgnoreFile后,Biome 会同时遵守.gitignore和biome.json中的规则,形成双重保护。
第四步:执行提交
现在配置已经就绪,我们编写一条符合"约定式提交"规范的 Commit Message。这是一个包含多项功能的重大更新,我们将重点放在 ui 组件和 docs 上。
1 | git commit -m "feat(ui, docs): build Button component with full TDD/CDD/DDD workflow and setup VitePress" |
这条消息清晰地表明我们完成了 Button 组件的完整工作流(TDD/CDD/DDD),并搭建了 VitePress 文档站。
一个可复用的蓝图: 这次提交的价值远超一个按钮。它为 Prorise-Admin 项目沉淀了一套完整的、可重复的组件开发模式。从现在开始,开发任何新组件,都可以复用这套“Story -> Test -> Code -> Docs”的黄金流程。













