编写 Stories #

什么是 Story? #

Story 是组件的一个特定状态或变体。每个 Story 代表组件在不同条件下的展示方式。

text
┌─────────────────────────────────────────────────────────────┐
│                    Story 的概念                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  组件 = Button                                              │
│                                                             │
│  Stories = 组件的各种状态                                    │
│                                                             │
│  ├── Primary Story    (主要按钮)                            │
│  ├── Secondary Story  (次要按钮)                            │
│  ├── Disabled Story   (禁用按钮)                            │
│  ├── Loading Story    (加载中按钮)                          │
│  └── WithIcon Story   (带图标按钮)                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

CSF 格式 #

CSF(Component Story Format)是 Storybook 的标准格式。

基本结构 #

javascript
// Button.stories.jsx
import Button from './Button';

// 默认导出:组件配置
export default {
  title: 'Components/Button',    // Story 路径
  component: Button,              // 关联组件
  tags: ['autodocs'],             // 自动文档
  argTypes: {                     // 参数类型定义
    variant: {
      control: 'select',
      options: ['primary', 'secondary'],
    },
  },
};

// 命名导出:各个 Story
export const Primary = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};

export const Secondary = {
  args: {
    variant: 'secondary',
    children: 'Secondary Button',
  },
};

默认导出配置 #

javascript
export default {
  // Story 标题和路径
  title: 'Components/Button',
  
  // 关联的组件
  component: Button,
  
  // 子组件
  subcomponents: { ButtonGroup },
  
  // 自动文档标签
  tags: ['autodocs'],
  
  // 参数类型定义
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger'],
      description: '按钮变体类型',
    },
  },
  
  // 参数默认值
  args: {
    variant: 'primary',
    size: 'medium',
  },
  
  // 装饰器
  decorators: [
    (Story) => (
      <div style={{ padding: 20 }}>
        <Story />
      </div>
    ),
  ],
  
  // 参数配置
  parameters: {
    layout: 'centered',
    backgrounds: {
      default: 'light',
    },
  },
};

Story 定义方式 #

对象语法(推荐) #

javascript
// Storybook 7+ 推荐的对象语法
export const Primary = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};

export const WithCustomRender = {
  args: {
    variant: 'primary',
  },
  render: (args) => (
    <div>
      <Button {...args}>Button 1</Button>
      <Button {...args}>Button 2</Button>
    </div>
  ),
};

函数语法 #

javascript
// 旧版函数语法(仍然支持)
export const Primary = (args) => <Button {...args} />;
Primary.args = {
  variant: 'primary',
  children: 'Primary Button',
};

// 带装饰器
export const WithDecorator = (args) => <Button {...args} />;
WithDecorator.decorators = [
  (Story) => (
    <div style={{ background: '#f0f0f0', padding: 20 }}>
      <Story />
    </div>
  ),
];

模板复用 #

javascript
// 创建模板
const Template = (args) => <Button {...args} />;

// 复用模板
export const Primary = Template.bind({});
Primary.args = {
  variant: 'primary',
  children: 'Primary Button',
};

export const Secondary = Template.bind({});
Secondary.args = {
  variant: 'secondary',
  children: 'Secondary Button',
};

Args 参数 #

基本使用 #

javascript
export const Primary = {
  args: {
    // 字符串
    children: 'Button',
    
    // 布尔值
    disabled: false,
    
    // 数字
    count: 5,
    
    // 对象
    style: { padding: '10px 20px' },
    
    // 数组
    items: ['Item 1', 'Item 2'],
    
    // 函数
    onClick: () => console.log('clicked'),
    
    // React 节点
    icon: <Icon name="plus" />,
  },
};

共享默认参数 #

javascript
export default {
  title: 'Components/Button',
  component: Button,
  // 所有 Story 共享的默认参数
  args: {
    size: 'medium',
    disabled: false,
  },
};

// 继承默认参数
export const Primary = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
    // size 和 disabled 继承自默认
  },
};

// 覆盖默认参数
export const Large = {
  args: {
    variant: 'primary',
    size: 'large',  // 覆盖默认
    children: 'Large Button',
  },
};

Args 组合 #

javascript
// 从其他 Story 继承参数
export const Primary = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};

export const PrimaryDisabled = {
  args: {
    ...Primary.args,
    disabled: true,
  },
};

ArgTypes 参数类型 #

控件配置 #

