Use Cases (更新: 2026/6/2)

用 Claude Code 开发 Storybook:UI 目录、交互测试与 CI 实战

用 Claude Code 搭建 Storybook:配置、TypeScript stories、交互测试、CI 与 Chromatic 检查。

用 Claude Code 开发 Storybook:UI 目录、交互测试与 CI 实战

不要把 Storybook 只当成组件截图墙

Storybook 可以把 UI 组件从应用主页面中拆出来单独开发。对初学者来说,它像一个可以打开按钮、表单、卡片的组件目录;对真实团队来说,它更像 UI 契约:props、状态、交互、无障碍、设计评审、CI 检查都能放在同一个地方。

如果只对 Claude Code 说“帮我加 Storybook”,它很可能只生成几个 Button.stories.tsx 文件。真正能维护的做法是先定义范围:哪些组件必须进入 Storybook,哪些状态必须覆盖,play 函数要验证哪些用户操作,PR 中如何执行 Chromatic 或等价的视觉回归检查。

本文以 React + TypeScript + Vite 为例,采用 Storybook 8/9 以后仍然自然的写法:.storybook/main.ts、CSF 的 Meta/StoryObjsatisfies@storybook/test 交互测试、@storybook/addon-vitest,以及可接入 Chromatic 的 CI。参考的官方文档包括 main configurationTypeScript storiesinteraction testingVitest addonChromatic for Storybook

如果你正在整理设计系统,也建议结合 Claude Code 设计系统构建Claude Code 无障碍实践 阅读。Storybook 最有价值的地方,不是展示“看起来不错”的组件,而是把状态和质量检查固定下来。

先从三个高价值场景开始

不要一次性把所有 UI 都搬进 Storybook。优先选择变化频繁、离转化或咨询更近的组件。

场景Storybook 中应覆盖的状态适合交给 Claude Code 的工作
设计系统 Buttonprimary、secondary、loading、disabled、所有尺寸props 类型、stories、controls、autodocs
免费注册表单空表单、输入错误、提交中、成功play 函数、mock、校验断言
文章结尾或商品页 CTA默认、强调、移动端、长文案响应式检查、Chromatic 差异、内部链接确认

这三个例子足以让设计师评审状态、开发者确认 props 契约、运营者检查 CTA 和表单是否仍然可用。

固定为 Vite 体系的配置

使用 Storybook 的 Vitest addon 时,Vite 体系最省心。React + Vite 项目应使用 @storybook/react-vite,Next.js 项目则优先评估 @storybook/nextjs-vite

npm create vite@latest storybook-claude-demo -- --template react-ts
cd storybook-claude-demo
npm install
npm create storybook@latest
npx storybook add @storybook/addon-a11y
npx storybook add @storybook/addon-vitest
npm install -D chromatic @vitest/browser-playwright playwright
npx playwright install chromium
{
  "scripts": {
    "dev": "vite",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "test-storybook": "vitest --project=storybook --run",
    "chromatic": "chromatic"
  }
}
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  framework: '@storybook/react-vite',
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-vitest'],
  docs: {
    defaultName: 'Docs',
  },
  staticDirs: ['../public'],
};

export default config;
// .storybook/preview.ts
import type { Preview } from '@storybook/react-vite';

const preview: Preview = {
  tags: ['autodocs'],
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    a11y: {
      test: 'todo',
    },
  },
};

export default preview;

示例1:Button 的 TypeScript story

不要为了 Storybook 新增生产环境不存在的 props。stories 应该记录真实组件的使用契约。

// src/components/Button.tsx
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';

type ButtonVariant = 'primary' | 'secondary' | 'outline';
type ButtonSize = 'sm' | 'md' | 'lg';

export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  children: ReactNode;
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
};

const variantStyle: Record<ButtonVariant, CSSProperties> = {
  primary: { background: '#2563eb', color: '#ffffff', borderColor: '#2563eb' },
  secondary: { background: '#0f172a', color: '#ffffff', borderColor: '#0f172a' },
  outline: { background: '#ffffff', color: '#0f172a', borderColor: '#94a3b8' },
};

