第八章. UI 原子:TDD/CDD 驱动构建 src/ui 与文档先行

第八章. TDD/CDD 驱动构建 src/ui

src/ui 目录将存放我们项目中 最基础、与业务无关、高度可复用 的 UI 原子组件,借鉴 Headless UIshadcn/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 组件的核心特征:

  1. 原子性:此目录下的组件应代表设计系统中最基础的、不可再分的 UI 单元,例如 Button, Input, Avatar, Badge 等。它们是构成更复杂组件(无论是在 src/components 还是页面中)的基石。
  2. Headless / Unstyled (无样式或最小化样式):受到 Headless UI 和 shadcn/ui 等库的启发,src/ui 组件的核心职责是提供 结构 (HTML)、功能 (JS Logic)、状态管理 (e.g., controlled/uncontrolled) 和无障碍性 (ARIA attributes)。它们 故意不包含 或只包含极少量的内置视觉样式(例如,可能只包含必要的 display: blockposition: relative)。
  3. 样式由外部注入:视觉呈现 完全 由外部通过 Tailwind CSS 原子类 来定义。这可以通过两种方式实现:
    • 直接应用:在使用组件时,直接通过 className prop 传入 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. 技术选型:cvatailwind-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 的核心能力包括:

  1. 声明式变体定义:允许你清晰地定义基础样式和基于不同属性(如 variant, size)的样式变体。
  2. 类型安全:与 TypeScript 深度集成,可以自动推断或显式定义变体属性的类型,并在使用时提供类型检查和自动补全。
  3. 复合变体:支持定义当多个属性同时满足特定条件时应用的额外样式。
  4. 默认变体:可以指定在未提供相应属性时默认应用的变体。

基础 API 示例 (cva(base?, config?)):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { cva } from "class-variance-authority";

// 定义按钮的样式变体
const buttonVariants = cva(
// 参数 1: 基础类 (应用于所有变体)
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
// 参数 2: 配置对象
variants: {
// 'variant' 属性的可选值及其对应的 Tailwind 类
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
// 'size' 属性的可选值及其对应的 Tailwind 类
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10", // 示例:为图标按钮定义特定尺寸
},
},
// (可选) 复合变体
// compoundVariants: [
// { variant: "outline", size: "lg", className: "border-2" },
// ],
// 默认应用的变体
defaultVariants: {
variant: "default",
size: "default",
},
}
);

// 使用示例 (在组件内部)
// const className = buttonVariants({ variant: 'destructive', size: 'sm' });

项目集成确认:我们可以在项目的 package.json 文件中确认 class-variance-authority 已经作为依赖项被安装:

