React组件库实战 - 第二章. StoryBook与Mdx初识:Button 组件的全景开发
React组件库实战 - 第二章. StoryBook与Mdx初识:Button 组件的全景开发
Prorise第二章. StoryBook与Mdx初识:Button 组件的全景开发
在第一章中,我们投入了大量精力,从零开始构建了一个坚实、专业且自动化的项目基座。现在,万事俱备,是时候真正开始“建设”我们的设计系统了。
本章的核心目标,并非仅仅是实现一个按钮,而是要通过开发这个最基础、最通用的 Button 组件,来完整地建立和演练一套世界级的组件开发 工作流。我们将依次经历 组件编码与变体设计、交互式文档编写 和 自动化单元测试 三个阶段。完成本章后,您将拥有一套可以复用到任何组件开发中的、高效、可靠的“肌肉记忆”。
2.1. 工厂模式:结合 daisyUI 与 cva 实现 Button 的变体
我们首先来解决组件开发中的第一个核心问题:如何优雅地管理组件的多种视觉形态?
2.1.1. 痛点剖析:混乱的条件样式
一个 Button 组件在真实业务场景中,通常需要支持多种变体。例如,按功能分,有主按钮、次按钮、危险按钮;按尺寸分,有大、中、小三种尺寸。在传统的开发模式中,我们可能会写出类似下面这样的代码:
1 | // 一个难以维护的 Button 组件示例 (错误示范) |
这种写法的弊端显而易见:当变体增加或需要引入新状态(如 disabled)时,组件内部的条件判断逻辑会迅速膨胀,变得难以阅读和扩展。这种将样式判断逻辑与组件渲染逻辑强耦合的方式,违背了“关注点分离”的软件设计基本原则。
2.1.2. 解决方案:daisyUI + cva 的双层抽象
我们的解决方案分为两层,完美地结合了第一章铺垫的技术:
- 样式层: 我们将直接使用
daisyUI提供的高语义、预设好的组件类名(如btn,btn-primary)作为样式的“原子单位”。这让我们无需手动组合大量的 Tailwind 工具类,极大地提升了开发效率。 - 变体管理层: 我们将使用
cva(Class Variance Authority) 作为“工厂”,它的任务是根据传入的props,动态地、声明式地选择并组合正确的daisyUI类名。
这种模式,让我们既能享受 daisyUI 的便捷,又能拥有 cva 带来的类型安全和优雅的变体管理能力。
2.1.3. 准备工作:创建文件与安装依赖
在开始编码前,我们先准备好环境。
1. 创建组件文件
遵循 shadcn/ui 的约定,我们将所有 UI 组件都放置在 src/components/ui 目录下。
1 | # 在项目根目录下执行 |
2. 安装 Slot 依赖
为了让我们的 Button 组件更具灵活性(例如,有时我们希望它渲染为一个 <a> 标签),我们需要 Radix UI 提供的 Slot 组件。
1 | pnpm install @radix-ui/react-slot |
2.1.4. 逐步构建:Button 组件的实现
现在,我们打开 src/components/ui/Button.tsx 文件,开始我们的渐进式构建之旅。每一步我们都会展示文件的 完整 状态,并用注释标明该步骤的 新增或修改 之处,确保您清楚每一行代码的上下文。
1. 阶段一:搭建最小化组件骨架
我们的起点,是一个最纯粹的 React 组件骨架。我们使用 React.forwardRef,这是构建可复用组件库的最佳实践,它允许父组件将 ref 直接传递到我们内部渲染的 DOM 元素上。
文件路径: src/components/ui/Button.tsx
1 | import * as React from 'react'; |
解析: 至此,我们有了一个功能上等同于原生 <button> 的 React 组件。
2. 阶段二:引入 daisyUI 基础样式
现在,让我们为这个骨架穿上 daisyUI 的“基础皮肤”。只需添加 btn 这个核心类名。
文件路径: src/components/ui/Button.tsx
1 | import * as React from 'react'; |
解析: 仅仅一个类名,我们的按钮就已经拥有了由 daisyUI 提供的一致的尺寸、字体、内外边距和交互效果,这得益于我们在第一章的全局配置。
3. 阶段三:创建 cva 样式工厂
为了管理 primary, secondary 等变体,我们现在引入 cva 来创建一个样式工厂。这个工厂的职责是根据 props,输出对应的 daisyUI 变体类名。
文件路径: src/components/ui/Button.tsx
1 | import * as React from 'react'; |
解析: 我们在文件顶部定义了 buttonVariants。注意,cva 的第一个参数是 daisyUI 的基础类 btn,而 variants 对象中的值则是 daisyUI 提供的高级变体类,如 btn-primary。这正是两大工具协同工作的核心体现。
4. 阶段四:定义类型安全的 Props 接口
接下来,我们要创建一个 ButtonProps 接口。它必须能接收所有原生按钮属性,并且能以类型安全的方式,理解我们刚刚用 cva 定义的 variant 和 size。
文件路径: src/components/ui/Button.tsx
1 | // ... imports 和 buttonVariants 定义 ... |
解析: VariantProps<typeof buttonVariants> 是 cva 的一个高级工具类型。它会自动分析 buttonVariants 的 variants 对象,并生成一个包含 variant 和 size 及其所有可能值的联合类型,确保了我们的组件 props 与样式定义永远保持同步。
5. 阶段五:连接 Props 与样式工厂
现在,我们将 Props 与 cva 工厂连接起来,让组件根据传入的 props 动态生成最终的类名。
文件路径: src/components/ui/Button.tsx
1 | // ... imports, buttonVariants, ButtonProps 定义 ... |
解析: 我们修改了 Button 组件的实现。buttonVariants({ variant, size, className }) 会根据传入的 props,从工厂中选择正确的 daisyUI 类名并组合起来。cn 函数则确保这些生成的类名能和任何从外部传入的 className 优雅地合并,并自动解决类名冲突。
6. 阶段六:实现 asChild 组合功能
最后,我们来添加 asChild 这个高级功能,让我们的 Button 组件拥有“附身”到其他元素(如链接 <a>)上的能力。这是最终的完整形态。
文件路径: src/components/ui/Button.tsx
1 | import * as React from 'react'; |
解析: 当 asChild 为 true 时,Comp 会变成 Slot 组件。Slot 会将其接收到的所有属性(包括我们生成的 className)“克隆”并传递给它的直接子元素,而自身不渲染任何 DOM 节点。这使得 <Button asChild><a href="/">Home</a></Button> 这样的写法成为可能,最终渲染出的会是一个带有完整按钮样式的 <a> 标签。
至此,一个集 daisyUI 的高效、cva 的优雅和 Radix Slot 的灵活于一体的、生产级的 Button 组件已构建完毕。
7. 阶段七:验证与消费组件
理论和代码构建已经完成,现在是收获成果的时候。我们将修改 Next.js 应用的首页,来实际使用和展示我们刚刚创建的 Button 组件的所有变体。这个过程不仅能验证我们的工作成果,也能让您直观地感受到 cva 变体系统的强大之处。
首先,为了更好地展示图标按钮,我们需要安装一个图标库。lucide-react 是 shadcn/ui 生态中最常用的图标库,我们来安装它。
1 | pnpm install lucide-react |
接下来,我们清空并修改首页文件,将其变为一个 Button 组件的“陈列室”。
文件路径: src/app/page.tsx
运行与验证
现在,回到您的终端,确保您在 prorise-ui 项目的根目录下,然后启动开发服务器:
1 | pnpm run dev |
打开浏览器并访问 http://localhost: 3000。您应该能看到一个清晰展示了所有按钮变体和尺寸的页面。您可以尝试点击、悬浮,并观察不同变体的交互效果。
这个页面的成功渲染,不仅证明了我们的 Button 组件代码是正确的,更重要的是,它验证了我们从第一章开始搭建的整个技术体系——从 Next.js 框架,到 Tailwind CSS 和 shadcn/ui 变量的注入,再到 daisyUI 插件的集成——已经完全打通并协同工作。