const sizeStyle: Record<ButtonSize, CSSProperties> = {
  sm: { minHeight: 32, padding: '0 12px', fontSize: 14 },
  md: { minHeight: 40, padding: '0 16px', fontSize: 15 },
  lg: { minHeight: 48, padding: '0 20px', fontSize: 16 },
};

export function Button({
  children,
  variant = 'primary',
  size = 'md',
  loading = false,
  disabled,
  style,
  ...props
}: ButtonProps) {
  return (
    <button
      {...props}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      style={{
        ...variantStyle[variant],
        ...sizeStyle[size],
        borderWidth: 1,
        borderStyle: 'solid',
        borderRadius: 6,
        fontWeight: 700,
        cursor: disabled || loading ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.55 : 1,
        ...style,
      }}
    >
      {loading ? 'Sending...' : children}
    </button>
  );
}
// src/components/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';

const meta = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  args: {
    children: 'Start free trial',
    variant: 'primary',
    size: 'md',
  },
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'outline'],
    },
    size: {
      control: 'inline-radio',
      options: ['sm', 'md', 'lg'],
    },
    loading: { control: 'boolean' },
    disabled: { control: 'boolean' },
  },
} satisfies Meta<typeof Button>;

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

export const Primary = {} satisfies Story;

export const Secondary = {
  args: {
    variant: 'secondary',
    children: 'View pricing',
  },
} satisfies Story;

export const AllSizes = {
  render: () => (
    <div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
    </div>
  ),
} satisfies Story;

export const Loading = {
  args: {
    loading: true,
  },
} satisfies Story;

示例2:把注册表单写到 interaction test

play 函数会在 story 渲染后执行用户操作,因此可以检查“能不能真的输入和提交”。

// src/components/SignupForm.tsx
import type { FormEvent } from 'react';
import { useState } from 'react';
import { Button } from './Button';

export type SignupFormData = {
  name: string;
  email: string;
  plan: 'free' | 'team';
};

type SignupFormProps = {
  plan?: SignupFormData['plan'];
  onSubmit?: (data: SignupFormData) => Promise<void> | void;
};

export function SignupForm({ plan = 'free', onSubmit }: SignupFormProps) {
  const [message, setMessage] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    const data: SignupFormData = {
      name: String(form.get('name') ?? ''),
      email: String(form.get('email') ?? ''),
      plan,
    };

    if (!data.email.includes('@')) {
      setMessage('Please enter a valid email address.');
      return;
    }

    await onSubmit?.(data);
    setMessage('Thanks, we will send the setup guide.');
  }

  return (
    <form onSubmit={handleSubmit} style={{ display: 'grid', gap: 12, maxWidth: 360 }}>
      <label>
        Name
        <input name="name" required style={{ display: 'block', width: '100%', minHeight: 36 }} />
      </label>
      <label>
        Email
        <input name="email" type="email" required style={{ display: 'block', width: '100%', minHeight: 36 }} />
      </label>
      <Button type="submit">Get the guide</Button>
      {message ? <p role="status">{message}</p> : null}
    </form>
  );
}
// src/components/SignupForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from '@storybook/test';
import { SignupForm } from './SignupForm';

const meta = {
  title: 'Components/SignupForm',
  component: SignupForm,
  args: {
    plan: 'team',
    onSubmit: fn(),
  },
} satisfies Meta<typeof SignupForm>;

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

export const Empty = {} satisfies Story;

export const InvalidEmail = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await userEvent.type(canvas.getByLabelText('Name'), 'Masa');
    await userEvent.type(canvas.getByLabelText('Email'), 'masa.example.com');
    await userEvent.click(canvas.getByRole('button', { name: 'Get the guide' }));

    await expect(canvas.getByRole('status')).toHaveTextContent('valid email');
  },
} satisfies Story;

export const SuccessfulSubmit = {
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    await userEvent.type(canvas.getByLabelText('Name'), 'Masa');
    await userEvent.type(canvas.getByLabelText('Email'), 'masa@example.com');
    await userEvent.click(canvas.getByRole('button', { name: 'Get the guide' }));

    await expect(args.onSubmit).toHaveBeenCalledWith({
      name: 'Masa',
      email: 'masa@example.com',
      plan: 'team',
    });
    await expect(canvas.getByRole('status')).toHaveTextContent('setup guide');
  },
} satisfies Story;