1
2
3
4
5
6
// package.json (部分)
"dependencies": {
// ...
"class-variance-authority": "^0.7.1", // <-- 确认已安装
// ...
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { twMerge } from 'tailwind-merge';

// 基础合并与冲突解决
twMerge('p-2', 'px-4'); // => 'p-2 px-4' (px-4 覆盖 p-2 的水平部分)
twMerge('bg-red-500', 'bg-blue-500'); // => 'bg-blue-500' (后者覆盖前者)

// 移除冗余
twMerge('p-4', 'p-4'); // => 'p-4'

// 处理更复杂的组合
twMerge(
'py-2 px-4', // 基础样式
'bg-blue-500', // 背景色
'hover:bg-blue-700', // 悬停样式
'py-3' // 覆盖基础样式的垂直 padding
);
// => 'px-4 bg-blue-500 hover: bg-blue-700 py-3'

项目集成确认:同样,我们在 package.json 中确认 tailwind-merge 已安装:

1
2
3
4
5
6
// package.json (部分)
"dependencies": {
// ...
"tailwind-merge": "^3.2.0", // <-- 确认已安装
// ...
}

8.2.3. 两者结合:创建 cn 工具函数

在实际组件开发中,我们几乎总是需要同时处理:

  1. 根据组件的 props 使用 cva 生成基础和变体类名。
  2. 接收外部传入的 className prop 以允许用户进行定制。
  3. 确保这两部分类名能够被 智能地、无冲突地 合并。
  4. (可选)有时还需要根据组件的内部状态或其他条件 动态地 添加类名。

社区的最佳实践是创建一个名为 cn (classnames 的缩写) 的工具函数,它通常结合了以下两个库的能力:

  • clsx: 一个用于 条件性 地拼接类名的微型库。它可以接收字符串、对象(键是类名,值是布尔值)、数组等多种输入。
  • tailwind-merge: 用于对 clsx 生成的初步结果进行最终的 冲突解决和优化

项目集成确认
package.json 显示 clsx 也已安装:

1
2
3
4
5
6
// package.json (部分)
"dependencies": {
// ...
"clsx": "^2.1.1", // <-- 确认已安装
// ...
}

cn 工具函数的实现:常见的做法是将其放在一个专门的文件 src/utils/cn.ts 中,以保持工具函数的职责单一性。我们将遵循规范的方式来讲解。

文件路径: src/utils/cn.ts (推荐创建此文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

/**
* 合并 CSS 类名,并使用 tailwind-merge 解决冲突。
*
* @param inputs - 接受任意数量的参数,可以是字符串、对象、数组等 (同 clsx)。
* @returns 优化后的 className 字符串。
*
* @example
* cn('p-4', 'bg-red-500', { 'font-bold': true, 'text-lg': false }, ['hover: opacity-75'])
* // => "p-4 bg-red-500 font-bold hover: opacity-75"
*
* // 结合 cva 使用:
* // cn(buttonVariants({ variant, size }), className)
*/
export function cn(...inputs: ClassValue[]) {
// 1. clsx 先处理条件和格式,生成一个可能包含冲突或冗余的类名字符串
const preliminaryClassName = clsx(inputs);
// 2. twMerge 再对结果进行智能优化
return twMerge(preliminaryClassName);
}

工作流程与价值

  1. clsx(inputs) 负责将所有输入(无论是什么格式,无论是否满足条件)转换为一个单一的类名字符串。
  2. twMerge(...) 接收这个字符串,并应用其对 Tailwind 类的理解,智能地移除冲突项(保留优先级更高的)和重复项。

最终使用模式 (结合 cva):在我们的 src/ui 组件中,最常见的 className 应用方式将是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { cn } from "@/utils/cn"; // 导入 cn 工具函数
import { buttonVariants } from "./variants"; // 导入 cva 定义

function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
// 1. 调用 cva 生成变体类
// 2. 传入外部 className
// 3. cn 函数负责将两者完美合并
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}

阶段性成果:我们为构建 src/ui 组件库奠定了坚实的技术基础。通过引入 cva 来管理样式变体,引入 tailwind-merge 来解决类名冲突,并创建一个结合两者的 cn 工具函数,我们现在拥有了一套专业、高效、类型安全的工具链来处理 Tailwind CSS 类的复杂组合。这为我们在下一节开始实战 Button 组件做好了充分准备。


8.3. 从零到一,构建企业级 Button 组件

在本节中,我们将以项目中最基础也最重要的原子组件——Button——为例,完整地、一步一步地实践我们在本章开头定下的 TDD/CDD (测试/组件驱动开发) 核心工作流。我们的目标不只是写出一个按钮,而是要内化一种 设计先行、测试驱动、逐步实现 的现代化前端开发思维。我们将深入 shadcn/ui 的设计哲学,手动实现其核心模式,从而真正掌握它,而不仅仅是会用它的命令行工具。

8.3.1. 谋定而后动:可扩展的组件文件结构

在开始编写任何代码之前,我们首先要解决一个架构问题:我们的组件文件应该放在哪里?

一个常见的做法是在 src 下创建一个 uicomponents 目录,然后将所有组件文件(如 Button.tsx, Input.tsx)直接放在里面。当组件数量稀少时,这看起来很整洁。但随着项目规模扩大,这个文件夹会迅速变得混乱不堪,一个组件相关的逻辑、样式、故事和测试文件散落各处,难以维护。

因此,我们将采用一种更具扩展性的 “独立文件夹” 模式。

设计决策

每一个独立的 UI 组件,都应该拥有自己的文件夹。该文件夹将内聚与此组件相关的所有文件:实现 (.tsx)、故事 (.stories.tsx) 和测试 (.test.tsx)。

实践步骤

  1. src/components 目录下,创建一个名为 ui 的子目录,专门用于存放我们的基础 UI 组件库。
  2. src/components/ui 内部,为我们的 Button 组件创建一个专属的文件夹。

现在,打开您的终端,执行以下命令:

1
mkdir -p src/components/ui/button

接着,我们预先创建好这个组件所需的核心文件:

1
2
3
touch src/components/ui/button/button.tsx
touch src/components/ui/button/button.stories.tsx
touch src/components/ui/button/button.test.tsx
1
2
3
4
5
6
7
src/
└── components/
└── ui/
└── button/
├── button.stories.tsx # Storybook 设计与文档
├── button.test.tsx # Vitest 单元/集成测试
└── button.tsx # 组件实现

这种结构的好处是显而易见的:高内聚、易查找、易删除/迁移。当您想了解 Button 的一切时,只需进入这个文件夹即可。这也为未来使用 shadcn-uiadd 命令自动生成组件代码打下了良好的基础。

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 核心概念
M

什么是 CSF 3.0?

A
architect

简单来说,它是一种用 ES6 模块来编写 Storybook 故事的标准化格式。

A
architect

核心有两个部分:一个 default export (默认导出) 的 Meta 对象,用来描述组件的元信息;以及多个 named export (具名导出) 的 Story 对象,每个代表组件的一个具体状态。

M

明白了,就是用标准化的 JS 对象来定义一切。

现在,让我们编写 Meta 对象。

文件路径: src/components/ui/button/button.stories.tsx (阶段一)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import type { Meta, StoryObj } from '@storybook/react-vite';

// 注意:此时 Button 还不存在,IDE 会报错。
// 这是 TDD/CDD 流程的正常现象,我们正在为未来的组件编写“规约”。
import { Button } from './button';

// Meta 对象定义了这组故事的“组件级”配置
const meta: Meta<typeof Button> = {
// title 决定了故事在 Storybook 侧边栏的显示路径
title: 'UI/Button',
// component 字段将故事与实际的 Button 组件关联起来
component: Button,
// parameters 用于配置 Storybook 的功能,layout: 'centered' 使组件在 Canvas 中居中显示
parameters: {
layout: 'centered',
},
// tags: ['autodocs'] 会为这个组件自动生成文档页
tags: ['autodocs'],
};

export default meta;

// 定义 Story 类型,方便后续编写故事时获得 TypeScript 类型提示
type Story = StoryObj<typeof meta>;

第二步:用 argTypes 设计组件的“契约”

argTypesMeta 对象中一个至关重要的属性。它不仅能配置 Storybook 中 Controls 面板的交互方式,更核心的是,它就是我们 设计和定义组件 props 的地方

让我们为 Button 设计几个核心 props

  • variant: 按钮的视觉风格(例如,默认、危险、描边等)。
  • size: 按钮的尺寸(例如,大、中、小)。
  • children: 按钮显示的内容。

继续编辑 meta 对象,添加 argTypes

文件路径: src/components/ui/button/button.stories.tsx (阶段二)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ... (imports)
const meta: Meta<typeof Button> = {
// ... (title, component, parameters, tags)

// [核心] argTypes 就是我们组件的 API 设计文档
argTypes: {
variant: {
control: 'select', // 在 Controls 面板中使用“下拉选择”控件
options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'], // 定义可选值
description: '按钮的视觉风格', // 在文档中显示的描述
},
size: {
control: 'radio', // 使用“单选按钮”控件
options: ['default', 'sm', 'lg', 'icon'],
description: '按钮的尺寸',
},
children: {
control: 'text', // 使用“文本输入”控件
description: '按钮内部显示的内容',
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

第三步:编写第一个可交互的“主故事” (Primary)

现在,我们来编写第一个具名导出的 Story 对象。这个故事通常被称为 PrimaryDefault,它将作为用户在 Controls 面板中进行交互和实验的“主模板”。

文件路径: src/components/ui/button/button.stories.tsx (阶段三)

1
2
3
4
5
6
7
8
9
10
11
12
13
// ... (meta 对象)
export default meta;
type Story = StoryObj<typeof meta>;

// Primary Story: 一个可交互的、配置齐全的基础按钮
export const Primary: Story = {
// args 定义了这个故事中组件的默认 props
args: {
variant: 'default',
size: 'default',
children: 'Primary Button',
},
};

第四步:编写代表各种状态的“文档故事”

为了让其他人能一目了然地看到 Button 的所有核心变体,我们继续编写更多的故事,每个故事固定一种关键的 props 组合。

文件路径: src/components/ui/button/button.stories.tsx (阶段四)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ... (Primary Story)

export const Destructive: Story = {
args: {
variant: 'destructive',
children: 'Destructive',
},
};

export const Outline: Story = {
args: {
variant: 'outline',
children: 'Outline',
},
};

export const Large: Story = {
args: {
size: 'lg',
children: 'Large Button',
},
};

export const Small: Story = {
args: {
size: 'sm',
children: 'Small Button',
},
};

Story 先行完成! 此刻,button.stories.tsx 已经成为我们 Button 组件的一份 动态设计规约。我们已经清晰地定义了它的 API 和视觉状态,尽管我们一行实现代码都还没写。

如果你现在尝试运行 Storybook ( pnpm storybook ),它会因为找不到 ./button 模块而构建失败。

预期中的失败! 这正是 TDD/CDD 流程的关键一步。我们先用 Story 和 Test 定义了“目标”,下一步的任务就是通过编码,让这个“目标”得以实现。


8.3.3. Test 驱动:用测试定义组件的行为

在进入编码实现之前,我们还有一步:编写测试。测试用例会把我们在 Storybook 中设计的“视觉规约”转化为更严格的“行为规约”。

第一步:搭建 Vitest 测试环境

我们的项目需要一些库来支持 React 组件的测试。

  1. 安装依赖

    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: 模拟真实用户交互(点击、输入等),比底层事件触发更可靠。
  2. 配置 Vitest

    在项目根目录修改 vitest.config.ts 文件,这个文件是我们创建 vite 项目默认自带的,后续被 Storybook 新增了属于他的测试

    文件路径: vitest.config.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    test: {
    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",
    },
    },
    ],
    },
  3. 创建测试设置文件

    我们需要一个设置文件来引入 @testing-library/jest-dom 的匹配器。

    文件路径: vitest.setup.ts

    1
    import '@testing-library/jest-dom';
  4. 配置 TypeScript 类型声明

    为了让 TypeScript 识别 toBeInTheDocument 等匹配器,需要添加类型引用。

    文件路径: vitest.shims.d.ts(如果文件已存在则追加)

    1
    /// <reference types="@testing-library/jest-dom" />

    然后在 tsconfig.jsoninclude 数组中添加此文件:

    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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';

// 同样,这里会因为 Button 不存在而报错
import { Button } from './button';

// 'describe' 用于将一组相关的测试用例组织在一起
describe('Button Component', () => {

// 'it' 或 'test' 定义了一个具体的测试用例
it("应该需要有一个子元素且能够被渲染", () => {
// 1. Arrange (安排): 准备测试环境和输入
render(<Button>Click Me</Button>);

// 2. Act (行动): 执行查询操作
// screen.getByRole 是一个强大的查询工具,它鼓励我们编写无障碍的代码。
// 它会寻找一个 role 为 'button' 且可访问名称 (accessible name) 包含 "Click Me" (不区分大小写) 的元素。
const buttonElement = screen.getByRole("button", { name: /click me/i });

// 3. Assert (断言): 验证结果是否符合预期
// toBeInTheDocument 是 @testing-library/jest-dom 提供的匹配器
expect(buttonElement).toBeInTheDocument();
});
});

第三步:配置测试快捷指令

package.json 中添加测试相关的脚本:

文件路径: package.json

1
2
3
4
5
6
7
8
9
{
"scripts": {
"test": "vitest", // 运行所有测试(单元测试 + Storybook测试)
"test:ui": "vitest --ui", // 启动可视化测试界面
"test:unit": "vitest --project unit", // 只运行单元测试
"test:storybook": "vitest --project storybook", // 只运行Storybook测试
"test:coverage": "vitest --coverage" // 运行测试并生成覆盖率报告
}
}

第四步:运行测试并拥抱失败

在终端中,启动 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import * as React from 'react';

// 定义最基础的 props 类型,它继承了所有原生 button 元素的属性
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, ...props }, ref) => {
return (
<button className={className} ref={ref} {...props}>
{children}
</button>
);
}
);
// 为组件设置一个 displayName,这在 React DevTools 中调试时非常有用
Button.displayName = 'Button';

