- 发布于
Storybook 组件开发指南:构建可维护的 UI 组件库
- 作者

- 姓名
- 全能波
- GitHub
- @weicracker
Storybook 组件开发指南:构建可维护的 UI 组件库
Storybook 是一个强大的工具,用于独立构建 UI 组件和页面。它提供了组件隔离开发、文档生成、可视化测试等功能。本文将深入探讨 Storybook 的核心特性和最佳实践。
Storybook 基础配置
项目初始化
// .storybook/main.js - 主配置文件
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-controls',
'@storybook/addon-actions',
'@storybook/addon-viewport',
'@storybook/addon-docs',
'@storybook/addon-a11y',
'@storybook/addon-design-tokens',
'storybook-addon-designs',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
features: {
buildStoriesJson: true,
storyStoreV7: true,
},
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
viteFinal: async (config) => {
// 自定义 Vite 配置
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname, '../src'),
}
return config
},
}
// .storybook/preview.js - 预览配置
import { themes } from '@storybook/theming'
import '../src/styles/globals.css'
export const parameters = {
// 操作面板配置
actions: { argTypesRegex: '^on[A-Z].*' },
// 控件配置
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
// 文档配置
docs: {
theme: themes.light,
inlineStories: true,
source: {
state: 'open',
},
},
// 视窗配置
viewport: {
viewports: {
mobile: {
name: 'Mobile',
styles: { width: '375px', height: '667px' },
},
tablet: {
name: 'Tablet',
styles: { width: '768px', height: '1024px' },
},
desktop: {
name: 'Desktop',
styles: { width: '1200px', height: '800px' },
},
},
},
// 背景配置
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' },
{ name: 'gray', value: '#f5f5f5' },
],
},
// 布局配置
layout: 'centered',
}
// 全局装饰器
export const decorators = [
(Story, context) => {
const { theme } = context.globals
return (
<div className={`storybook-wrapper ${theme || 'light'}`}>
<Story />
</div>
)
},
]
// 全局类型
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
icon: 'paintbrush',
items: [
{ value: 'light', title: 'Light Theme' },
{ value: 'dark', title: 'Dark Theme' },
],
showName: true,
},
},
}
基础组件开发
按钮组件
// src/components/Button/Button.jsx
import React from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import './Button.css'
/**
* 通用按钮组件
*/
export const Button = ({
variant = 'primary',
size = 'medium',
disabled = false,
loading = false,
icon,
iconPosition = 'left',
fullWidth = false,
children,
className,
onClick,
...props
}) => {
const buttonClasses = classNames(
'btn',
`btn--${variant}`,
`btn--${size}`,
{
'btn--disabled': disabled,
'btn--loading': loading,
'btn--full-width': fullWidth,
'btn--icon-only': !children && icon,
},
className
)
const handleClick = (event) => {
if (disabled || loading) {
event.preventDefault()
return
}
onClick?.(event)
}
const renderIcon = () => {
if (loading) {
return <span className="btn__spinner" />
}
if (icon) {
return <span className="btn__icon">{icon}</span>
}
return null
}
return (
<button
className={buttonClasses}
disabled={disabled || loading}
onClick={handleClick}
{...props}
>
{iconPosition === 'left' && renderIcon()}
{children && <span className="btn__text">{children}</span>}
{iconPosition === 'right' && renderIcon()}
</button>
)
}
Button.propTypes = {
/** 按钮变体 */
variant: PropTypes.oneOf(['primary', 'secondary', 'danger', 'ghost', 'link']),
/** 按钮尺寸 */
size: PropTypes.oneOf(['small', 'medium', 'large']),
/** 是否禁用 */
disabled: PropTypes.bool,
/** 是否显示加载状态 */
loading: PropTypes.bool,
/** 图标元素 */
icon: PropTypes.node,
/** 图标位置 */
iconPosition: PropTypes.oneOf(['left', 'right']),
/** 是否全宽 */
fullWidth: PropTypes.bool,
/** 按钮内容 */
children: PropTypes.node,
/** 自定义类名 */
className: PropTypes.string,
/** 点击事件处理器 */
onClick: PropTypes.func,
}
// src/components/Button/Button.stories.jsx
import { Button } from './Button'
import { action } from '@storybook/addon-actions'
// 默认导出配置
export default {
title: 'Components/Button',
component: Button,
parameters: {
docs: {
description: {
component: '通用按钮组件,支持多种样式和状态。',
},
},
},
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'danger', 'ghost', 'link'],
description: '按钮的视觉样式变体',
},
size: {
control: { type: 'select' },
options: ['small', 'medium', 'large'],
description: '按钮的尺寸大小',
},
disabled: {
control: { type: 'boolean' },
description: '是否禁用按钮',
},
loading: {
control: { type: 'boolean' },
description: '是否显示加载状态',
},
fullWidth: {
control: { type: 'boolean' },
description: '是否占满容器宽度',
},
children: {
control: { type: 'text' },
description: '按钮显示的文本内容',
},
onClick: {
action: 'clicked',
description: '按钮点击事件',
},
},
}
// 基础故事模板
const Template = (args) => <Button {...args} />
// 默认故事
export const Default = Template.bind({})
Default.args = {
children: '默认按钮',
}
// 不同变体
export const Primary = Template.bind({})
Primary.args = {
variant: 'primary',
children: '主要按钮',
}
export const Secondary = Template.bind({})
Secondary.args = {
variant: 'secondary',
children: '次要按钮',
}
export const Danger = Template.bind({})
Danger.args = {
variant: 'danger',
children: '危险按钮',
}
export const Ghost = Template.bind({})
Ghost.args = {
variant: 'ghost',
children: '幽灵按钮',
}
export const Link = Template.bind({})
Link.args = {
variant: 'link',
children: '链接按钮',
}
// 不同尺寸
export const Sizes = () => (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<Button size="small">小按钮</Button>
<Button size="medium">中等按钮</Button>
<Button size="large">大按钮</Button>
</div>
)
// 状态展示
export const States = () => (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<Button>正常状态</Button>
<Button disabled>禁用状态</Button>
<Button loading>加载状态</Button>
</div>
)
// 带图标的按钮
export const WithIcon = () => (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<Button icon={<span>📁</span>}>左侧图标</Button>
<Button icon={<span>📁</span>} iconPosition="right">
右侧图标
</Button>
<Button icon={<span>📁</span>} />
</div>
)
// 全宽按钮
export const FullWidth = Template.bind({})
FullWidth.args = {
fullWidth: true,
children: '全宽按钮',
}
FullWidth.parameters = {
layout: 'padded',
}
// 交互测试
export const Interactive = Template.bind({})
Interactive.args = {
children: '点击我',
onClick: action('button-click'),
}
// 自定义样式
export const CustomStyle = Template.bind({})
CustomStyle.args = {
children: '自定义样式',
className: 'custom-button',
style: {
background: 'linear-gradient(45deg, #ff6b6b, #4ecdc4)',
border: 'none',
color: 'white',
},
}
表单组件
// src/components/Input/Input.jsx
import React, { forwardRef, useState } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import './Input.css'
export const Input = forwardRef(
(
{
type = 'text',
size = 'medium',
variant = 'default',
disabled = false,
error = false,
success = false,
placeholder,
label,
helperText,
errorMessage,
prefix,
suffix,
clearable = false,
showPassword = false,
maxLength,
className,
onChange,
onClear,
...props
},
ref
) => {
const [showPasswordToggle, setShowPasswordToggle] = useState(false)
const [inputType, setInputType] = useState(type)
const inputClasses = classNames(
'input',
`input--${size}`,
`input--${variant}`,
{
'input--disabled': disabled,
'input--error': error,
'input--success': success,
'input--with-prefix': prefix,
'input--with-suffix': suffix || clearable || (type === 'password' && showPassword),
},
className
)
const handleChange = (event) => {
onChange?.(event)
}
const handleClear = () => {
const event = {
target: { value: '' },
type: 'change',
}
onChange?.(event)
onClear?.()
}
const togglePasswordVisibility = () => {
setInputType(inputType === 'password' ? 'text' : 'password')
setShowPasswordToggle(!showPasswordToggle)
}
const renderPrefix = () => {
if (prefix) {
return <span className="input__prefix">{prefix}</span>
}
return null
}
const renderSuffix = () => {
const suffixElements = []
if (clearable && props.value) {
suffixElements.push(
<button
key="clear"
type="button"
className="input__clear"
onClick={handleClear}
tabIndex={-1}
>
✕
</button>
)
}
if (type === 'password' && showPassword) {
suffixElements.push(
<button
key="password-toggle"
type="button"
className="input__password-toggle"
onClick={togglePasswordVisibility}
tabIndex={-1}
>
{showPasswordToggle ? '🙈' : '👁️'}
</button>
)
}
if (suffix) {
suffixElements.push(
<span key="suffix" className="input__suffix">
{suffix}
</span>
)
}
return suffixElements.length > 0 ? (
<div className="input__suffix-container">{suffixElements}</div>
) : null
}
return (
<div className="input-wrapper">
{label && (
<label className="input__label">
{label}
{props.required && <span className="input__required">*</span>}
</label>
)}
<div className="input__container">
{renderPrefix()}
<input
ref={ref}
type={inputType}
className={inputClasses}
disabled={disabled}
placeholder={placeholder}
maxLength={maxLength}
onChange={handleChange}
{...props}
/>
{renderSuffix()}
</div>
{(helperText || errorMessage) && (
<div
className={classNames('input__helper', {
'input__helper--error': error && errorMessage,
})}
>
{error && errorMessage ? errorMessage : helperText}
</div>
)}
{maxLength && (
<div className="input__counter">
{(props.value || '').length} / {maxLength}
</div>
)}
</div>
)
}
)
Input.displayName = 'Input'
Input.propTypes = {
type: PropTypes.oneOf(['text', 'email', 'password', 'number', 'tel', 'url']),
size: PropTypes.oneOf(['small', 'medium', 'large']),
variant: PropTypes.oneOf(['default', 'filled', 'outlined']),
disabled: PropTypes.bool,
error: PropTypes.bool,
success: PropTypes.bool,
placeholder: PropTypes.string,
label: PropTypes.string,
helperText: PropTypes.string,
errorMessage: PropTypes.string,
prefix: PropTypes.node,
suffix: PropTypes.node,
clearable: PropTypes.bool,
showPassword: PropTypes.bool,
maxLength: PropTypes.number,
className: PropTypes.string,
onChange: PropTypes.func,
onClear: PropTypes.func,
}
// src/components/Input/Input.stories.jsx
import { useState } from 'react'
import { Input } from './Input'
import { action } from '@storybook/addon-actions'
export default {
title: 'Components/Input',
component: Input,
parameters: {
docs: {
description: {
component: '通用输入框组件,支持多种类型和状态。',
},
},
},
argTypes: {
type: {
control: { type: 'select' },
options: ['text', 'email', 'password', 'number', 'tel', 'url'],
},
size: {
control: { type: 'select' },
options: ['small', 'medium', 'large'],
},
variant: {
control: { type: 'select' },
options: ['default', 'filled', 'outlined'],
},
onChange: { action: 'changed' },
onClear: { action: 'cleared' },
},
}
const Template = (args) => {
const [value, setValue] = useState(args.value || '')
return (
<Input
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
args.onChange?.(e)
}}
/>
)
}
export const Default = Template.bind({})
Default.args = {
placeholder: '请输入内容',
}
export const WithLabel = Template.bind({})
WithLabel.args = {
label: '用户名',
placeholder: '请输入用户名',
helperText: '用户名必须是唯一的',
}
export const Required = Template.bind({})
Required.args = {
label: '邮箱地址',
type: 'email',
placeholder: '请输入邮箱地址',
required: true,
}
export const WithError = Template.bind({})
WithError.args = {
label: '密码',
type: 'password',
error: true,
errorMessage: '密码长度至少8位',
value: '123',
}
export const Success = Template.bind({})
Success.args = {
label: '用户名',
success: true,
helperText: '用户名可用',
value: 'john_doe',
}
export const Disabled = Template.bind({})
Disabled.args = {
label: '禁用输入框',
disabled: true,
value: '不可编辑的内容',
}
export const Sizes = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<Input size="small" placeholder="小尺寸输入框" />
<Input size="medium" placeholder="中等尺寸输入框" />
<Input size="large" placeholder="大尺寸输入框" />
</div>
)
export const Variants = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<Input variant="default" placeholder="默认样式" />
<Input variant="filled" placeholder="填充样式" />
<Input variant="outlined" placeholder="轮廓样式" />
</div>
)
export const WithPrefixSuffix = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<Input prefix={<span>@</span>} placeholder="用户名" label="用户名" />
<Input suffix={<span>.com</span>} placeholder="域名" label="网站域名" />
<Input
prefix={<span>$</span>}
suffix={<span>USD</span>}
placeholder="0.00"
label="价格"
type="number"
/>
</div>
)
export const Password = Template.bind({})
Password.args = {
type: 'password',
label: '密码',
placeholder: '请输入密码',
showPassword: true,
}
export const Clearable = Template.bind({})
Clearable.args = {
label: '可清除输入框',
placeholder: '输入内容后可清除',
clearable: true,
value: '可以清除的内容',
}
export const WithMaxLength = Template.bind({})
WithMaxLength.args = {
label: '限制长度',
placeholder: '最多输入50个字符',
maxLength: 50,
helperText: '请简要描述',
}
// 表单组合示例
export const FormExample = () => {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
})
const handleChange = (field) => (event) => {
setFormData((prev) => ({
...prev,
[field]: event.target.value,
}))
}
return (
<div style={{ maxWidth: '400px', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<Input
label="用户名"
placeholder="请输入用户名"
value={formData.username}
onChange={handleChange('username')}
required
/>
<Input
type="email"
label="邮箱地址"
placeholder="请输入邮箱地址"
value={formData.email}
onChange={handleChange('email')}
required
/>
<Input
type="password"
label="密码"
placeholder="请输入密码"
value={formData.password}
onChange={handleChange('password')}
showPassword
required
/>
<Input
type="password"
label="确认密码"
placeholder="请再次输入密码"
value={formData.confirmPassword}
onChange={handleChange('confirmPassword')}
error={formData.confirmPassword && formData.password !== formData.confirmPassword}
errorMessage="密码不匹配"
required
/>
</div>
)
}
高级功能
文档驱动开发
// src/components/Card/Card.stories.mdx
import { Meta, Story, Canvas, ArgsTable, Description } from '@storybook/addon-docs'
import { Card } from './Card'
<Meta
title="Components/Card"
component={Card}
parameters={{
docs: {
description: {
component: `
卡片组件是一个灵活的容器,用于显示相关的内容和操作。它提供了一致的视觉样式和交互模式。
## 设计原则
- **一致性**: 保持统一的视觉风格
- **层次感**: 通过阴影和边框创建层次
- **可访问性**: 支持键盘导航和屏幕阅读器
## 使用场景
- 产品展示
- 用户信息卡片
- 内容摘要
- 操作面板
`
}
}
}}
/>
# Card 卡片组件
<Description of={Card} />
## 基础用法
<Canvas>
<Story name="Basic">
<Card>
<Card.Header>
<h3>卡片标题</h3>
</Card.Header>
<Card.Body>
<p>这是卡片的主要内容区域。</p>
</Card.Body>
<Card.Footer>
<button>操作按钮</button>
</Card.Footer>
</Card>
</Story>
</Canvas>
## 不同样式
<Canvas>
<Story name="Variants">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1rem' }}>
<Card variant="default">
<Card.Body>默认样式</Card.Body>
</Card>
<Card variant="outlined">
<Card.Body>轮廓样式</Card.Body>
</Card>
<Card variant="elevated">
<Card.Body>阴影样式</Card.Body>
</Card>
</div>
</Story>
</Canvas>
## 交互状态
<Canvas>
<Story name="Interactive">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
<Card hoverable>
<Card.Body>可悬停卡片</Card.Body>
</Card>
<Card clickable onClick={() => alert('卡片被点击')}>
<Card.Body>可点击卡片</Card.Body>
</Card>
<Card selectable selected>
<Card.Body>可选择卡片</Card.Body>
</Card>
</div>
</Story>
</Canvas>
## 复杂示例
<Canvas>
<Story name="Complex">
<Card variant="elevated" hoverable>
<Card.Header>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<img src="https://via.placeholder.com/40" alt="Avatar" style={{ borderRadius: '50%' }} />
<div>
<h4 style={{ margin: 0 }}>John Doe</h4>
<p style={{ margin: 0, color: '#666', fontSize: '0.875rem' }}>软件工程师</p>
</div>
</div>
</Card.Header>
<Card.Body>
<img src="https://via.placeholder.com/300x200" alt="Content" style={{ width: '100%', borderRadius: '4px' }} />
<p style={{ marginTop: '1rem' }}>
这是一个复杂的卡片示例,包含了头像、标题、图片和操作按钮。
</p>
</Card.Body>
<Card.Footer>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button>👍 点赞</button>
<button>💬 评论</button>
<button>📤 分享</button>
</div>
<span style={{ color: '#666', fontSize: '0.875rem' }}>2小时前</span>
</div>
</Card.Footer>
</Card>
</Story>
</Canvas>
## API 参考
<ArgsTable of={Card} />
## 子组件
### Card.Header
卡片头部组件,通常包含标题和操作按钮。
### Card.Body
卡片主体组件,包含主要内容。
### Card.Footer
卡片底部组件,通常包含操作按钮或元信息。
## 可访问性
- 使用语义化的 HTML 结构
- 支持键盘导航
- 提供适当的 ARIA 属性
- 确保足够的颜色对比度
## 最佳实践
1. **内容组织**: 保持卡片内容的逻辑性和相关性
2. **操作位置**: 将主要操作放在显眼位置
3. **响应式设计**: 确保在不同屏幕尺寸下的良好表现
4. **加载状态**: 为异步内容提供加载状态
设计系统集成
// .storybook/design-system.js
export const designTokens = {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
900: '#1e3a8a',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
500: '#6b7280',
900: '#111827',
},
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
},
typography: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['Fira Code', 'monospace'],
},
fontSize: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
},
borderRadius: {
none: '0',
sm: '0.125rem',
md: '0.375rem',
lg: '0.5rem',
full: '9999px',
},
shadows: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
},
}
// Design Tokens Story
// src/stories/DesignTokens.stories.jsx
export default {
title: 'Design System/Tokens',
parameters: {
docs: {
description: {
component: '设计系统的基础令牌,包括颜色、间距、字体等。',
},
},
},
}
export const Colors = () => (
<div>
<h2>颜色系统</h2>
{Object.entries(designTokens.colors).map(([colorName, colorScale]) => (
<div key={colorName} style={{ marginBottom: '2rem' }}>
<h3 style={{ textTransform: 'capitalize' }}>{colorName}</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{Object.entries(colorScale).map(([scale, color]) => (
<div
key={scale}
style={{
width: '100px',
height: '80px',
backgroundColor: color,
borderRadius: '4px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
color: parseInt(scale) > 500 ? 'white' : 'black',
fontSize: '0.875rem',
fontWeight: 'medium',
}}
>
<div>{scale}</div>
<div style={{ fontSize: '0.75rem', opacity: 0.8 }}>{color}</div>
</div>
))}
</div>
</div>
))}
</div>
)
export const Typography = () => (
<div>
<h2>字体系统</h2>
<div style={{ marginBottom: '2rem' }}>
<h3>字体大小</h3>
{Object.entries(designTokens.typography.fontSize).map(([size, value]) => (
<div key={size} style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: value, fontWeight: 'medium' }}>
{size} - {value}
</div>
<div style={{ fontSize: '0.875rem', color: '#666' }}>
The quick brown fox jumps over the lazy dog
</div>
</div>
))}
</div>
<div>
<h3>字体粗细</h3>
{Object.entries(designTokens.typography.fontWeight).map(([weight, value]) => (
<div key={weight} style={{ marginBottom: '0.5rem' }}>
<span style={{ fontWeight: value, fontSize: '1.125rem' }}>
{weight} ({value}) - The quick brown fox
</span>
</div>
))}
</div>
</div>
)
export const Spacing = () => (
<div>
<h2>间距系统</h2>
{Object.entries(designTokens.spacing).map(([size, value]) => (
<div
key={size}
style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem' }}
>
<div style={{ width: '60px', fontSize: '0.875rem', fontWeight: 'medium' }}>{size}</div>
<div
style={{
width: value,
height: '20px',
backgroundColor: '#3b82f6',
borderRadius: '2px',
}}
/>
<div style={{ fontSize: '0.875rem', color: '#666' }}>{value}</div>
</div>
))}
</div>
)
export const Shadows = () => (
<div>
<h2>阴影系统</h2>
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
{Object.entries(designTokens.shadows).map(([size, shadow]) => (
<div key={size} style={{ textAlign: 'center' }}>
<div
style={{
width: '120px',
height: '80px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: shadow,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '0.5rem',
}}
>
{size}
</div>
<div style={{ fontSize: '0.875rem', color: '#666' }}>{shadow}</div>
</div>
))}
</div>
</div>
)
可视化测试
// .storybook/test-runner.js
const { getStoryContext } = require('@storybook/test-runner')
module.exports = {
setup() {
// 全局设置
},
async preRender(page, context) {
// 渲染前的设置
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }])
},
async postRender(page, context) {
// 渲染后的测试
const storyContext = await getStoryContext(page, context)
// 可访问性测试
if (storyContext.parameters?.a11y?.disable !== true) {
await page.evaluate(() => {
return new Promise((resolve) => {
// 运行 axe-core 可访问性测试
if (window.axe) {
window.axe.run().then(resolve)
} else {
resolve()
}
})
})
}
// 视觉回归测试
if (storyContext.parameters?.screenshot !== false) {
await page.screenshot({
path: `screenshots/${context.id}.png`,
fullPage: true,
})
}
},
}
// 可视化测试配置
// .storybook/main.js 中添加
module.exports = {
addons: [
'@storybook/addon-essentials',
{
name: '@storybook/addon-storysource',
options: {
rule: {
test: [/\.stories\.(jsx?|tsx?)$/],
include: [path.resolve(__dirname, '../src')],
},
loaderOptions: {
prettierConfig: { printWidth: 80, singleQuote: false },
},
},
},
],
features: {
interactionsDebugger: true,
},
}
// 交互测试
// src/components/Button/Button.test.stories.jsx
import { expect } from '@storybook/jest'
import { within, userEvent } from '@storybook/testing-library'
import { Button } from './Button'
export default {
title: 'Tests/Button',
component: Button,
}
export const ClickInteraction = {
args: {
children: '点击测试',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('button')
// 测试按钮存在
await expect(button).toBeInTheDocument()
// 测试点击事件
await userEvent.click(button)
// 测试焦点状态
await expect(button).toHaveFocus()
},
}
export const DisabledState = {
args: {
children: '禁用按钮',
disabled: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('button')
// 测试禁用状态
await expect(button).toBeDisabled()
// 测试点击无效
await userEvent.click(button)
await expect(button).not.toHaveFocus()
},
}
export const LoadingState = {
args: {
children: '加载按钮',
loading: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('button')
// 测试加载状态
await expect(button).toBeDisabled()
await expect(canvas.getByText('加载按钮')).toBeInTheDocument()
},
}
// 键盘导航测试
export const KeyboardNavigation = {
args: {
children: '键盘导航',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('button')
// Tab 键聚焦
await userEvent.tab()
await expect(button).toHaveFocus()
// Enter 键激活
await userEvent.keyboard('{Enter}')
// Space 键激活
await userEvent.keyboard(' ')
},
}
部署和发布
构建和部署
// package.json - 构建脚本
{
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"storybook:test": "test-storybook",
"chromatic": "chromatic --exit-zero-on-changes",
"deploy-storybook": "storybook-to-ghpages"
},
"devDependencies": {
"@storybook/react-vite": "^7.0.0",
"@storybook/addon-essentials": "^7.0.0",
"@storybook/test-runner": "^0.10.0",
"chromatic": "^6.0.0",
"storybook-deployer": "^2.8.16"
}
}
// .github/workflows/storybook.yml - CI/CD 配置
name: Storybook
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Run Storybook tests
run: npm run storybook:test
- name: Visual regression testing
run: npm run chromatic
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build and deploy to GitHub Pages
run: npm run deploy-storybook
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# chromatic.config.json - Chromatic 配置
{
"projectToken": "your-project-token",
"buildScriptName": "build-storybook",
"exitZeroOnChanges": true,
"exitOnceUploaded": true,
"ignoreLastBuildOnBranch": "main"
}
总结
Storybook 为组件开发提供了完整的工具链:
- 组件隔离开发:独立开发和测试组件
- 文档自动生成:基于代码自动生成文档
- 可视化测试:支持视觉回归测试
- 设计系统:构建一致的设计语言
- 团队协作:提供共享的组件库
掌握 Storybook,你就能构建出高质量、可维护的 UI 组件库!
Storybook 是现代前端组件开发的标准工具,值得深入学习和实践。