示例3:CI 与 Chromatic 等价检查

Vitest addon 会把 stories 转成浏览器中的组件测试。带有 play 的 story 会执行交互和断言。

// .storybook/vitest.setup.ts
import { setProjectAnnotations } from '@storybook/react-vite';
import * as previewAnnotations from './preview';

setProjectAnnotations([previewAnnotations]);
// vitest.config.ts
import { defineConfig, defineProject, mergeConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import viteConfig from './vite.config';

const dirname = path.dirname(fileURLToPath(import.meta.url));

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      projects: [
        defineProject({
          extends: true,
          plugins: [
            storybookTest({
              configDir: path.join(dirname, '.storybook'),
              storybookScript: 'npm run storybook -- --ci',
              storybookUrl: 'http://localhost:6006',
              tags: {
                include: ['test'],
                exclude: ['experimental'],
              },
            }),
          ],
          test: {
            name: 'storybook',
            browser: {
              enabled: true,
              provider: playwright({}),
              headless: true,
              instances: [{ browser: 'chromium' }],
            },
            setupFiles: ['./.storybook/vitest.setup.ts'],
          },
        }),
      ],
    },
  }),
);
# .github/workflows/storybook.yml
name: storybook

on:
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run test-storybook
      - run: npm run build-storybook
      - name: Publish to Chromatic
        if: ${{ secrets.CHROMATIC_PROJECT_TOKEN != '' }}
        run: npx chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }}

给 Claude Code 的请求模板

请为 React + TypeScript + Vite 项目整理 Storybook。

范围:
- src/components/Button.tsx
- src/components/SignupForm.tsx

要求:
- .storybook/main.ts 使用 @storybook/react-vite
- CSF 使用 Meta/StoryObj 和 satisfies
- Button 覆盖 primary, secondary, outline, loading, disabled, all sizes
- SignupForm 编写 invalid email 和 successful submit 的 play 函数
- 使用 @storybook/test 的 within, userEvent, expect, fn
- 配置 Vitest addon,让 test-storybook 能在 CI 中运行
- 在 GitHub Actions 中加入 build-storybook 和 Chromatic 等价检查

禁止:
- 不要写伪代码
- 不要增加真实组件没有的 props
- 不要把 secret 写进代码
- 不要格式化范围外文件

完成后报告:
- 修改文件
- 执行命令
- 剩余风险

常见坑

第一,给 Storybook 增加专用 props 会让文档和真实应用脱节。第二,只测试成功路径会漏掉错误输入、loading、disabled 和重复提交。第三,时间、随机数、动画和远程图片会让视觉差异变得很吵。第四,混用 @storybook/react@storybook/react-vite@storybook/nextjs-vite 容易造成 alias 和 addon 配置问题。第五,CI 中忘记安装 Chromium 会让浏览器模式测试失败。第六,不要把收入相关的 CTA、商品卡片、咨询表单放到最后才补。

收益导线与实际体验

技术媒体或产品站点可以把 Storybook 当作收益导线的保护层。免费资料表单、商品页 CTA、培训咨询表单、文章内部链接卡片都可以写成 stories,并在 PR 中通过交互测试和视觉检查确认。

想先固定 Claude Code 的请求写法,可以从免费速查表开始;需要模板化流程可以看产品与模板;如果要在真实仓库中整合 Storybook、CI、Chromatic、无障碍和 CTA 检查,可以使用Claude Code 培训与导入咨询

实际试用时,最能减少返工的不是 Button 的颜色状态,而是 SignupForm 的失败场景。把 InvalidEmailSuccessfulSubmit 分开,再在 CI 中运行 test-storybookbuild-storybook,能更早发现表单和 CTA 的问题。视觉检查也不必一开始覆盖全站,先集中在转化相关的组件上,评审会轻很多。

#Claude Code #Storybook #UI开发 #components #frontend
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。