export { Button };

在你保存这个文件的瞬间,切换到 Vitest UI 的浏览器窗口,你会惊喜地发现:

GREEN! 我们的第一个测试用例已经通过了!我们通过编写最少的代码,满足了测试的规约。

第二步:引入 CVA,为样式变体做准备

现在,我们需要处理在 Storybook 中设计的 variantsize。如果用 if/else 或三元运算符来拼接 Tailwind CSS 类,代码会变得混乱不堪。这时,我们需要一个强大的工具:class-variance-authority (CVA)。

CVA 简介
M

CVA 是做什么的?

A
architect

它是专门用来管理组件样式变体的。你给它定义好基础类、不同的变体(如 variant, size)以及对应的 Tailwind 类,它就会返回一个函数。

A
architect

调用这个函数并传入 props (如 { variant: 'destructive' }),它就会智能地返回正确的 class 字符串。

M

听起来像是样式的状态机,输入 props,输出 class。

A
architect

完全正确!它让样式管理变得声明式和可预测。

首先,安装它:

1
pnpm add class-variance-authority

然后,在 button.tsx 中定义我们的 buttonVariants

文件路径: src/components/ui/button/button.variants.tsx (阶段二)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { cva } from "class-variance-authority";
// [核心] 定义 CVA 变体
export const buttonVariants = cva(
// 1. 基础类 (所有变体共享)
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
// 2. 变体定义
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
// 3. 默认变体
defaultVariants: {
variant: "default",
size: "default",
},
},
);

