Storybook 组件开发
大约 8 分钟约 2453 字
Storybook 组件开发
简介
Storybook 是 UI 组件的隔离开发环境和文档系统,支持独立开发、测试和文档化组件。通过编写 Story 展示组件的不同状态(默认、加载、错误、空数据等),帮助设计师、开发者和测试人员在组件未集成到页面之前就理解组件的所有可用状态。Storybook 支持 React、Vue、Angular、Svelte 等主流框架,配合 Chromatic 可实现视觉回归测试(Visual Regression Testing)。在现代前端工程中,Storybook 是构建设计系统(Design System)和组件库不可或缺的工具。
特点
实现
Story 基础编写(CSF 3.0)
// Button.stories.tsx — 按钮组件 Story
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
// Meta 定义 — 组件元信息
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'], // 自动生成文档页
argTypes: {
variant: {
control: 'select',
options: ['primary', 'danger', 'ghost'],
description: '按钮变体',
table: {
defaultValue: { summary: 'primary' },
type: { summary: 'string' },
},
},
size: {
control: 'select',
options: ['small', 'medium', 'large'],
description: '按钮尺寸',
},
disabled: {
control: 'boolean',
description: '是否禁用',
},
loading: {
control: 'boolean',
description: '是否加载中',
},
onClick: { action: 'clicked' }, // 记录点击事件
},
args: {
variant: 'primary',
size: 'medium',
disabled: false,
loading: false,
},
};
export default meta;
type Story = StoryObj<typeof Button>;
// ========== 基础变体 ==========
export const Primary: Story = {
args: { children: '主要按钮', variant: 'primary' },
};
export const Danger: Story = {
args: { children: '危险按钮', variant: 'danger' },
};
export const Ghost: Story = {
args: { children: '幽灵按钮', variant: 'ghost' },
};
export const Disabled: Story = {
args: { children: '禁用按钮', variant: 'primary', disabled: true },
};
export const Loading: Story = {
args: { children: '加载中...', variant: 'primary', loading: true },
};
// ========== 尺寸变体 ==========
export const Small: Story = {
args: { children: '小按钮', size: 'small' },
};
export const Large: Story = {
args: { children: '大按钮', size: 'large' },
};
// ========== 所有变体组合 ==========
export const AllVariants: Story = {
render: (args) => (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="primary" {...args}>主要</Button>
<Button variant="danger" {...args}>危险</Button>
<Button variant="ghost" {...args}>幽灵</Button>
</div>
),
};
// ========== 按钮组 ==========
export const ButtonGroup: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h4>变体</h4>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="primary">主要</Button>
<Button variant="danger">危险</Button>
<Button variant="ghost">幽灵</Button>
</div>
</div>
<div>
<h4>尺寸</h4>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Button size="small">Small</Button>
<Button size="medium">Medium</Button>
<Button size="large">Large</Button>
</div>
</div>
<div>
<h4>状态</h4>
<div style={{ display: 'flex', gap: 8 }}>
<Button disabled>禁用</Button>
<Button loading>加载中</Button>
</div>
</div>
</div>
),
};交互测试与 play 函数
// Button.stories.tsx — 交互测试
import { within, userEvent, expect } from '@storybook/test';
// 点击测试
export const ClickTest: Story = {
args: { children: '点击我', variant: 'primary' },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).toHaveBeenCalled();
},
};
// 焦点测试
export const FocusTest: Story = {
args: { children: '聚焦测试' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
button.focus();
await expect(button).toHaveFocus();
},
};
// 键盘交互测试
export const KeyboardTest: Story = {
args: { children: '键盘测试' },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
button.focus();
await userEvent.keyboard('{Enter}');
await expect(args.onClick).toHaveBeenCalled();
},
};
// 禁用状态测试
export const DisabledClickTest: Story = {
args: { children: '禁用按钮', disabled: true },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
// 禁用按钮不应触发 onClick
await expect(args.onClick).not.toHaveBeenCalled();
},
};复杂组件 Story
// Input.stories.tsx — 输入框组件
import type { Meta, StoryObj } from '@storybook/react';
import { Input } from './Input';
const meta: Meta<typeof Input> = {
title: 'Components/Input',
component: Input,
tags: ['autodocs'],
argTypes: {
value: { control: 'text' },
placeholder: { control: 'text' },
error: { control: 'text' },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Input>;
export const Default: Story = {
args: { placeholder: '请输入内容' },
};
export const WithValue: Story = {
args: { value: '已输入的内容', onChange: () => {} },
};
export const WithError: Story = {
args: { error: '此字段为必填项' },
};
export const Disabled: Story = {
args: { value: '不可编辑', disabled: true },
};
// 表单示例
export const FormExample: Story = {
render: () => (
<form style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
<Input label="用户名" placeholder="请输入用户名" required />
<Input label="邮箱" placeholder="请输入邮箱" type="email" />
<Input label="密码" placeholder="请输入密码" type="password" />
</form>
),
};
// ========== DataTable.stories.tsx — 数据表格 ==========
import type { Meta, StoryObj } from '@storybook/react';
import { DataTable } from './DataTable';
interface DataRow {
id: number;
name: string;
email: string;
status: 'active' | 'inactive';
role: 'admin' | 'user';
}
const sampleData: DataRow[] = [
{ id: 1, name: '张三', email: 'zhangsan@example.com', status: 'active', role: 'admin' },
{ id: 2, name: '李四', email: 'lisi@example.com', status: 'inactive', role: 'user' },
{ id: 3, name: '王五', email: 'wangwu@example.com', status: 'active', role: 'user' },
{ id: 4, name: '赵六', email: 'zhaoliu@example.com', status: 'active', role: 'user' },
];
const meta: Meta<typeof DataTable<DataRow>> = {
title: 'Components/DataTable',
component: DataTable,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof DataTable<DataRow>>;
export const Default: Story = {
args: {
data: sampleData,
columns: [
{ key: 'name', title: '姓名' },
{ key: 'email', title: '邮箱' },
{ key: 'status', title: '状态', render: (row) => (
<span style={{ color: row.status === 'active' ? 'green' : 'gray' }}>
{row.status === 'active' ? '活跃' : '停用'}
</span>
)},
{ key: 'role', title: '角色' },
],
},
};
export const Empty: Story = {
args: {
data: [],
columns: [
{ key: 'name', title: '姓名' },
{ key: 'email', title: '邮箱' },
],
},
};
export const Loading: Story = {
args: {
data: [],
loading: true,
columns: [
{ key: 'name', title: '姓名' },
{ key: 'email', title: '邮箱' },
],
},
};Storybook 配置
// .storybook/main.ts — 核心配置
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials', // 文档、控制面板、操作日志、源码
'@storybook/addon-interactions', // 交互测试
'@storybook/addon-a11y', // 无障碍检查
'@storybook/addon-viewport', // 视口切换
'@storybook/addon-backgrounds', // 背景色切换
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autotags: true, // 自动标签
},
staticDirs: ['../public'],
env: (config) => ({
...config,
API_URL: 'http://localhost:5000',
}),
};
export default config;
// .storybook/preview.ts — 全局预览配置
import type { Preview } from '@storybook/react';
import { withBackgrounds } from '@storybook/addon-backgrounds';
import '../src/styles/global.css';
const preview: Preview = {
parameters: {
// 背景色
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#1f1f1f' },
{ name: 'gray', value: '#f5f5f5' },
],
},
// 视口
viewport: {
viewports: {
mobile: { name: 'Mobile', styles: { width: '375px', height: '812px' } },
tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' } },
desktop: { name: 'Desktop', styles: { width: '1440px', height: '900px' } },
},
},
// 无障碍
a11y: {
element: '#root',
config: {},
options: {},
},
// 控制面板排序
controls: { sort: 'requiredFirst' },
actions: { argTypesRegex: '^on[A-Z].*' },
},
// 全局装饰器
decorators: [
(Story) => (
<div style={{ padding: 16 }}>
<Story />
</div>
),
],
// 全局标签
tags: ['autodocs'],
};
export default preview;
// .storybook/preview-head.html — 全局 HTML 头部
// <link rel="preconnect" href="https://fonts.googleapis.com">
// <style>body { font-family: 'Inter', sans-serif; }</style>MDX 文档编写
<!-- Button.mdx — 组件文档 -->
import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs';
import { Button } from './Button';
<Meta title="Components/Button/文档" component={Button} />
# Button 按钮
用于触发操作的按钮组件。
## 使用方式
```tsx
import { Button } from '@/components/Button';
<Button variant="primary" size="medium" onClick={() => alert('clicked')}>
点击我
</Button>变体
尺寸
Props
最佳实践
- 使用
variant控制按钮样式,不要直接修改颜色。 - 危险操作使用
variant="danger"并配合确认弹窗。 - 表单提交按钮使用
variant="primary"。
### 自动化测试集成
```typescript
// tests/Button.test.tsx — 配合 Storybook 测试
import { composeStories } from '@storybook/react';
import { render, screen, fireEvent } from '@testing-library/react';
import * as stories from '../components/Button.stories';
// 从 Storybook 自动生成测试用例
const { Primary, Danger, Disabled } = composeStories(stories);
test('renders primary button with text', () => {
render(<Primary />);
expect(screen.getByRole('button')).toHaveTextContent('主要按钮');
});
test('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<Primary onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
test('disabled button does not call onClick', () => {
const onClick = vi.fn();
render(<Disabled onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});优点
缺点
总结
Storybook 为组件提供隔离开发环境和文档系统。通过 Story 展示组件的不同状态和用法,配合 autodocs 自动生成组件文档页。play 函数实现交互测试,composeStories 复用 Story 为单元测试。Chromatic 实现视觉回归测试。建议为设计系统中的每个组件编写完整的 Story(覆盖所有变体、状态和边界情况)。
关键知识点
- CSF 3.0 格式使用 StoryObj 类型定义 Story。
- args 控制组件参数,argTypes 定义控制面板类型和描述。
- play 函数实现交互测试,配合 @storybook/test 使用。
- autodocs 标签自动生成组件文档页。
- composeStories 可以将 Story 转为测试用例复用。
项目落地视角
- 为组件库中每个组件编写 Story,覆盖所有变体和状态。
- CI 中运行 Chromatic 进行视觉回归测试。
- Storybook 部署为团队共享的组件文档站(如 GitHub Pages、Vercel)。
- 在 pull request 中自动截图对比,防止 UI 回归。
常见误区
- 只写一个默认 Story,不覆盖所有状态(加载、错误、空数据、长文本)。
- Story 中的 Mock 数据与真实数据差异过大。
- 忘记为组件添加 JSDoc 注释导致文档不完整。
- 忽略装饰器(Decorator)导致全局依赖(Context、Router)缺失。
进阶路线
- 学习 Chromatic 或 Percy 进行视觉回归测试。
- 研究 Storybook 的 Testing Utility 自动化测试。
- 了解 Storybook 的 Composition 合并多个 Storybook。
- 探索 Storybook 的 Design Addon 与 Figma 集成。
适用场景
- 设计系统组件库开发。
- 组件文档和交互展示。
- 视觉回归测试。
- 跨团队组件共享和协作。
落地建议
- 每个组件文件同级创建 .stories.tsx。
- CI 中运行 Storybook 构建和测试。
- 部署 Storybook 为团队文档站。
- 为 Story 添加 JSDoc 注释,支持 autodocs。
排错清单
- 检查 Story 文件是否匹配 glob 模式。
- 确认 addon 配置是否正确。
- 检查组件的全局依赖(Context、Router)是否在 decorator 中提供。
- 确认 TypeScript 类型是否正确配置(tsconfig paths)。
复盘问题
- 组件库的 Story 覆盖率是多少?
- 视觉回归测试发现了多少问题?
- Storybook 是否帮助设计师理解了组件的可用状态?
- Storybook 的构建时间是否可以优化?
