用 Claude Code 开发 Storybook:UI 目录、交互测试与 CI 实战
用 Claude Code 搭建 Storybook:配置、TypeScript stories、交互测试、CI 与 Chromatic 检查。
不要把 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/StoryObj、satisfies、@storybook/test 交互测试、@storybook/addon-vitest,以及可接入 Chromatic 的 CI。参考的官方文档包括 main configuration、TypeScript stories、interaction testing、Vitest addon 和 Chromatic for Storybook。
如果你正在整理设计系统,也建议结合 Claude Code 设计系统构建 和 Claude Code 无障碍实践 阅读。Storybook 最有价值的地方,不是展示“看起来不错”的组件,而是把状态和质量检查固定下来。
先从三个高价值场景开始
不要一次性把所有 UI 都搬进 Storybook。优先选择变化频繁、离转化或咨询更近的组件。
| 场景 | Storybook 中应覆盖的状态 | 适合交给 Claude Code 的工作 |
|---|---|---|
| 设计系统 Button | primary、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 的失败场景。把 InvalidEmail 和 SuccessfulSubmit 分开,再在 CI 中运行 test-storybook 与 build-storybook,能更早发现表单和 CTA 的问题。视觉检查也不必一开始覆盖全站,先集中在转化相关的组件上,评审会轻很多。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。