第三步:回到测试,为样式变体编写新测试 (RED)

我们的代码已经有了处理变体的能力,但组件本身还没使用它。现在,回到 button.test.tsx,添加一个新的测试用例来“驱动”我们完成下一步。

文件路径: src/components/ui/button/button.test.tsx (阶段二)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... (imports)
describe('Button Component', () => {
// ....前面的测试用例保持不变
// 新增测试用例
it("应该需要有一个destructive变体且能够被渲染", () => {
render(<Button variant="destructive">Delete</Button>);
const buttonElement = screen.getByRole("button", { name: /delete/i });

// 我们期望按钮上应用了 destructive 变体对应的背景色和前景色类
// toHaveClass 同样来自 @testing-library/jest-dom
expect(buttonElement).toHaveClass(
"bg-destructive",
"text-destructive-foreground",
);
});
});

保存后,Vitest UI 会显示一个新的 失败 (RED) 测试。这是因为它渲染的 Button 还没有应用任何 CVA 生成的类。

第四步:集成 CVA,让第二个测试通过 (GREEN)

目标明确:修改 Button 组件,让它使用 buttonVariants 函数。

文件路径: src/components/ui/button/button.tsx (阶段三)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/utils/cn";
import { buttonVariants } from "./button.variants";
// 使用 VariantProps 工具类型从 CVA 定义中推断出 variant 和 size 的 props 类型
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, variant, size, ...props }, ref) => {
// [核心] 使用 cn 函数,将 CVA 生成的类和外部传入的 className 智能合并
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
{children}
</button>
);
},
);
// 为组件设置一个 displayName,这在 React DevTools 中调试时非常有用
Button.displayName = "Button";
export { Button };

保存文件,再看 Vitest UI。

GREEN AGAIN! 第二个测试也通过了!我们的 TDD 循环正在健康地运转。

8.3.5. 最终验证与 asChild 的魔法

现在,我们代码的核心功能已经完成,并且有测试覆盖。是时候回到 Storybook 看看我们的劳动成果了。

启动 Storybook:

1
pnpm storybook

打开浏览器,导航到 UI/Button。你会看到,我们在 button.stories.tsx 中定义的所有故事现在都 正确地渲染 了出来!你可以在 Controls 面板中自由组合 variantsize,组件会如预期般变化。

实现 asChild 多态特性

还有一个 shadcn/ui 的常见模式我们没有实现:asChild prop。有时候,我们希望一个组件拥有按钮的样式,但其本身是一个链接 (<a>) 或路由组件 (<Link>)。asChild prop 就是为了解决这个问题。当 asChild={true} 时,Button 组件将不会渲染自己的 <button> 标签,而是会把它所有的 props(包括计算好的 className)“克隆”给它的直接子元素。

这需要借助一个小巧而强大的库:@radix-ui/react-slot

  1. 安装依赖

    1
    pnpm add @radix-ui/react-slot
  2. 修改 Button 实现

文件路径: src/components/ui/button/button.tsx (最终阶段)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot'; // 导入 Slot
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

// ... (buttonVariants 定义)

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean; // 添加 asChild prop
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, children, ...props }, ref) => {
// 根据 asChild 的值,决定渲染的根组件是 Slot 还是 'button'
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
{children}
</Comp>
);
}
);
// ... (displayName, exports)

现在,你可以回到 Storybook,添加一个新的故事来验证 asChild 的功能:

文件路径: src/components/ui/button/button.stories.tsx (补充)

1
2
3
4
5
6
7
8
// ...
export const AsLink: Story = {
args: {
variant: "link",
asChild: true,
children: <a href="https://prorise666.site">I am a link</a>,
},
};

刷新 Storybook,你会看到一个新的 AsLink 故事。使用浏览器的开发者工具检查它,你会发现渲染出来的 DOM 元素是一个 <a> 标签,但它完美地应用了我们 variant: 'link' 的样式。
任务完成!
我们不仅从零开始构建了一个功能完备、样式灵活、类型安全的 Button 组件,更重要的是,我们完整地体验了企业级的 TDD/CDD 开发流程:

  1. 规划了 可扩展的文件结构。
  2. 用 Storybook 作为设计工具,定义了组件的 API 和视觉状态。
  3. 用 Vitest 作为质量保障,编写了失败的测试来驱动开发。
  4. 逐步编码,让测试逐一变绿,最终让 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 中关于文档的 核心设计理念

  1. .stories.tsx (CSF 文件): 这是我们组件状态的 “SSOT”。它像一个数据库,以纯粹、类型安全的 ES 模块形式,导出一系列可交互的组件状态(Stories)。我们在 8.3.2 节中创建的文件就是它。
  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Meta, Story, Controls } from '@storybook/blocks';
import * as ButtonStories from './button.stories';

{/* [核心] Meta 标签是连接的桥梁。
'of={ButtonStories}' 告诉 Storybook:
1. 这个 MDX 文档是关于 ButtonStories.tsx 中定义的那个组件的。
2. 自动从 ButtonStories.tsx 的 meta 对象继承 argTypes、component 等信息。
3. 将 Storybook UI 中的 "Docs" 标签页替换为此文件的内容。
*/}
<Meta of={ButtonStories} />

# Button 按钮

按钮用于触发一个操作,是界面中最基础、最核心的交互元素。

`Prorise-Admin` 的基础 `Button` 组件基于 `shadcn/ui` 和 `cva` 理念构建,
提供了灵活的变体(variants)和尺寸(sizes)配置,并支持通过 `asChild` prop 实现多态渲染。

代码深度解析

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{/* ... 此前内容 ... */}

## 基础用法 (Primary)