javascript
export default {
  title: 'Components/Button',
  component: Button,
  argTypes: {
    // 下拉选择
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger', 'warning'],
      description: '按钮变体类型',
      table: {
        type: { summary: 'string' },
        defaultValue: { summary: 'primary' },
      },
    },
    
    // 单选按钮
    size: {
      control: 'radio',
      options: ['small', 'medium', 'large'],
    },
    
    // 行内单选
    shape: {
      control: 'inline-radio',
      options: ['default', 'circle', 'round'],
    },
    
    // 布尔开关
    disabled: {
      control: 'boolean',
    },
    
    // 文本输入
    children: {
      control: 'text',
    },
    
    // 数字输入
    count: {
      control: 'number',
    },
    
    // 范围滑块
    opacity: {
      control: { type: 'range', min: 0, max: 1, step: 0.1 },
    },
    
    // 颜色选择
    color: {
      control: 'color',
    },
    
    // 对象编辑
    style: {
      control: 'object',
    },
    
    // 数组编辑
    items: {
      control: 'object',
    },
    
    // 日期选择
    date: {
      control: 'date',
    },
    
    // 文件选择
    image: {
      control: 'file',
      accept: '.png,.jpg,.jpeg',
    },
    
    // 隐藏控件
    className: {
      control: false,
    },
  },
};

映射值 #

javascript
argTypes: {
  status: {
    control: 'select',
    options: ['pending', 'active', 'completed'],
    mapping: {
      pending: { color: 'orange', icon: 'clock' },
      active: { color: 'blue', icon: 'play' },
      completed: { color: 'green', icon: 'check' },
    },
  },
},

条件显示 #

javascript
argTypes: {
  icon: {
    if: { arg: 'showIcon', truthy: true },
  },
  iconPosition: {
    if: { arg: 'showIcon', truthy: true },
    control: 'radio',
    options: ['left', 'right'],
  },
},

Decorators 装饰器 #

组件级装饰器 #

javascript
export default {
  title: 'Components/Card',
  component: Card,
  decorators: [
    // 函数装饰器
    (Story) => (
      <div style={{ padding: 20, background: '#f5f5f5' }}>
        <Story />
      </div>
    ),
  ],
};

Story 级装饰器 #

javascript
export const WithDarkBackground = {
  decorators: [
    (Story) => (
      <div style={{ padding: 20, background: '#333' }}>
        <Story />
      </div>
    ),
  ],
  args: {
    children: 'Dark Theme',
  },
};

Provider 装饰器 #

javascript
// 提供上下文
import { ThemeProvider } from '../theme';
import { I18nProvider } from '../i18n';

export default {
  title: 'Components/Button',
  component: Button,
  decorators: [
    (Story) => (
      <ThemeProvider theme="light">
        <I18nProvider locale="zh-CN">
          <Story />
        </I18nProvider>
      </ThemeProvider>
    ),
  ],
};

Router 装饰器 #

javascript
import { MemoryRouter } from 'react-router-dom';

export default {
  title: 'Components/NavLink',
  component: NavLink,
  decorators: [
    (Story) => (
      <MemoryRouter initialEntries={['/home']}>
        <Story />
      </MemoryRouter>
    ),
  ],
};

Redux 装饰器 #

javascript
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';

const mockStore = configureStore({
  reducer: {
    user: userReducer,
  },
  preloadedState: {
    user: { name: 'Test User', isLoggedIn: true },
  },
});

export default {
  title: 'Components/UserProfile',
  component: UserProfile,
  decorators: [
    (Story) => (
      <Provider store={mockStore}>
        <Story />
      </Provider>
    ),
  ],
};

Parameters 参数 #

布局配置 #

javascript
export default {
  title: 'Components/Button',
  component: Button,
  parameters: {
    // 布局方式
    layout: 'centered',  // 'centered' | 'fullscreen' | 'padded'
  },
};

// Story 级别覆盖
export const FullWidth = {
  parameters: {
    layout: 'fullscreen',
  },
};

背景配置 #

javascript
export default {
  parameters: {
    backgrounds: {
      default: 'light',
      values: [
        { name: 'light', value: '#ffffff' },
        { name: 'dark', value: '#333333' },
        { name: 'brand', value: '#1890ff' },
      ],
    },
  },
};

// Story 级别背景
export const OnDark = {
  parameters: {
    backgrounds: { default: 'dark' },
  },
};

视口配置 #

javascript
export default {
  parameters: {
    viewport: {
      defaultViewport: 'mobile1',
      viewports: {
        mobile1: {
          name: 'Mobile (375)',
          styles: { width: '375px', height: '812px' },
        },
        tablet: {
          name: 'Tablet (768)',
          styles: { width: '768px', height: '1024px' },
        },
      },
    },
  },
};

文档配置 #

javascript
export default {
  parameters: {
    docs: {
      description: {
        component: `
## Button 组件

按钮用于触发操作。

### 使用场景

- 表单提交
- 页面导航
- 触发操作
        `,
      },
      source: {
        // 源码显示方式
        type: 'code',  // 'code' | 'auto'
      },
    },
  },
};

禁用插件 #

javascript
export const WithoutControls = {
  parameters: {
    controls: {
      disable: true,
    },
    actions: {
      disable: true,
    },
  },
};

Render 函数 #

自定义渲染 #

javascript
// 完全自定义渲染
export const MultipleButtons = {
  render: (args) => (
    <div style={{ display: 'flex', gap: '10px' }}>
      <Button {...args}>Button 1</Button>
      <Button {...args}>Button 2</Button>
      <Button {...args}>Button 3</Button>
    </div>
  ),
};

