发布于

Storybook 组件开发指南:构建可维护的 UI 组件库

作者

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 为组件开发提供了完整的工具链:

  1. 组件隔离开发:独立开发和测试组件
  2. 文档自动生成:基于代码自动生成文档
  3. 可视化测试:支持视觉回归测试
  4. 设计系统:构建一致的设计语言
  5. 团队协作:提供共享的组件库

掌握 Storybook,你就能构建出高质量、可维护的 UI 组件库!


Storybook 是现代前端组件开发的标准工具,值得深入学习和实践。