这是按钮最基础的用法。你可以在下方的 Controls 面板中实时修改它的 `variant`、`size` 和 `children` 来查看所有组合。

<Story of={ButtonStories.Primary} />

## 风格变体 (Variants)

我们提供了 6 种预设的视觉风格,以应对不同的业务场景。

- `default`: 默认风格,用于主要操作。
- `destructive`: 危险操作,如“删除”。
- `outline`: 描边风格,用于次要操作。
- `secondary`: 次要风格,视觉上比 `default` 弱。
- `ghost`: 幽灵按钮,用于最弱的提示性操作。
- `link`: 链接风格,看起来像一个链接。

<Story of={ButtonStories.Destructive} />
<Story of={ButtonStories.Outline} />

## 尺寸 (Sizes)

我们提供三种标准尺寸,以及一种用于纯图标按钮的 `icon` 尺寸。

- `lg`: 大尺寸 (h-11)
- `default`: 默认尺寸 (h-10)
- `sm`: 小尺寸 (h-9)

<Story of={ButtonStories.Large} />
<Story of={ButtonStories.Small} />

## `asChild` 多态渲染

通过 `asChild` prop,`Button` 组件可以将其样式和行为“附加”到它的直接子元素上。
这在您需要一个**路由链接**(如 React Router 的 `<Link>`)同时又希望它**看起来像个按钮**时非常有用。

<Story of={ButtonStories.AsLink} />

代码深度解析

  • <Story of={...} />: 我们使用 <Story> 标签,并通过 of 属性精确地指向 ButtonStories 对象中的特定 Story(例如 ButtonStories.Primary)。
  • 自动 Controls: 当我们嵌入 Primary Story 时,Storybook 会自动在它下方附带上 Controls 面板(因为 Primary 是一个可交互的主故事)。
  • 静态展示: 当我们嵌入 DestructiveLarge 等 Story 时,Storybook 默认只显示组件画布,这非常适合用于在文档中并列展示特定状态。

第三步:自动生成 API 参考

最后,一份专业的文档必须有一个完整的 API 属性列表。我们不需要手动编写这个表格,@storybook/addon-docs 提供了 <Controls> 组件来自动完成这项工作。

文件路径: src/components/ui/button/button.mdx (最终)

1
2
3
4
5
6
7
8
{/* ... 此前内容 ... */}

## API 参考 (Props)

以下是 `Button` 组件所有可用的 `props`。
这份表格是根据组件的 TypeScript 接口 (`ButtonProps`) 和 `button.stories.tsx` 中的 `argTypes` 自动生成的。

<Controls />

代码深度解析

  • <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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ... (imports)
import { Button } from './button'

const meta: Meta<typeof Button> = {
  title: 'UI/Button'
  component: Button,
  parameters: {
    layout: 'centered',
  },
  // tags: ['autodocs'], // <-- [核心] 删除或注释掉这一行
  argTypes: {
// ... (argTypes 内容)
},
};

export default meta;
// ... (Stories)

8.4.5. 最终验证

现在,让我们重启 Storybook(如果它正在运行,它可能会自动热更新)。

1
pnpm storybook

再次访问浏览器中的 UI/Button。你会发现 “Docs” 标签页的内容已经 完全被我们的 button.mdx 文件所取代

你现在拥有了一个:

  1. 叙事清晰:包含了我们用 Markdown 编写的介绍和指南。
  2. 交互式:嵌入了可实时操作的 Primary Story 和 Controls。
  3. 图文并茂:并列展示了 DestructiveLarge 等关键变体。
  4. 自动同步:拥有一个根据代码自动生成的 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
2
3
4
5
it("应该需要有一个子元素且能够被渲染", () => {
render(<Button>Click Me</Button>);
const buttonElement = screen.getByRole("button", { name: /click me/i });
expect(buttonElement).toBeInTheDocument();
});

这被称为 单元测试。它运行在 jsdom(一个 Node.js 中的模拟浏览器环境)中,速度极快,非常适合用来验证:

  • 组件是否能成功渲染(toBeInTheDocument)。
  • 组件是否根据 props 应用了正确的 classNametoHaveClass)。

然而,它 无法 验证:

  • 组件在真实浏览器布局中的视觉表现。
  • CSS hoverfocus-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
qq

我们将回到 button.stories.tsx 文件,添加一个新的、专门用于测试交互的 Story。

文件路径: src/components/ui/button/button.stories.tsx (补充)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// [1. 导入测试工具]
import { Meta, StoryObj } from '@storybook/react-vite';
import { fn, within, userEvent, expect } from '@storybook/test'; // 导入 vitest 和 testing-library 的工具
import { Button } from './button';

const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
// ... (parameters)
// 关键!👇 添加 test 标签,使这个组件的所有 stories 都可以被 Vitest 插件测试
tags: ["test"],

// [2. 关键:为 onClick prop 启用 action]
// 这会让我们在 Storybook UI 的 "Actions" 选项卡中看到点击事件
// 并且,@storybook/test 的 fn() 会自动 "spy" 这个 action
argTypes: {
// ... (我们之前定义的 variant, size, children)
onClick: { action: 'clicked' }, // 告诉 Storybook 追踪 onClick prop
},
};
export default meta;

type Story = StoryObj<typeof meta>;

// ... (Primary, Destructive, Outline, Large, Small, AsLink 等故事)

// [3. 添加一个新的 Story 专门用于交互测试]
export const WithClickInteraction: Story = {
// 我们为这个 Story 提供一个 mock 的 onClick 回调
// 我们使用 fn() 来创建一个 Vitest "spy" 函数
// Storybook 会自动将它连接到 argTypes 中定义的 'clicked' action
args: {
variant: 'secondary',
children: 'Click Me!',
onClick: fn(), // <-- [核心] 创建一个可被追踪的 spy 函数
},

// [4. 编写 play 函数]
// 这是一个异步函数,它会在组件渲染后执行
play: async ({ canvasElement, args }) => {
// 'canvasElement' 是这个 Story 渲染所在的根 DOM 元素

// [A] 获取画布和组件
// 使用 'within' 将查询范围限定在当前 Story 的画布内
const canvas = within(canvasElement);

// [B] 查找元素
// 使用最佳实践 getByRole 查找按钮
const button = canvas.getByRole('button', { name: /click me/i });

// [C] 模拟交互
// 模拟真实用户点击按钮
await userEvent.click(button);

// [D] 断言
// 验证我们传入的 spy 函数 (args.onClick) 是否被调用了
await expect(args.onClick).toHaveBeenCalled();
await expect(args.onClick).toHaveBeenCalledOnce(); // 确保只被调用了一次
},
};