// 使用 args 和 argTypes
export const DynamicRender = {
  args: {
    count: 3,
  },
  render: ({ count }) => (
    <div>
      {Array.from({ length: count }).map((_, i) => (
        <Button key={i}>Button {i + 1}</Button>
      ))}
    </div>
  ),
};

组合渲染 #

javascript
export const AllVariants = {
  render: () => (
    <div style={{ display: 'grid', gap: '10px' }}>
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="danger">Danger</Button>
    </div>
  ),
};

export const AllSizes = {
  render: () => (
    <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
      <Button size="small">Small</Button>
      <Button size="medium">Medium</Button>
      <Button size="large">Large</Button>
    </div>
  ),
};

Play 函数 #

交互测试 #

javascript
import { within, userEvent, expect } from '@storybook/test';

export const InteractiveButton = {
  args: {
    onClick: fn(),
  },
  play: async ({ args, canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');
    
    // 点击按钮
    await userEvent.click(button);
    
    // 验证点击被调用
    await expect(args.onClick).toHaveBeenCalled();
  },
};

复杂交互 #

javascript
export const FormSubmission = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // 填写表单
    await userEvent.type(canvas.getByLabelText('用户名'), 'testuser');
    await userEvent.type(canvas.getByLabelText('密码'), 'password123');
    
    // 提交表单
    await userEvent.click(canvas.getByRole('button', { name: '登录' }));
    
    // 验证结果
    await expect(canvas.getByText('登录成功')).toBeInTheDocument();
  },
};

Story 组合 #

扩展 Story #

javascript
// 基础 Story
export const Base = {
  args: {
    children: 'Base Button',
  },
};

// 扩展基础 Story
export const Primary = {
  ...Base,
  args: {
    ...Base.args,
    variant: 'primary',
  },
};

// 进一步扩展
export const PrimaryLarge = {
  ...Primary,
  args: {
    ...Primary.args,
    size: 'large',
  },
};

Story 模板 #

javascript
// 定义模板
const createButtonStory = (variant, size = 'medium') => ({
  args: {
    variant,
    size,
    children: `${variant} ${size} button`,
  },
});

// 使用模板创建多个 Story
export const PrimarySmall = createButtonStory('primary', 'small');
export const PrimaryMedium = createButtonStory('primary', 'medium');
export const PrimaryLarge = createButtonStory('primary', 'large');
export const SecondarySmall = createButtonStory('secondary', 'small');
export const SecondaryMedium = createButtonStory('secondary', 'medium');

MDX Stories #

使用 MDX #

mdx
<!-- Button.stories.mdx -->
import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs';
import Button from './Button';

<Meta title="Components/Button" component={Button} />

# Button 组件

按钮用于触发操作。

## 基础用法

<Canvas>
  <Story name="Primary">
    <Button variant="primary">Primary Button</Button>
  </Story>
</Canvas>

## 所有变体

<Canvas>
  <Story name="AllVariants">
    <div style={{ display: 'flex', gap: '10px' }}>
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="danger">Danger</Button>
    </div>
  </Story>
</Canvas>

## API

<ArgsTable of={Button} />

MDX 与 CSF 混合 #

javascript
// Button.stories.js
import Button from './Button';

export default {
  title: 'Components/Button',
  component: Button,
};

export const Primary = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};
mdx
<!-- Button.docs.mdx -->
import { Meta, Story, Canvas } from '@storybook/addon-docs';
import Button from './Button';

<Meta title="Components/Button/Docs" />

# Button 文档

这是 Button 组件的详细文档。

<Canvas>
  <Story name="Example">
    <Button variant="primary">Example</Button>
  </Story>
</Canvas>

最佳实践 #

1. Story 命名 #

javascript
// ✅ 好的命名
export const Primary = {};
export const WithIcon = {};
export const LoadingState = {};
export const ErrorState = {};
export const WithLongText = {};

// ❌ 不好的命名
export const Story1 = {};
export const Test = {};
export const Default = {};  // 除非是默认状态

2. 组织结构 #

javascript
export default {
  title: 'Components/Button',
  component: Button,
};

// 基础状态
export const Primary = {};
export const Secondary = {};
export const Tertiary = {};

// 尺寸变体
export const Small = {};
export const Medium = {};
export const Large = {};

// 状态变体
export const Loading = {};
export const Disabled = {};

// 组合示例
export const WithIcon = {};
export const WithBadge = {};
export const InGroup = {};

3. 文档描述 #

javascript
export const Primary = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
  parameters: {
    docs: {
      description: {
        story: '主要操作按钮,用于页面的主要行动点。',
      },
    },
  },
};

4. 类型安全 #

typescript
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import Button from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
};

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

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};

下一步 #

现在你已经掌握了 Stories 的编写技巧,接下来学习 插件系统 扩展 Storybook 的功能!

最后更新:2026-03-29