2.1.5 本节小结
在本节中,我们严格遵循“渐进式构建”的原则,成功创建了设计系统的第一个核心组件 Button。我们没有直接堆砌原子类,而是巧妙地将 daisyUI 的高语义组件类作为 cva 样式工厂的输出,完美结合了二者的优点。通过分步实现组件骨架、类型接口、样式映射和组合功能,我们将一个复杂的组件构建过程拆解为一系列聚焦单一概念的、易于理解的步骤。这个过程不仅产出了一个具体的组件,更重要的是,为我们后续所有组件的开发,奠定了一套可复用的、优雅的、层次分明的编码范式。
老师,我有一个疑问。在 2.1 节构建 Button 的过程中,我们用到了 React、cva、Tailwind 的类,甚至 Radix 的 Slot。但是我好像没有在 Button.tsx 的 import 语句或代码的任何地方看到 shadcn/ui。它在我们的项目中到底扮演了什么角色?
问得非常好!您之所以在组件代码中“看不到”它,正是因为它和 React 这些库的性质完全不同。shadcn/ui 不是一个运行时依赖的组件库,而是一套构建和管理我们自有组件的工作流与工具集。
我们可以从两个方面来理解它已经为我们所做的工作:
第一,它是我们设计系统的奠基者 (init 命令)。
所以,虽然 Button.tsx 没有 import ‘shadcn-ui’,但它完全运行在 shadcn/ui 所构建的基础设施之上。
我明白了,所以它更像是一个项目初始化器和环境配置器,而不是一个像 daisyUI 那样提供现成类名的库。
完全正确!这就引出了它的第二个角色。
第二,它是我们未来获取复杂组件源码的传送门 (add 命令)。
未来当我们遇到更复杂的组件,比如 Dialog 时,就可以使用 npx shadcn-ui@latest add dialog 命令。这个命令不会安装一个无法修改的黑盒组件,而是会将遵循同样最佳实践的 Dialog.tsx 源代码文件,直接复制到我们的 src/components/ui 目录下。
啊,所以我明白了!我们之所以要手动构建一次 Button,是为了彻底理解 shadcn/ui 体系下组件的内部构造原理。这样,以后我们用 add 命令添加任何组件,都能完全看懂它的源码,并可以随心所欲地进行修改!
正是如此!您的总结非常到位。shadcn/ui 的哲学就是“组件是你自己的”。它赋予我们对代码的 100% 所有权。
因此,shadcn/ui 在我们项目中的角色可以总结为:在初始阶段,它是一个环境配置器和设计令牌生成器。在开发阶段,它是一套我们遵循的架构模式,以及一个按需获取高质量组件源码的工具。
我们在 2.1 节所做的一切,本质上就是在亲手实践并内化这套先进的架构模式。
2.2. 文档驱动开发:集成 Storybook 8 与组件可视化
现在,我们已经拥有了一个功能完备的 Button 组件。但一个孤立的组件代码文件,对于使用它的其他开发者、以及与之协作的设计师来说,仍然是一个“黑盒”。如何直观地展示它的所有功能、变体和交互状态?如何为它创建一份易于维护、永远与代码同步的“活文档”?
答案就是 Storybook。本节,我们将为项目集成最新版的 Storybook 8,并将其配置为我们组件的“可视化工作台”和文档中心。
2.2.1. 一键安装:使用 Storybook 8 CLI 初始化工作环境
1.核心理念:组件驱动开发
在集成工具之前,我们先要理解其背后的思想——组件驱动开发 (Component-Driven Development, CDD)。其核心主张是,将 UI 组件作为开发的基本单元,在独立、隔离的环境中进行构建和测试,然后再集成到复杂的应用中。
Storybook 正是实践这一理念的行业标准工具。它为我们提供了一个脱离主应用的、专门用于开发和展示组件的“实验室”环境。在这个环境中,我们可以轻松地模拟组件的每一种状态和变体,而无需为了看到一个特定状态的按钮,而去手动操作整个复杂的业务流程。
2.初始化命令
Storybook 8 提供了强大的 CLI 工具,可以自动检测我们的项目技术栈(Next.js, React, Tailwind CSS)并完成所有必需的配置。
在您的项目 prorise-ui 的根目录下,执行以下命令:
1 | npx storybook@latest init |
该命令会执行一系列自动化操作:
- 探测项目配置,并询问您是否确认安装。
- 安装所有必要的 Storybook 依赖包,例如
@storybook/react-nextjs,@storybook/addon-essentials等。 - 在项目根目录下创建一个
.storybook目录,其中包含 Storybook 的核心配置文件。 - 在
src/目录下创建一个stories目录,并生成一些示例故事文件,以验证安装是否成功。
3.分析生成的文件
安装完成后,您的项目目录结构会新增以下关键部分:
1 | prorise-ui/ |
我们重点关注 .storybook 目录下的两个核心文件:
.storybook/main.ts: 这是 Storybook 的“控制面板”。
stories: 定义了 Storybook 去哪里寻找我们的故事文件(.stories.tsx)。addons: 注册了需要加载的插件,例如addon-essentials包含了一系列核心功能(如 Controls, Actions, Viewport 等)。framework: 指定了 Storybook 需要适配的框架,这里它会自动配置为@storybook/nextjs-vite。
.storybook/preview.ts: 这是所有故事的“全局画布”。我们可以在这里定义全局参数、添加全局装饰器(Decorators),或者导入全局样式。这个文件对于后续的主题集成至关重要。
4.首次运行
注意: postcss.config.mjs 使用的是数组格式,这是 Next.js 的默认格式,但 Storybook(使用 Vite)不兼容。我们需要将它改为对象格式:
1 | const config = { |
Storybook 的 init 命令会自动在 package.json 中添加一个 storybook 脚本。现在,让我们运行它。
请注意,根据您的指示,我们项目的包管理器统一使用 pnpm。
1 | pnpm run storybook |
执行后,Storybook 会启动一个本地开发服务器,并自动在浏览器中打开。您应该能看到 Storybook 的欢迎界面,以及左侧导航栏中由 CLI 生成的 Button, Header, Page 等示例故事。

5.清理工作
这些自动生成的示例故事很好地验证了我们的安装,但它们并不是我们 Prorise UI 设计系统的一部分。为了保持项目整洁,我们将它们删除,为下一节编写我们自己的第一个故事做准备。
在终端中执行以下命令(或手动删除该文件夹):
1 | # Linux / macOS |
现在,我们拥有了一个纯净、与项目深度集成的 Storybook 8 环境。但您可能会发现,示例故事中的按钮样式似乎并不正确——这正是我们下一节要解决的问题。
2.2.2. 主题集成:让 Storybook 理解我们的全局样式与暗色模式
痛点剖析:隔离的预览环境
当您运行 pnpm storybook 时,您所看到的组件预览区域(通常称为 “Canvas”)实际上是 一个独立的 iframe 页面。这个页面与我们的 Next.js 应用是完全隔离的,因此它默认不会加载 src/app/globals.css 文件。这就导致了两个问题:
- 所有由
shadcn/ui生成的 CSS 变量(如--primary,--background)都未定义。 - Tailwind 和
daisyUI的样式虽然被 Storybook 的构建过程所编译,但由于缺少基础变量,渲染效果会完全错乱。 - Tailwind CSS 的暗色模式依赖于
<html>标签上是否存在一个dark类,Storybook 默认也不知道如何控制这个类。
我们的任务就是解决这三个问题,让 Storybook 的预览环境与我们的主应用环境完全同步。
前置准备:确保构建工具兼容性
在开始配置之前,我们需要处理一个潜在的兼容性问题。当您使用 npx storybook@latest init 安装 Storybook 时,它会自动选择 @storybook/nextjs-vite 框架。这意味着 Storybook 将使用 Vite 作为构建引擎,而不是 Next.js 的默认构建工具。
这带来了一个问题:Next.js 15 生成的 postcss.config.mjs 文件使用的是数组格式的插件配置,而 Vite 要求使用对象格式。我们需要先修正这个配置,否则 Storybook 将无法正常启动。
文件路径: postcss.config.mjs
请将您的 PostCSS 配置文件修改为以下格式:
1 | const config = { |
解析: 将 plugins 从数组格式(["@tailwindcss/postcss"])改为对象格式({ "@tailwindcss/postcss": {} })。这种对象格式同时兼容 Next.js 和 Vite,不会影响您的 Next.js 应用的正常运行,只是写法上的差异。
现在,您可以成功运行 pnpm storybook 了,虽然此时 Storybook 可能会提示 “找不到任何故事”,这是正常的,因为我们还没有编写任何故事文件。
第一步:注入全局样式
这是最关键、也是最简单的一步。我们需要告诉 Storybook,在渲染任何故事之前,都必须先加载我们的全局样式文件。这个配置在 .storybook/preview.ts 文件中完成。
文件路径: .storybook/preview.ts
1 | import type { Preview } from "@storybook/react"; |
解析: 只需在文件顶部添加 import "../src/app/globals.css"; 这一行,Webpack (或 Vite) 在打包 Storybook 时就会将我们的全局样式包含进去。此时,如果您重新运行 pnpm storybook,会发现组件的颜色、字体等基础样式已经基本正确,因为 CSS 变量已经被正确加载。
第二步:安装并注册主题切换插件
为了在 Storybook 中方便地切换亮/暗色模式,我们需要一个专门的插件。官方的 @storybook/addon-themes 是最佳选择。
首先,安装它作为开发依赖:
1 | pnpm add -D @storybook/addon-themes |
然后,我们需要在 Storybook 的主配置文件中“注册”这个插件,让 Storybook 加载它。
文件路径: .storybook/main.ts
1 | import type { StorybookConfig } from "@storybook/nextjs-vite"; |
解析: 在 addons 数组中添加 @storybook/addon-themes,即可激活该插件。
第三步:配置主题切换装饰器
插件激活后,我们还需要告诉它我们的主题是如何工作的。这里有一个重要的技术决策需要做出。
在我们的项目中,我们使用了 daisyUI 作为 UI 组件库。daisyUI 的主题系统是基于 HTML 的 data-theme 属性来工作的,而不是 CSS 类。当您在 HTML 根元素上设置 data-theme="dark" 时,daisyUI 会自动应用该主题中定义的所有 CSS 变量(如 --color-primary、--color-accent 等)。
因此,我们需要使用 @storybook/addon-themes 提供的 withThemeByDataAttribute 工具,而不是常见的 withThemeByClassName。
我们将使用一个 “装饰器 (Decorator)” 来完成这个配置。装饰器是 Storybook 的一个强大功能,它像一个包装纸,可以将我们所有的故事都包裹在里面,从而提供统一的上下文或功能。
文件路径: .storybook/preview.ts
1 | import type { Preview } from "@storybook/nextjs-vite"; |
代码深度解析:
decorators: 这是preview.ts中的一个关键属性,它是一个数组,可以包含多个装饰器。withThemeByDataAttribute: 这是@storybook/addon-themes导出的一个装饰器工厂函数,专门用于处理基于 HTML 属性的主题切换。themes对象: 我们在这里定义了我们拥有的主题。键名(如light,dark)将作为工具栏中选项的名称,值(如"light","dark")则是当该主题被选中时,需要设置到 HTML 根元素data-theme属性上的值。attributeName: 明确指定我们要操作的属性名为data-theme。这与 daisyUI 的主题系统完全匹配。defaultTheme: 指定 Storybook 默认加载时使用的主题。
为什么选择 withThemeByDataAttribute?能否同时使用两个装饰器?
您可能会看到很多 Storybook 教程使用 withThemeByClassName,那是因为纯 Tailwind CSS 项目通常通过 .dark 类来切换主题。而我们的项目使用了 daisyUI,它有自己的主题系统,需要通过 data-theme 属性来激活。
理论上,您确实可以同时使用两个装饰器:
1 | decorators: [ |
这样配置后,切换主题时会同时:
- 设置
data-theme="dark"属性(激活 daisyUI 主题) - 添加
.dark类(激活 Tailwind 的暗色模式工具类)
那么什么时候需要两个装饰器,什么时候只需要一个?
这取决于您的代码中是否使用了 Tailwind 的 dark: 变体类。让我们分析两种场景:
场景 1:只使用 daisyUI 组件
如果您的组件完全依赖 daisyUI 的类名(如 btn, btn-primary),这些类名的样式是由 daisyUI 的 CSS 变量驱动的。只要 data-theme 属性正确设置,daisyUI 就会应用正确的颜色。此时,只需要 withThemeByDataAttribute 就足够了。
1 | // 这个组件只需要 data-theme 属性 |
场景 2:混合使用 Tailwind 暗色模式类
如果您的组件中使用了 Tailwind 的 dark: 前缀类名,例如:
1 | <div className="bg-white dark:bg-gray-900 text-black dark:text-white"> |
在这种情况下,您需要同时使用两个装饰器。因为:
dark:bg-gray-900和dark:text-white需要.dark类才能生效btn btn-primary需要data-theme属性才能获得正确的颜色
我们项目的选择
在我们当前的项目中,由于 Button 组件是基于 daisyUI 的 btn 类构建的,并且 globals.css 中已经通过 @layer base 定义了全局的背景和文字颜色:
1 | @layer base { |
这些基础样式会自动根据 CSS 变量变化,而这些变量在 globals.css 的 :root 和 .dark 选择器中已经定义。因此,使用单个 withThemeByDataAttribute 装饰器已经足以满足需求。
但如果您在后续开发中,在组件内部大量使用了 dark: 前缀的 Tailwind 类,那时再添加 withThemeByClassName 装饰器也不迟。Storybook 的装饰器系统支持随时添加或移除装饰器,这是一个可以根据项目需求灵活调整的配置。
第四步:最终验证
所有配置都已就绪。现在让我们重新启动 Storybook(如果它正在运行,请先停止它):
1 | pnpm storybook |
Storybook 应该能够成功启动。虽然此时您可能还看不到任何组件(因为我们还没有编写故事文件),但您会在 Storybook 界面的右上角工具栏中看到一个新的图标 —— 这就是主题切换器,通常显示为画笔或太阳/月亮图标。
点击这个图标,您会看到两个选项:light 和 dark。当您在它们之间切换时,整个 Storybook 预览区域的背景色会随之变化。这证明了以下几点:
globals.css已经成功加载到 Storybook 的预览环境中。@storybook/addon-themes插件正在正常工作。data-theme属性正在被正确设置和切换。
理解主题切换的工作原理:
当您切换到暗色主题时,Storybook 会在预览区域的根 HTML 元素上设置 data-theme="dark"。此时,daisyUI 会读取这个属性,并应用 globals.css 中定义的暗色主题配置(第 162-195 行的 @plugin "daisyui/theme" { name: "dark"; ... } 部分)。这个配置会覆盖所有的颜色变量,例如:
--color-accent从亮色模式的oklch(62% 0.214 259.815)(柔和的蓝紫色)变为oklch(55% 0.288 302.321)(鲜艳的紫色)--color-base-100从oklch(98% 0 0)(接近白色)变为oklch(14% 0.005 285.823)(深灰色)
这就是为什么使用 withThemeByDataAttribute 如此重要 —— 它确保了 daisyUI 的整套主题系统能够被正确激活,而不仅仅是改变背景色。
至此,我们的 Storybook 环境已经配置完毕,它成为了一个能够 100% 精确反映我们应用真实样式的、强大的可视化开发与调试平台。在下一章中,我们将学习如何为组件编写故事文件,让它们在这个完美的环境中得以展现。
2.2.3. 编写第一个 Story:Button 组件的 CSF 3.0 实践
在上一节中,我们成功地将 Storybook 的预览环境与我们的项目主题完全同步。现在,这个“可视化工作台”已经准备就绪,是时候将我们精心打造的第一个“产品”——Button 组件——放上展台了。
本节我们将学习如何使用最新、最主流的 组件故事格式 3.0,为 Button 组件编写它的第一个“故事 (Story)”。
核心理念:什么是 Story?什么是 CSF?
Story: 一个 Story 代表一个组件的 单一、可被渲染的状态。例如,“一个主色、大尺寸的按钮”就是一个 Story,“一个禁用状态的、次要颜色的按钮”是另一个 Story。通过为组件编写一系列 Stories,我们就能完整地记录和展示它的所有可能性。
CSF (Component Story Format): 这是 Storybook 官方推荐的、基于标准 ES 模块的 Story 编写规范。CSF 3.0 是其最新版本,它通过一个
meta对象来定义组件级的元数据,并通过具名导出 (Named Exports) 来定义每一个独立的 Story。这种格式不仅写法简洁,而且与 TypeScript 的结合极为出色,能提供强大的类型推断和自动补全能力。
第一步:创建 Story 文件
按照社区的最佳实践,组件的 Story 文件应该与组件源文件并置存放,并以 .stories.tsx 作为后缀。这样做的好处是便于查找和维护。
让我们为 Button 组件创建它的 Story 文件:
1 | # 在项目根目录下执行 |
第二步:构建基础结构 (Meta 对象)
每一个 Story 文件都必须有一个默认导出 (default export),我们称之为 meta 对象。它负责告诉 Storybook 这个文件是关于哪个组件的,以及如何对它进行分类和展示。
文件路径: src/components/ui/Button.stories.tsx
1 | import type { Meta, StoryObj } from '@storybook/react'; |
代码深度解析:
title: 定义组件在 Storybook 侧边栏中的显示路径。'Components/Button'会在侧边栏生成Components文件夹,其下有Button条目。component: 将 Story 文件与组件进行强关联。这使得 Storybook 能够读取组件的 TypeScript 类型,自动生成 Controls 控件和文档。parameters.layout: 控制组件在 Canvas 中的布局方式,'centered'让组件居中显示,便于观察。tags: ['autodocs']: 启用 Storybook 的自动文档生成功能,会为组件创建一个 “Docs” 标签页。satisfies Meta<typeof Button>: TypeScript 的类型断言语法,确保meta对象符合 Storybook 的 Meta 类型要求,同时保留类型推断能力。
第三步:编写第一个 Story (Primary)
现在,我们来定义第一个具体的 Story。一个 Story 就是一个简单的、具名导出的对象。
文件路径: src/components/ui/Button.stories.tsx
1 | import type { Meta, StoryObj } from '@storybook/react'; |
代码深度解析:
type Story = StoryObj<typeof meta>;: 这是一个 TypeScript 的最佳实践。StoryObj是 Storybook 提供的泛型,typeof meta会将我们meta对象中关于组件类型的信息传递给它,从而创建一个与Button组件props完全匹配的Story类型。export const Primary: Story = { ... };: 我们通过具名导出的方式创建了一个名为Primary的 Story。这个导出的名称会成为它在 Storybook 侧边栏中的显示名称。args: 这是定义一个 Story 最核心的部分。args对象中的每一个键值对,都会被作为props传递给被渲染的Button组件。在DefaultStory 中,我们只设置了children,让组件使用其默认样式。在PrimaryStory 中,我们指定了variant为primary,这会应用 daisyUI 的btn-primary类名。
第四步:为更多变体编写 Stories
Storybook 的强大之处在于能够清晰地罗列和展示组件的所有状态。让我们继续为 Button 的其他重要变体添加对应的 Stories。
文件路径: src/components/ui/Button.stories.tsx
1 | // ... 此前的 meta, Story 类型, Default 和 Primary Stories ... |
解析: 我们的 Button 组件基于 daisyUI 构建,拥有丰富的变体选项。我们为不同的 variant(颜色变体如 secondary、accent、success 等),不同的 buttonStyle(样式风格如 outline、ghost),不同的 size(尺寸如 sm、lg),以及不同的 behavior(行为状态如 disabled、active)都创建了独立的 Story。每一个具名导出都会在 Storybook 的侧边栏生成一个对应的条目,让我们可以轻松地在不同状态之间切换查看。
注意我们使用了 buttonStyle 而不是将 outline 放在 variant 中,这是因为 daisyUI 将颜色和样式风格分离为两个独立的维度,使得组合更加灵活。例如,您可以创建一个 “accent 颜色的 outline 样式按钮”,只需同时设置 variant="accent" 和 buttonStyle="outline"。
第五步:最终验证
现在,再次运行 Storybook(如果它已经在运行,通常会自动热更新):
1 | pnpm storybook |
打开浏览器,您现在应该能在左侧的侧边栏中看到 Components/Button 条目,展开它,下面会列出我们刚刚编写的所有 Stories:Default, Primary, Secondary, Accent, Success, Warning, Error, Outline, Ghost, Small, Large, Disabled, Active 等。点击其中任意一个,右侧的 Canvas 区域都会立刻渲染出对应状态的 Button 组件。
特别值得注意的是,当您切换 Storybook 的主题(使用右上角的主题切换器)时,按钮的颜色会根据 daisyUI 的主题配置自动变化。例如,Accent 按钮在亮色模式下显示为柔和的蓝紫色,而在暗色模式下则变为鲜艳的紫色,这证明了我们在上一节配置的 data-theme 属性正在正常工作。

我们已经成功地为组件创建了一份 “可视化说明书”。但这还不够,目前我们只能查看预设的状态。如何才能在 Storybook 界面上自由地、动态地组合所有 props 来探索组件的全部潜力呢?这正是我们下一节要解决的问题。
2.2.4. 交互式调试:利用 Controls Addon 动态修改 Props
核心理念:Props 的自动化 UI
您可能会想,Button.stories.tsx 文件中定义的 args 对象,仅仅是为组件传递了固定的 props。这背后隐藏着 Storybook 一个极其强大的特性:它能够动态地解析这些 args 以及组件本身的 TypeScript 类型,并自动为它们生成一套可交互的图形界面。这个功能由 @storybook/addon-essentials 中包含的 Controls Addon 提供。
它的工作原理如下:
- Storybook 读取
meta.component,找到我们的Button组件。 - 它分析
Button组件的ButtonProps接口,识别出variant,size,children,disabled等所有可配置的 props 及其类型。 - 对于
variant和size这种拥有明确联合类型 ('primary' | 'secondary' ...) 的 prop,它能智能地判断出这是一个“选择题”。 - 最终,它在 Storybook 界面的附加面板中,为我们动态生成一个包含下拉菜单、单选框、文本输入框等控件的表单,让我们能够实时修改这些 props。
第一步:改造 Story 为交互式模板
为了激活 Controls 功能,我们不需要为每个变体都编写一个 Story,而是只需要一个“模板 Story”。这个 Story 的 args 定义了组件的默认状态,然后由 Controls 来动态改变这些 args。
让我们来改造 Button.stories.tsx 文件。我们将 Primary 这个 Story 重命名为 InteractivePlayground,并暂时移除其他冗余的 Story,使其成为我们唯一的、可交互的“实验台”。
文件路径: src/components/ui/Button.stories.tsx
1 | import type { Meta, StoryObj } from '@storybook/react'; |
解析: 我们将 Primary Story 重构为了 InteractivePlayground,并为其 args 提供了更完整的默认值。现在,当您查看这个 Story 时,Storybook 的 Controls 插件将会被激活。
验证: 运行 pnpm storybook,在侧边栏中导航到 UI/Button/InteractivePlayground。在底部的 Addons 面板中,切换到 “Controls” 标签页。您会看到 Storybook 已经为 variant 和 size 自动生成了下拉选择框,为 children 生成了文本输入框,为 disabled 生成了开关。尝试修改这些控件的值,您会看到画布中的按钮实时地发生变化!
第二步:优化交互体验:配置 argTypes
自动生成的下拉框已经很好用了,但我们可以做得更好。例如,对于选项较少的 size,使用单选框(Radio buttons)可能比下拉框更直观。我们可以通过在 meta 对象中配置 argTypes 属性,来精确地定制每一个 prop 在 Controls 面板中的外观和行为。
文件路径: src/components/ui/Button.stories.tsx
1 | import type { Meta, StoryObj } from '@storybook/react'; |
代码深度解析:
argTypes:meta对象中的这个属性允许我们对每一个 prop 进行详细的配置。control: 指定了该 prop 在 UI 中使用哪种类型的控件。'select'是下拉框,'radio'是单选框,'boolean'是开关。options: 为select和radio控件提供了明确的选项列表。虽然 Storybook 能自动推断,但显式定义能保证顺序和完整性。description: 为该 prop 添加一段描述性文字,它会显示在 Controls 面板中,极大地增强了文档的可读性。table: 这个对象用于配置在“Docs”页面(我们将在下一节学习)中自动生成的 Props 表格的显示内容,如类型摘要和默认值。
最终验证
刷新您的 Storybook 页面。再次查看 InteractivePlayground 故事的 Controls 面板,您会发现:

variant仍然是下拉框(因为选项较多)。size已经变成了更直观的单选框。asChild和disabled变成了易于操作的开关。- 每个控件旁边都出现了我们编写的描述文字。
我们已经将 Storybook 变成了一个功能强大、文档清晰、高度交互的组件“实验室”。任何团队成员现在都可以无需编写任何代码,就能自由地探索 Button 组件的所有功能组合,这对于组件的测试、评审和推广都具有不可估量的价值。
2.3. 文档深化:使用 MDX 编写专业组件文档
本节,我们将从单纯的“组件示例”升级到创建“组件文档”。我们将学习如何使用 MDX(一种允许在 Markdown 中编写 JSX 的强大格式),为 Button 组件创建一个内容丰富、图文并茂、且包含交互式示例和 API 表格的专业文档页面。
2.3.1. 核心变更:理解 MDX 与 CSF 的协同模式
在开始编写文档之前,我们必须先理解 Storybook 8 带来的一个至关重要的 破坏性变更,它彻底改变了我们组织文档和故事的方式。
历史回顾:*.stories.mdx 的旧模式及其弊端
在 Storybook 的旧版本(v6 及更早)中,一种流行的做法是使用 *.stories.mdx 文件。在这种模式下,开发者会将 Markdown 叙述内容和 Story 的代码定义 混合在同一个文件里。
这种“一体化”的文件格式虽然在当时看似便捷,但很快暴露出一系列问题:
- 工具链支持不佳: 在一个
.mdx文件中嵌入复杂的 React/TSX 代码,对于 TypeScript 的类型检查、ESLint 的静态分析和 Prettier 的代码格式化等工具来说都是一场噩梦,开发者体验很差。 - 可复用性差: 定义在 MDX 文件内部的 Story 很难被外部引用,例如,我们无法在单元测试中方便地导入和复用这个 Story。
- 职责混淆: 它将“文档编写”和“组件示例定义”这两个完全不同的关注点混杂在了一起,违背了软件工程的“关注点分离”原则。
Storybook 8 的新范式:示例与文档的彻底分离
为了解决以上问题,Storybook 8 强制推行了一种全新的、职责更清晰的协同模式。其核心思想是:示例归示例,文档归文档。
.stories.tsx(CSF 文件): 它的 唯一职责,就是以纯粹的、类型安全的 ES 模块形式,导出一系列组件的可交互状态(即我们上一节编写的Story对象)。它是我们所有组件示例的“单一事实来源”.mdx(文档文件): 它的职责是编写叙事性的文档内容,如设计理念、使用指南等。它本身不再定义任何 Story,而是像一个“消费者”一样,从.stories.tsx文件中 导入 并 嵌入 这些已经定义好的交互式示例。
我们可以通过下表来清晰地理解这种职责划分:
| 文件类型 | 核心职责 | 产出物 |
|---|---|---|
.stories.tsx | 定义组件的各种可交互状态 (CSF) | 一系列具名导出的 Story 对象 |
.mdx | 撰写叙事性文档,并嵌入 Story | 一个图文并茂的文档页面 |
连接的桥梁:@storybook/addon-docs 的核心标签
那么,.mdx 文件是如何 “消费” .stories.tsx 文件的呢?答案是依靠 Storybook 的 @storybook/addon-docs 插件提供的一系列专用 MDX 文档组件:
<Meta of={...} />: 这是 MDX 文档与 CSF 文件之间的 “连接器”。它通常放在文件顶部,通过of属性指向从.stories.tsx文件中导入的整个 stories 模块,从而将整个文档页与Button组件关联起来。<Story of={...} />: 这是 “嵌入器”。在文档的正文中,我们可以使用这个标签,通过of属性指向从.stories.tsx中导入的某个具体 Story(例如Primary或Sizes),从而在当前位置渲染出那个交互式的 Story 示例。<Controls />: 这是 “文档生成器”。这个标签可以自动读取与当前文档关联的组件(通过<Meta>标签指定)的 TypeScriptProps接口,并生成一份详尽的 API 属性表格。
重要提示:这些文档组件需要从 @storybook/addon-docs/blocks 导入。addon-docs 是 Storybook 的官方文档插件,在安装 Storybook 时会自动安装,无需单独配置。
总结而言,Storybook 8+ 的新范式通过强制分离,让我们的项目结构变得更加清晰和健壮。.stories.tsx 成为了高度可复用、类型安全的 “组件示例库”,而 .mdx 则成为了纯粹的 “文档层”。
2.3.2. 创建 Button.mdx 文档页
第一步:创建 MDX 文件
我们首先在 Button 组件所在的目录中,创建一个与它同名的 .mdx 文件。
1 | # 在项目根目录下执行 |
第二步:关联 Meta 并撰写简介
在 Button.mdx 文件中,我们要做的第一件事就是导入 Button.stories.tsx 文件中的所有导出,并使用 <Meta> 标签将本文档与这些故事关联起来。
文件路径: src/components/ui/Button.mdx
1 | import { Meta, Story, Controls } from '@storybook/addon-docs/blocks'; |
代码深度解析:
import { Meta, Story, Controls } from '@storybook/addon-docs/blocks';: 从 Storybook 的addon-docs插件中导入所需的文档组件。这个路径是官方推荐的导入方式。import * as ButtonStories from './Button.stories';: 我们将Button.stories.tsx中所有export的内容(包括默认导出的meta和每一个具名导出的Story对象)全部导入,并聚合到名为ButtonStories的命名空间对象中。<Meta of={ButtonStories} />: 这是实现 “协同模式” 的 关键链接。of属性指向整个 stories 模块,Storybook 会自动从中读取默认导出的 meta 信息。完成这一步后,Storybook 会自动将侧边栏中Button条目的 “Docs” 标签页替换为此 MDX 文件的内容。
第三步:嵌入交互式主 Story
一份好的文档应该开门见山,首先展示一个功能最全、可供用户随意把玩的“主示例”。在我们的例子中,InteractivePlayground Story 正是为此而生。
文件路径: src/components/ui/Button.mdx
1 | {/* ... 此前内容 ... */} |
代码深度解析:
<Story of={ButtonStories.InteractivePlayground} />: 我们使用<Story>标签,并通过of属性精确地指定要嵌入ButtonStories命名空间中的InteractivePlayground这个 Story。Storybook 会在此处渲染出该 Story 的交互式预览界面,下方还会自动附带我们在上一节中精心配置的 Controls 面板。
第四步:展示核心变体
接下来,我们用更少的篇幅,集中展示组件最核心的变体分类,并辅以简短的说明。我们将 Variants 和 Sizes 这两个聚合型 Story 嵌入进来。
文件路径: src/components/ui/Button.mdx
1 | {/* ... 此前内容 ... */} |
解析: 通过这种方式,我们将叙事性的使用指南(Markdown 文本)与可视化的、真实渲染的组件示例(<Story> 标签)完美地结合在了一起。
第五步:自动生成 API 参考
最后,一份专业文档必不可少的是详尽的 API 属性参考。我们无需手动编写这个表格,@storybook/addon-docs 插件可以为我们自动生成。
文件路径: src/components/ui/Button.mdx
1 | {/* ... 此前内容 ... */} |
代码深度解析:
<Controls />: 当在 MDX 文件中使用时,这个标签的功能会扩展。它不仅会像在<Story>标签下方那样显示交互式控件,更会在此处渲染一个 完整的 API 属性表格。表格的内容完全来自于ButtonProps的 TypeScript 类型定义,以及我们在Button.stories.tsx文件的meta.argTypes中提供的description,defaultValue等元数据。这确保了我们的 API 文档永远与代码保持 100% 同步。
最终验证
文件路径: src/components/ui/Button.stories.tsx
我们之前配置了 Storybook 的自动文档生成,会根据 Typescript 自动生成文档,我们现在使用了自定义的文档格式,需要将文档中的 tags: ['autodocs'] 行删除以让 Storybook 使用我们的自定义文档
1 | // 组件元数据配置 |
现在,运行 pnpm storybook 并导航到 Components/Button。您会看到,默认展示的 “Docs” 标签页已经变成了我们刚刚编写的、内容丰富的 MDX 文档页面。它包含了清晰的介绍、可实时交互的主示例、分类展示的核心变体,以及一份完整、精确的 API 参考表格。

我们已经成功地为 Button 组件创建了一份世界级的、图文并茂、交互丰富且永不过时的“活文档”。这完成了我们专业工作流中“文档驱动”这一环。接下来,我们将为它建立最后一道防线——自动化测试。
2.4. 质量保障:Storybook 的集成测试方案
本节,我们将了解 Storybook 为我们自动配置的测试基础设施,以及如何在此基础上扩展传统的组件单元测试。
2.4.1. Storybook 的测试配置:开箱即用的优势
当我们使用 npx storybook@latest init 安装 Storybook 时,它不仅为我们配置了可视化开发环境,还自动集成了一套基于 Vitest 的测试解决方案。
Storybook 已为我们做了什么?
让我们先查看项目的 package.json,您会发现以下测试相关的包已经被自动安装:
1 | { |
此外,Storybook 还为我们创建了以下配置文件:
vitest.config.ts- Vitest 的主配置文件.storybook/vitest.setup.ts- Storybook 测试的设置文件vitest.shims.d.ts- TypeScript 类型定义
这意味着我们的测试基础设施已经完全就绪,无需从零开始配置。
Storybook 测试方案的特点
查看 vitest.config.ts 文件,我们会发现 Storybook 配置的是一种特殊的测试方式:
文件路径: vitest.config.ts
1 | import { defineConfig } from 'vitest/config'; |
配置深度解析:
这个配置使用了 Storybook 的 @storybook/addon-vitest 插件,它提供了一种创新的测试方式:
Browser Mode(浏览器模式): 使用真实的 Playwright 浏览器来运行测试,而不是传统的 jsdom 模拟环境。这意味着测试会在真实的浏览器 DOM 中执行,能够捕获更多实际环境中的问题。
Story-based Testing(基于 Story 的测试):
storybookTest插件会自动扫描所有的.stories.tsx文件,并将每个导出的 Story 转换为一个测试用例。例如,我们的PrimaryStory 会自动生成一个测试,验证该 Story 能否正确渲染。继承 Storybook 配置: 测试会完全继承 Storybook 的所有配置,包括装饰器(decorators)、全局样式、主题等。这确保了测试环境与 Storybook 预览环境完全一致。
这种测试方式的优势与局限:
优势:
- 零额外代码:所有 Story 自动成为测试用例,无需手动编写
- 真实环境:在真实浏览器中运行,包括 CSS 渲染、布局计算等
- 可视化测试:可以测试可访问性(a11y)、视觉回归等
局限性:
- 速度较慢:浏览器启动和渲染需要时间
- 适用场景:主要用于验证组件能够正确渲染,不适合复杂的业务逻辑测试
理解 Storybook 的测试理念
Storybook 的测试方案基于一个核心理念:如果一个 Story 能够在 Storybook 中正确显示,那么它就应该能够通过测试。 这种 “Story 即测试” 的思想,让我们编写 Story 的同时,也自动积累了测试覆盖率。
这与传统的单元测试(使用 React Testing Library 直接测试组件)是互补的关系。Storybook 测试关注 “组件能否正确渲染各种状态”,而传统单元测试关注 “组件的交互逻辑是否正确”。
验证配置
Storybook 已经为我们配置好了一切。我们可以通过以下方式验证配置是否正常工作。在后续章节中,我们将学习如何为 Stories 添加交互测试,以及如何在需要时扩展传统的组件单元测试。

2.4.2. 交互测试:为 Story 编写 play 函数
核心理念:可重放的交互脚本
Storybook 提供了一个名为 play 函数的强大特性。它是一个可以附加到任意 Story 对象上的异步函数,其核心作用是:在 Story 渲染完成后,以编程方式模拟用户与组件的交互,并对交互结果进行断言。
play 函数的独特之处在于它的“双重价值”:
- 在 Storybook UI 中: 它会在 “Interactions” 插件面板中,可视化地、一步步地重放 您的交互脚本。这提供了一个无与伦比的交互式调试工具。
- 在自动化测试中: Storybook 的测试运行器会自动执行
play函数,并将其中的断言(assertions)作为测试结果。这使得您的交互逻辑能够被集成到 CI/CD 流程中。
第一步:理解 play 函数的工具集
play 函数依赖于 @storybook/test 包提供的工具集,这个包在 storybook init 时已经被自动安装。它重新导出并整合了业界标准的测试库:
within: 源自@testing-library/dom,用于将查询范围限定在特定元素内。userEvent: 源自@testing-library/user-event,用于以更接近真实用户行为的方式模拟交互(如点击、输入)。expect: 源自vitest或jest,是进行断言的核心函数。fn: 一个创建“间谍”或“模拟函数”的工具,用于追踪函数是否被调用、以及如何被调用。
第二步:为 Button 组件编写交互测试 Story
现在,让我们回到 Button 的故事文件,为其添加一个专门用于测试点击行为的新 Story。
文件路径: src/components/ui/Button.stories.tsx
1 | import type { Meta, StoryObj } from '@storybook/react'; |
代码深度解析:
- 导入工具: 我们从
@storybook/test导入了编写交互测试所需的所有核心函数。 - 创建新 Story: 我们创建了一个名为
ClickInteraction的新 Story。一个最佳实践是,将带有play函数的复杂交互测试,与用于视觉展示的简单 Story 分离开。 - 获取画布:
play函数接收一个包含canvasElement的上下文对象。within(canvasElement)将我们的查询范围限定在这个 Story 的渲染区域内。 - 查询元素: 我们使用
canvas.getByRole('button', ...)来查找按钮。这是 React Testing Library 推荐的最佳实践,因为它最接近真实用户(特别是辅助技术用户)查找元素的方式。 - 模拟交互:
await userEvent.click(button)以一种高保真的方式模拟了用户的点击行为,它会触发mousedown,mouseup,click等一系列事件。 - 断言 (Assertion):
await expect(args.onClick).toHaveBeenCalled()是测试的核心。这里必须使用fn()创建的 mock 函数,而不能依赖argTypes中的action配置。我们在这里断言这个函数 已经被调用过,从而验证了组件的点击功能是正常的。
常见错误与修复:
如果您在 args 中没有显式使用 onClick: fn(),而是依赖 argTypes 的 action 配置,会遇到以下错误:
1 | TypeError: [Function actionHandler] is not a spy or a call to a spy! |
错误原因: argTypes: { onClick: { action: 'clicked' } } 仅仅是让 Storybook 在 UI 的 Actions 面板中记录事件,它 不会 自动将 args.onClick 转换为可被 expect 断言的 spy/mock 函数。
解决方案: 必须在 Story 的 args 中显式使用 fn():
1 | export const ClickInteraction: Story = { |
第三步:配置测试脚本
在运行自动化测试之前,我们需要在 package.json 中添加测试脚本。虽然 Storybook 已经为我们配置好了 vitest.config.ts,但测试命令需要手动添加。
文件路径: package.json
1 | { |
脚本说明:
test: 执行一次性测试,运行所有 Stories,完成后退出。这是用于持续集成(CI)的标准命令。test:watch: 启动 Vitest 的监听模式,每当源文件或测试文件发生变化时,自动重新运行相关测试。适合开发阶段使用。test:ui: 启动 Vitest 的图形化界面(通常在浏览器中打开),提供更直观的测试结果查看、筛选和调试体验。
第四步:双重验证
现在,我们的 play 函数已经编写完毕,测试脚本也配置好了。我们可以通过两种方式来验证它的成果。
1. 在 Storybook UI 中进行可视化调试
运行 npm run storybook,导航到 Components/Button/ClickInteraction 这个 Story。您会看到:
- 画布中的按钮被正常渲染。
- 底部的 Addons 面板会自动切换到 “Interactions” 标签页。
- 您会看到
play函数中的每一步——click和expect——都被清晰地列出,并带有一个绿色的对勾,表示执行成功。您可以点击每一步来查看组件在交互前后的 DOM 快照。
2. 在自动化测试中运行
在终端中运行我们刚刚配置的测试脚本:
1 | npm test |
预期结果: Vitest 会启动 Playwright 浏览器,在后台无头模式(headless mode)下运行您的所有 Stories。由于 storybookTest 插件的存在,ClickInteraction 这个 Story 的 play 函数会被自动执行。
您会看到类似以下的测试报告:
1 | ✓ Button.stories.tsx (595ms) |
这证明了我们的 play 函数不仅是一个强大的可视化调试工具,更是一个 可被 CI/CD 流水线集成的、可靠的自动化集成测试用例。
当您运行 npm run test:ui 并查看可视化界面时,您可能会注意到测试结果显示为 PASS (1) 和 Tests 5 passed (5)。这可能会让人困惑:为什么文件数是 1,但测试用例数是 5?
测试统计详解:
- Test Files 1 passed (1): “1” 指的是 1 个测试文件 通过,即
Button.stories.tsx。 - Tests 5 passed (5): “5” 指的是 5 个测试用例,对应您在
Button.stories.tsx中导出的 5 个 Story:- ✓ Interactive Playground
- ✓ Default
- ✓ Sizes
- ✓ Variants
- ✓ Click Interaction
测试通过的判定标准:
Storybook 的 storybookTest 插件会自动为每个 Story 创建测试用例,测试标准分为两个层级:
基础渲染测试(所有 Story 都执行)
- ✅ Story 能在 Playwright 浏览器中正确渲染
- ✅ 组件没有抛出运行时错误
- ✅ DOM 结构正确生成
这就是为什么即使
Interactive Playground、Default、Sizes、Variants这些 Story 没有play函数,它们也能通过测试——因为它们都成功渲染了。交互逻辑测试(仅带有
play函数的 Story)- ✅
play函数中的所有步骤成功执行 - ✅ 所有
expect断言都通过
对于
Click InteractionStory,测试验证了:- 按钮能被找到(通过
getByRole) - 按钮能被点击(通过
userEvent.click) onClick函数确实被调用了(通过expect(...).toHaveBeenCalled())
- ✅
这意味着什么?
您的测试覆盖率实际上包含了两个维度:
- 视觉覆盖: 所有 5 个 Story 都验证了组件在不同状态下能正确渲染(基础测试)
- 行为覆盖: 1 个 Story (
Click Interaction) 验证了组件的交互逻辑(深度测试)
这种测试策略非常高效:通过编写 Story,您自然地积累了视觉测试用例;当需要深入测试交互逻辑时,只需为特定 Story 添加 play 函数即可。
2.4.3 本节小结
在本节中,我们系统地学习了 Storybook 的完整测试解决方案:
测试基础设施: 理解了 Storybook 通过
@storybook/addon-vitest插件为我们自动配置的测试环境,包括 Vitest、Playwright 和 Testing Library 的集成。交互测试核心: 掌握了
play函数的编写方法,学会使用within、userEvent、expect和fn()等工具来模拟用户行为并进行断言。常见问题解决: 明确了
onClick: fn()的必要性,理解了argTypes中的action配置与测试用 mock 函数的区别。测试脚本配置: 在
package.json中添加了test、test:watch和test:ui三个脚本,让测试能够在不同场景下运行。测试结果解读: 理解了测试统计的含义——每个 Story 都是一个测试用例,基础测试验证渲染,带
play函数的 Story 额外验证交互逻辑。
通过这些学习,我们不仅实现了一个可在 Storybook UI 中可视化重放的交互调试脚本,更重要的是,我们建立了一套能够在真实浏览器环境中运行的、可被持续集成的自动化测试体系。这为我们的组件库提供了坚实的质量保障,让 “Story 即测试” 的理念真正落地。
2.5. 流程闭环:文档驱动开发与黄金工作流
至此,我们已经完整地经历了一个专业级组件——Button——从诞生到拥有完备文档和自动化测试的全过程。在这一节,我们将不再编写新的代码,而是后退一步,从更高的维度来审视和复盘我们刚刚走过的路。
本节的核心目标,是将我们在实践中摸索的零散步骤,提炼并固化为一套清晰、可复用、可推广的 方法论。这套方法论,就是我们贯穿整个课程的“黄金工作流”。
黄金工作流:一套以 Storybook 为中心的开发模式
回顾我们开发 Button 组件的整个过程,可以清晰地划分为四个核心阶段,它们环环相扣,构成了一个以 Storybook 为中心的、高效的开发闭环。
1. 阶段一:构思与可视化 (Storybook First)
我们的起点并非直接编写 Button.tsx 的实现代码,而是在 Button.stories.tsx 文件中进行构思。
- 我们做了什么: 我们首先定义了
meta对象,思考并确定了组件的公开 API(即props),并通过argTypes精确地描述了它们。然后,我们通过编写一系列独立的Story对象(Primary,Sizes,Variants等),在隔离的环境中将组件的各种视觉形态 可视化 出来。 - 核心价值: 这种“Storybook 优先”的方法,迫使我们从一开始就站在 组件消费者 的角度来思考问题。它让我们在投入大量精力进行具体实现之前,就能清晰地定义组件的“契约”(API),并在一个独立的“实验室”中预览和评审其视觉表现,极大地降低了后期返工的风险。
2. 阶段二:定义与验证行为 (Interaction Testing)
在组件的视觉形态被定义后,我们紧接着通过 play 函数为其核心交互行为编写了测试。
- 我们做了什么: 我们创建了
ClickInteractionStory,并利用@storybook/test提供的工具集,编写了一个模拟用户点击并断言onClick回调被触发的交互脚本。 - 核心价值:
play函数不仅是一个自动化测试用例,它更是一份“可执行的交互文档”。它以代码的形式,精确地、无歧义地定义了组件在特定交互下应该如何响应。这种做法将测试环节从流程的末端,提至与组件定义紧密结合的核心位置,是行为驱动开发(BDD)思想在组件开发中的绝佳实践。
3. 阶段三:编码实现 (Make it Pass)
当前两步完成后,我们进行具体编码实现的目标变得异常明确和纯粹。
- 我们做了什么: 我们逐步构建了
Button.tsx的内部逻辑。我们的编码过程,本质上就是为了让在 Storybook 中定义的所有视觉 Story 能够正确渲染,以及让play函数中的所有断言能够顺利通过。 - 核心价值: 在这个阶段,我们不再需要“猜测”组件应该是什么样、或者应该怎么工作。所有的需求都已经被前两个阶段以“可视化”和“可执行”的形式明确下来。开发过程从充满不确定性的“创造”,转变为目标明确的“实现”,效率和准确性都得到了极大的提升。
4. 阶段四:叙事与文档 (MDX Enrichment)
当组件的功能被完全实现和验证后,我们才开始编写长篇的叙事性文档。
- 我们做了什么: 我们创建了
Button.mdx文件,在其中撰写了关于组件的设计理念和使用指南,并利用<Story>和<Controls>标签,将我们早已准备好的交互式示例和自动生成的 API 表格无缝地嵌入其中。 - 核心价值: 文档工作不再是开发流程结束后令人头疼的“补充材料”。相反,它变成了一个轻松的“组装”工作。核心的交互示例和 API 文档早已存在或能够自动生成,我们只需专注于补充那些最需要人类智慧的、关于“为什么”和“如何更好地使用”的叙事性内容。
理念升华:文档驱动开发
我们刚刚实践的这套“黄金工作流”,正是 文档驱动开发 (Documentation-Driven Development, DDD) 理念在现代前端组件开发中的具体体现。
在传统工作流中,文档是代码的附属品,往往在开发完成后才被动地编写,导致其内容常常滞后于代码,甚至被完全忽略。
而在我们的新范式中,文档(以 Storybook 中的 Stories 和 MDX 形式存在)成为了整个开发过程的中心枢纽和驱动力。它是我们构思 API 的草稿板,是验证视觉效果的试验台,是定义交互行为的规约,也是最终交付给用户的产品的一部分。代码实现和自动化测试,都围绕着这份“活文档”来展开。