代码深度解析

  1. argTypes: { onClick: ... }:我们首先在 meta 中告诉 Storybook,onClick 是一个我们关心的 “action”。
  2. args: { onClick: fn() }:在 WithClickInteraction Story 中,我们必须传入一个真实的函数作为 onClick prop。fn() 创建了一个 Vitest 间谍函数,它能记录所有对它的调用。
  3. play: async ({ canvasElement, args }) => ...:这是 play 函数的主体。
  4. within(canvasElement):获取当前 Story 的 “画布”,确保我们的测试不会意外查询到 Storybook UI 的其他部分。
  5. userEvent.click(button):模拟一次完整的、高保真的用户点击。
  6. 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 快照。

image-20251026141222989

这是一个极其强大的 可视化调试工具,你可以亲眼看到你的交互脚本是如何执行的。

2. 自动化测试 (在 CI/CD 中)

这个 play 函数同时也是一个 完整的自动化测试用例

  • 8.3.3 节中,我们已经配置了 vitest.config.ts 来运行 storybook 项目,并添加了 pnpm test:storybook 脚本。

  • 现在,在你的终端中运行这个脚本:

    1
    pnpm test:storybook
  • Vitest 会启动 Playwright,在 后台无头浏览器 中加载你所有的 Stories。

  • 当它加载到 WithClickInteraction Story 时,它会自动执行 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?”“我该 如何使用 这个模板添加一个新页面?”
Buttonvariant='destructive' 时什么样?”“项目的 权限系统 是如何设计的?”
核心价值与组件代码并置,提供交互式 API 调试。内容优先,提供教程、指南和架构说明。

这两者 完美互补。Storybook 是我们 src/ui 组件库的“API 参考”,而 VitePress 是我们整个 Prorise-Admin 模板的“产品说明书”。

8.6.2. 快速启动:使用官方 CLI 初始化

VitePress 提供了强大的 CLI 工具,可以通过一个交互式向导快速搭建整个文档站点,这是官方推荐的最佳实践。

第一步:安装依赖

首先,我们将 VitePress 作为项目的开发依赖项进行安装。

1
pnpm add -D vitepress

第二步:运行初始化向导

接下来,在你的项目根目录下运行 init 命令。

1
2
# 在项目根目录执行
pnpm dlx vitepress init

这个命令会启动一个交互式的设置向导,它会询问你几个问题来配置你的文档站:

  • 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
2
3
4
5
6
7
8
9
10
/
├── docs/
│ ├── .vitepress/
│ │ └── config.ts # [核心] VitePress 配置文件
│ ├── api-examples.md # 示例页面
│ ├── markdown-examples.md # 示例页面
│ └── index.md # 我们的文档站首页
├── src/
│ └── ... (项目源代码)
└── ... (package.json 等)

8.6.3. 定制化配置 (config.ts)

初始化向导已经为我们生成了一个功能齐全的 docs/.vitepress/config.ts 文件。现在我们只需要根据项目需求对其进行微调。

打开 docs/.vitepress/config.ts,并将其修改为以下内容:

文件路径: docs/.vitepress/config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { defineConfig } from 'vitepress'

// https://vitepress.dev/reference/site-config
export default defineConfig({
// [站点元数据]
title: "Prorise Admin", // 站点标题,会显示在浏览器标签页
description: "企业级后台管理系统模板,基于 React 19、Vite 与 Ant Design。", // 站点描述,用于 SEO

// [主题配置]
themeConfig: {
// https://vitepress.dev/reference/default-theme-config

// [导航栏]
nav: [
{ text: '首页', link: '/' },
{ text: '快速开始', link: '/guide/getting-started' }, // 示例链接
{ text: 'Storybook', link: 'http://localhost:6006' } // 示例:外链到 Storybook
],

// [侧边栏]
sidebar: {
'/guide/': [
{
text: '指南',
items: [
{ text: '快速开始', link: '/guide/getting-started' },
{ text: '项目结构', link: '/guide/directory-structure' }
// 更多指南...
]
}
]
},

// [社交链接]
socialLinks: [
{ icon: 'github', link: 'https://github.com/your-repo/prorise-admin' } // 替换为你的仓库链接
]
}
})

代码深度解析

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
---
# 这是一个特殊的 "Frontmatter" 块
# 它用于配置 VitePress 的主题功能
layout: home

hero:
name: "Prorise Admin"
text: "现代企业级后台管理模板"
tagline: 基于 React 19、Vite、Ant Design 5、Tailwind CSS 和 TypeScript 构建。
actions:
- theme: brand
text: 快速开始
link: /guide/getting-started
- theme: alt
text: 在 GitHub 上查看
link: https://github.com/Prorise-cool/Prorise-admin

features:
- title: 🚀 最新技术栈
details: 基于 React 19、Vite 6、AntD 5 与 Tailwind CSS 4,享受极致的开发体验。
- title: 📦 企业级架构
details: 预设动态路由、权限控制、API Mock、国际化等企业级解决方案。
- title: 🎨 深度主题定制
details: 利用 Vanilla Extract 实现动态主题切换,与 AntD 完美融合。
---

代码深度解析

  • --- ... ---:这是 “Frontmatter”。VitePress 通过读取这里的 layout: home 来决定使用“首页布局”。
  • hero: 配置了首页大标题(Hero)区域的全部内容,包括名称、标语和两个 CTA(Call to Action)按钮。
  • features: 配置了首页下方的三个特色功能卡片。

8.6.5. 运行开发服务器

最后一步,由于我们在初始化向导中选择了 Yes,VitePress 已经自动在 package.json 中添加了必要的 NPM 脚本。

文件路径: package.json (由 vitepress init 自动添加)

1
2
3
4
5
6
7
8
{
"scripts": {
// ... (已有的 test, storybook 等脚本)
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
}
}

现在,万事俱备。在终端中运行:

1
pnpm docs:dev

VitePress 会启动一个开发服务器(通常在 http://localhost:5173)。打开浏览器,你将看到一个功能齐全、样式精美、支持明暗模式切换的专业文档站首页!
VitePress 集成完毕! 我们现在拥有了两个强大的文档系统:

  1. Storybookpnpm storybook,用于组件 API 调试与交互测试。
  2. VitePresspnpm 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# 快速开始

欢迎使用 **Prorise Admin**!本指南将帮助你在几分钟内启动并运行项目。

## 环境准备

在开始之前,请确保你的开发环境满足以下要求:

- **Node.js**: >= 20.0.0
- **pnpm**: >= 9.0.0

::: tip 为什么使用 pnpm?
Prorise Admin 使用 pnpm 作为包管理器,它更快、更节省磁盘空间。如果你还没有安装 pnpm,可以通过以下命令安装:

``` bash
npm install -g pnpm
​```
:::

## 安装依赖

克隆项目后,在项目根目录运行:

```bash
pnpm install
​```

::: warning 注意
项目配置了 `preinstall` 钩子,强制使用 pnpm。如果你尝试使用 npm 或 yarn,安装将会失败。
:::

## 启动开发服务器

安装完成后,运行以下命令启动开发服务器:

```bash
pnpm dev
​```

开发服务器默认运行在 `http://localhost:5173`,打开浏览器访问即可看到应用。

::: tip 热重载
项目支持 HMR (Hot Module Replacement),你的修改会立即反映在浏览器中,无需手动刷新。
:::

## 项目脚本

Prorise Admin 提供了丰富的命令行脚本:

### 开发相关

```bash
# 启动开发服务器
pnpm dev

# 构建生产版本
pnpm build

# 预览生产构建
pnpm preview
​```

### 代码质量

```bash
# 代码检查
pnpm lint

# 自动修复代码问题
pnpm lint: fix

# 格式化代码
pnpm format
​```

### 组件开发

```bash
# 启动 Storybook 组件文档(运行在 http://localhost: 6006)
pnpm storybook

# 构建 Storybook 静态文件
pnpm build-storybook
​```

### 测试

```bash
# 运行所有测试
pnpm test

# 使用 UI 界面运行测试
pnpm test: ui

# 运行单元测试
pnpm test: unit

# 运行 Storybook 测试
pnpm test: storybook

# 生成测试覆盖率报告
pnpm test: coverage
​```

### 文档

```bash
# 启动文档开发服务器
pnpm docs: dev

# 构建文档
pnpm docs: build

# 预览构建的文档
pnpm docs: preview
​```

## 技术栈

Prorise Admin 基于现代化的技术栈构建:

| 技术 | 说明 |
|------|------|
| [React 19](https://react.dev/) | 最新版本的 React,支持 Server Components 和更多新特性 |
| [Vite](https://vite.dev/) | 极速的前端构建工具(使用 Rolldown 版本) |
| [Ant Design 5](https://ant.design/) | 企业级 UI 组件库 |
| [TypeScript](https://www.typescriptlang.org/) | 类型安全的 JavaScript 超集 |
| [Tailwind CSS v4](https://tailwindcss.com/) | 实用优先的 CSS 框架 |
| [Vanilla Extract](https://vanilla-extract.style/) | 类型安全的 CSS-in-JS |
| [Zustand](https://zustand-demo.pmnd.rs/) | 轻量级状态管理 |
| [Vitest](https://vitest.dev/) | 基于 Vite 的单元测试框架 |
| [Storybook](https://storybook.js.org/) | 组件驱动开发和文档 |
| [VitePress](https://vitepress.dev/) | 静态站点生成器 |
| [Biome](https://biomejs.dev/) | 快速的代码格式化和检查工具 |

## 项目特性

-**React 19** - 支持最新的 React 特性
-**TypeScript** - 完整的类型支持
-**主题系统** - 支持亮色/暗色主题切换
-**组件库** - 基于 Ant Design 和自定义组件
-**自动导入** - React Hooks 和 Ant Design 组件自动导入
-**代码规范** - Biome + Lefthook 保证代码质量
-**测试支持** - Vitest + Testing Library
-**组件文档** - Storybook 组件预览和文档
-**Git 钩子** - Lefthook 自动化工作流

## 下一步

现在你已经成功运行了项目,可以:

- 📖 查看 [项目结构](/guide/directory-structure) 了解目录组织
- 🎨 探索 [组件库](http://localhost:6006) 查看可用组件
- 🔧 学习如何 [自定义主题](/guide/theme-customization)
- 📝 阅读 [开发指南](/guide/development) 开始开发

::: tip 遇到问题?
如果在启动过程中遇到任何问题,请查看 [常见问题](/guide/faq) 或在 [GitHub Issues](https://github.com/Prorise-cool/Prorise-admin/issues) 中提问。
:::

文档普遍编写流程:

  • 结构化的章节设计:文档按照用户的使用流程组织,从环境准备、安装、启动到深入使用,层层递进。
  • VitePress 特性使用
    • ::: tip::: warning:使用 VitePress 的自定义容器功能,将重要提示以醒目的方式呈现。
    • Markdown 表格:清晰地展示技术栈信息,便于用户快速了解项目使用的工具。
  • 完整的脚本清单:将 package.json 中所有可用的命令按功能分类展示,这对于新用户了解项目能力至关重要。
  • 技术栈对照表:不仅列出了使用的技术,还附带了官方文档链接和简短说明,降低了学习成本。
  • 项目特性概览:用简洁的条目列举项目的核心能力,让用户快速了解项目价值。
  • 引导式的"下一步":通过内部链接引导用户继续探索,形成文档的导航网络。

第三步:更新配置文件

确保 docs/.vitepress/config.ts 中的侧边栏配置包含了新创建的快速开始页面:

文件路径: docs/.vitepress/config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { defineConfig } from "vitepress";

// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Prorise Admin",
description: "企业级后台管理系统模板,基于 React 19、Vite 与 Ant Design。",
themeConfig: {
// https://vitepress.dev/reference/default-theme-config

// [导航栏]
nav: [
{ text: "首页", link: "/" },
{ text: "快速开始", link: "/guide/getting-started" }, // 示例链接
{ text: "Storybook", link: "http://localhost: 6006" }, // 示例:外链到 Storybook
],

// [侧边栏]
sidebar: {
"/guide/": [
{
text: "指南",
items: [
{ text: "快速开始", link: "/guide/getting-started" },
{ text: "项目结构", link: "/guide/directory-structure" },
// 更多指南...
],
},
],
},

// [社交链接]
socialLinks: [
{ icon: "github", link: "https://github.com/Prorise-cool/Prorise-admin" }, // 替换为你的仓库链接
],
},
});

配置解析

  • 导航栏链接:在顶部导航栏中添加了"快速开始"的直达链接,提升了文档的可访问性。
  • 侧边栏配置:使用路径前缀 /guide/ 来匹配所有指南页面,确保在浏览指南时,侧边栏始终显示指南的导航结构。
  • 可扩展性:配置结构清晰,便于后续添加更多文档页面,只需在 items 数组中追加新项即可。

第四步:验证文档

现在重启文档开发服务器(如果已经在运行):

1
2
# 如果服务器正在运行,按 Ctrl+C 停止,然后重新启动
pnpm docs: dev

打开浏览器访问 http://localhost:5173,你将看到:

  1. 首页的导航栏中出现了"快速开始"链接
  2. 点击进入后,左侧显示指南的侧边栏导航
  3. 主内容区域展示了完整的快速开始文档,包含所有格式化的提示框、代码块和表格

文档系统的协同效应

至此,我们的文档生态系统已经形成了完整的闭环:

文档类型工具访问地址目标用户
组件 API 文档Storybookhttp://localhost:6006组件开发者
项目使用指南VitePresshttp://localhost:5173模板使用者

这两个系统通过配置文件中的链接相互关联,为不同角色的用户提供了各自需要的信息入口。当开发者需要了解某个组件的 API 时,可以访问 Storybook;当新用户需要了解如何使用整个项目模板时,可以访问 VitePress 文档站。

下一步计划

现在我们有了第一份文档,接下来可以继续扩展文档体系,例如:

  • docs/guide/directory-structure.md - 项目结构说明
  • docs/guide/theme-customization.md - 主题定制指南
  • docs/guide/development.md - 开发指南
  • docs/guide/faq.md - 常见问题

每添加一份新文档,只需在 config.tssidebar.items 中添加相应的链接即可。VitePress 的约定式路由会自动处理页面的渲染和导航,我们的项目还没有完全构建完毕,所以这些文档相关的我们以后单开一个章节来做,现在先放着吧!


8.7. 本章小结 & 代码入库(含bug修复)

在本章中,我们以构建 Prorise-Admin 的第一个原子组件 Button 为载体,完整地实践了一套现代化、高标准的 TDD/CDD/DDD 联合工作流。我们建立的这套流程,将成为后续所有 src/ui 组件开发的“黄金标准”。

回顾本章,我们取得了以下核心进展:

  1. 确立了设计哲学 (8.1):

    • 我们明确了 src/ui 目录(Headless, Tailwind 驱动)与 Ant Design(功能复杂, 开箱即用)的职责边界,为项目的 UI 体系奠定了战略基础。
  2. 构建了核心工具链 (8.2):

    • 我们引入了 class-variance-authority (cva) 来声明式地管理样式变体。
    • 引入了 tailwind-mergeclsx,并封装了 cn 工具函数,完美解决了 Tailwind 类名合并与冲突的工程难题。
  3. 实践了 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.forwardRefcvacn 函数,逐步完成了 Button 组件的编码,使所有测试通过。
    • 功能增强 (8.3.5): 我们引入 @radix-ui/react-slot,实现了 asChild 多态渲染,极大提升了组件的灵活性。
  4. 建立了双层文档系统 (8.4, 8.6):

    • Storybook MDX (8.4): 我们创建了 button.mdx,构建了面向开发者/维护者的“API 技术实验室”。我们学会了分离 CSF (.stories.tsx) 和叙事层 (.mdx),并使用 <Meta>, <Story>, <Controls> 将两者无缝结合。
    • VitePress (8.6): 我们独立搭建了 docs 目录,构建了面向模板使用者的“项目用户手册”。这为我们沉淀项目级架构和教程提供了平台。
  5. 实现了自动化交互测试 (8.5):

    • 我们利用 Storybook 的 play 函数和 @storybook/test 工具集,为 Button 编写了运行在真实浏览器中的交互测试,验证了 onClick 行为,为组件质量提供了坚实的保障。

代码入库:组件开发工作流蓝图

我们已经完成了 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"$schema": "https://biomejs.dev/schemas/2.2.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!auto-imports.d.ts",
"!docs/.vitepress/cache",
"!docs/.vitepress/dist",
"!node_modules",
"!dist",
"!build",
"!storybook-static"
]
},
// ... 其他配置
}

配置要点解析

  • 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
2
3
4
5
6
# Auto-generated files
auto-imports.d.ts

# VitePress
docs/.vitepress/cache
docs/.vitepress/dist

常见陷阱提示

  • Biome 2.x 不再支持 files.ignore 配置项,必须使用 files.includes 配合排除模式。
  • 文件夹路径不需要 /** 后缀,直接写文件夹名即可,如 !node_modules 而不是 !node_modules/**
  • 启用 vcs.useIgnoreFile 后,Biome 会同时遵守 .gitignorebiome.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”的黄金流程。