← React/Storybook: разработка компонентов в изоляции#292 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Storybook: разработка компонентов в изоляции

Что такое Storybook

Storybook — это инструмент для разработки UI-компонентов в изоляции от приложения. Вместо того чтобы запускать всё приложение для тестирования одной кнопки, вы открываете Storybook и видите все варианты этой кнопки на одной странице.

Зачем нужен Storybook:

  • Разрабатывать компоненты без зависимости от роутинга и стейта приложения
  • Документировать все состояния компонента (loading, error, empty, success)
  • Тестировать edge cases (длинный текст, отсутствие данных)
  • Дизайнеры и менеджеры могут смотреть компоненты без запуска приложения
  • Автоматические тесты на регрессию внешнего вида (Chromatic)
  • Stories: основная концепция

    Story — это функция, которая возвращает компонент с определёнными пропсами. Каждая история = один конкретный случай использования:

    // Button.stories.tsx
    import type { Meta, StoryObj } from '@storybook/react'
    import { Button } from './Button'
    
    // Мета: настройки для всех историй компонента
    const meta: Meta<typeof Button> = {
      title: 'UI/Button',       // путь в сайдбаре Storybook
      component: Button,
      tags: ['autodocs'],       // автогенерация документации
      argTypes: {
        variant: {
          control: 'select',
          options: ['primary', 'secondary', 'danger'],
        },
        size: { control: 'radio', options: ['sm', 'md', 'lg'] },
        disabled: { control: 'boolean' },
        onClick: { action: 'clicked' },  // логирует клики
      },
    }
    
    export default meta
    type Story = StoryObj<typeof meta>
    
    // Истории — именованные экспорты
    export const Primary: Story = {
      args: {
        variant: 'primary',
        size: 'md',
        children: 'Нажми меня',
      },
    }
    
    export const Danger: Story = {
      args: {
        variant: 'danger',
        children: 'Удалить',
      },
    }
    
    export const Disabled: Story = {
      args: {
        disabled: true,
        children: 'Недоступно',
      },
    }
    
    export const LongText: Story = {
      args: {
        children: 'Очень длинный текст кнопки который может не влезть',
      },
    }

    Args и Controls

    Args — это пропсы истории. В UI Storybook они становятся Controls — интерактивными элементами управления:

    // Можно задавать args на разных уровнях:
    
    // 1. Глобальные (в preview.ts)
    export const globalArgs = { theme: 'light' }
    
    // 2. На уровне компонента (в meta)
    const meta = {
      args: { size: 'md' }  // дефолтные args для всех историй
    }
    
    // 3. На уровне истории
    export const Large: Story = {
      args: { size: 'lg' }  // переопределяет мета args
    }

    Decorators

    Decorators оборачивают истории в дополнительный контекст (провайдеры, стили):

    // В meta — для всех историй компонента
    const meta = {
      decorators: [
        (Story) => (
          <ThemeProvider theme="dark">
            <div style={{ padding: 20 }}>
              <Story />
            </div>
          </ThemeProvider>
        ),
      ],
    }
    
    // В preview.ts — глобально для всех историй
    export const decorators = [
      (Story) => (
        <ReduxProvider store={store}>
          <RouterProvider>
            <Story />
          </RouterProvider>
        </ReduxProvider>
      ),
    ]

    Addons

    Storybook расширяется через аддоны:

    | Аддон | Что даёт |

    |---|---|

    | @storybook/addon-essentials | Controls, Actions, Docs, Viewport |

    | @storybook/addon-a11y | Проверка доступности |

    | @storybook/addon-interactions | Тесты взаимодействий |

    | chromatic | Визуальное тестирование |

    | @storybook/addon-themes | Переключение тем |

    Настройка (.storybook/)

    // .storybook/main.ts
    const config = {
      stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
      addons: [
        '@storybook/addon-essentials',
        '@storybook/addon-a11y',
      ],
      framework: {
        name: '@storybook/react-vite',
        options: {},
      },
    }
    
    // .storybook/preview.ts — глобальные настройки
    export const parameters = {
      actions: { argTypesRegex: '^on[A-Z].*' },  // авто-логирование onXxx пропсов
      controls: {
        matchers: {
          color: /(background|color)$/i,
          date: /Date$/i,
        },
      },
    }

    Component-Driven Development (CDD)

    Философия разработки снизу вверх:

    Атомы (Button, Input, Icon)
      ↓
    Молекулы (SearchBar = Input + Button)
      ↓
    Организмы (Header = Logo + Nav + SearchBar)
      ↓
    Шаблоны (PageLayout = Header + Content + Footer)
      ↓
    Страницы (HomePage)

    Storybook позволяет разрабатывать каждый уровень изолированно, а потом собирать из готовых кирпичиков.

    Примеры

    Симуляция реестра Storybook на ванильном JS: регистрация историй, работа с args, рендеринг нужных вариантов

    // Симулируем работу Storybook: регистрацию историй,
    // хранение args и рендеринг компонентов с разными пропсами.
    
    // --- Реестр историй ---
    
    class StorybookRegistry {
      constructor() {
        this.registry = new Map()  // component -> { meta, stories }
      }
    
      // Регистрация компонента и его историй
      register(meta, stories) {
        const componentName = meta.title
        this.registry.set(componentName, {
          meta,
          stories: new Map(Object.entries(stories))
        })
        console.log('Зарегистрирован компонент:', componentName)
        console.log('  Историй:', Object.keys(stories).length)
        return this
      }
    
      // Получить все истории компонента
      getStories(componentTitle) {
        const entry = this.registry.get(componentTitle)
        if (!entry) return []
        return Array.from(entry.stories.entries()).map(([name, story]) => ({
          name,
          // Мерджим args: мета -> история (история переопределяет)
          args: { ...entry.meta.args, ...story.args }
        }))
      }
    
      // Рендеринг истории — вызов компонента с нужными args
      render(componentTitle, storyName) {
        const entry = this.registry.get(componentTitle)
        if (!entry) throw new Error('Компонент не найден: ' + componentTitle)
    
        const story = entry.stories.get(storyName)
        if (!story) throw new Error('История не найдена: ' + storyName)
    
        const mergedArgs = { ...entry.meta.args, ...story.args }
        const result = entry.meta.component(mergedArgs)
    
        console.log('Рендер [' + componentTitle + ' / ' + storyName + ']:')
        console.log('  Args:', JSON.stringify(mergedArgs))
        console.log('  Результат:', result)
        return result
      }
    
      // Показать документацию компонента
      docs(componentTitle) {
        const entry = this.registry.get(componentTitle)
        if (!entry) return
    
        console.log('
    === Документация:', componentTitle, '===')
        console.log('Компонент:', entry.meta.component.name)
        if (entry.meta.argTypes) {
          console.log('Пропсы:')
          for (const [prop, config] of Object.entries(entry.meta.argTypes)) {
            console.log('  -', prop + ':', config.description || config.control || 'any')
          }
        }
        console.log('Истории:')
        for (const [name, story] of entry.stories) {
          console.log('  -', name, story.args ? JSON.stringify(story.args) : '(нет args)')
        }
      }
    }
    
    // --- Пример компонента ---
    
    function Button({ variant = 'primary', size = 'md', children = 'Кнопка', disabled = false }) {
      const variantStyle = { primary: 'синяя', secondary: 'серая', danger: 'красная' }
      const sizeStyle = { sm: 'маленькая', md: 'средняя', lg: 'большая' }
      return '[КНОПКА: ' + children + ' | ' + variantStyle[variant] + ' | ' + sizeStyle[size] + (disabled ? ' | disabled' : '') + ']'
    }
    
    // --- Регистрация историй ---
    
    const storybook = new StorybookRegistry()
    
    storybook.register(
      {
        title: 'UI/Button',
        component: Button,
        args: { size: 'md', variant: 'primary' },  // дефолтные args
        argTypes: {
          variant: { control: 'select', description: 'Вариант кнопки' },
          size: { control: 'radio', description: 'Размер кнопки' },
          disabled: { control: 'boolean', description: 'Отключена ли кнопка' },
        },
      },
      {
        Default: { args: { children: 'Нажми меня' } },
        Danger:  { args: { variant: 'danger', children: 'Удалить' } },
        Large:   { args: { size: 'lg', children: 'Большая кнопка' } },
        Disabled: { args: { disabled: true, children: 'Недоступно' } },
      }
    )
    
    // --- Использование ---
    
    console.log('=== Список историй ===')
    storybook.getStories('UI/Button').forEach(s => {
      console.log(' -', s.name, '| args:', JSON.stringify(s.args))
    })
    
    console.log('
    === Рендеринг историй ===')
    storybook.render('UI/Button', 'Default')
    storybook.render('UI/Button', 'Danger')
    storybook.render('UI/Button', 'Disabled')
    
    storybook.docs('UI/Button')

    Storybook: разработка компонентов в изоляции

    Что такое Storybook

    Storybook — это инструмент для разработки UI-компонентов в изоляции от приложения. Вместо того чтобы запускать всё приложение для тестирования одной кнопки, вы открываете Storybook и видите все варианты этой кнопки на одной странице.

    Зачем нужен Storybook:

  • Разрабатывать компоненты без зависимости от роутинга и стейта приложения
  • Документировать все состояния компонента (loading, error, empty, success)
  • Тестировать edge cases (длинный текст, отсутствие данных)
  • Дизайнеры и менеджеры могут смотреть компоненты без запуска приложения
  • Автоматические тесты на регрессию внешнего вида (Chromatic)
  • Stories: основная концепция

    Story — это функция, которая возвращает компонент с определёнными пропсами. Каждая история = один конкретный случай использования:

    // Button.stories.tsx
    import type { Meta, StoryObj } from '@storybook/react'
    import { Button } from './Button'
    
    // Мета: настройки для всех историй компонента
    const meta: Meta<typeof Button> = {
      title: 'UI/Button',       // путь в сайдбаре Storybook
      component: Button,
      tags: ['autodocs'],       // автогенерация документации
      argTypes: {
        variant: {
          control: 'select',
          options: ['primary', 'secondary', 'danger'],
        },
        size: { control: 'radio', options: ['sm', 'md', 'lg'] },
        disabled: { control: 'boolean' },
        onClick: { action: 'clicked' },  // логирует клики
      },
    }
    
    export default meta
    type Story = StoryObj<typeof meta>
    
    // Истории — именованные экспорты
    export const Primary: Story = {
      args: {
        variant: 'primary',
        size: 'md',
        children: 'Нажми меня',
      },
    }
    
    export const Danger: Story = {
      args: {
        variant: 'danger',
        children: 'Удалить',
      },
    }
    
    export const Disabled: Story = {
      args: {
        disabled: true,
        children: 'Недоступно',
      },
    }
    
    export const LongText: Story = {
      args: {
        children: 'Очень длинный текст кнопки который может не влезть',
      },
    }

    Args и Controls

    Args — это пропсы истории. В UI Storybook они становятся Controls — интерактивными элементами управления:

    // Можно задавать args на разных уровнях:
    
    // 1. Глобальные (в preview.ts)
    export const globalArgs = { theme: 'light' }
    
    // 2. На уровне компонента (в meta)
    const meta = {
      args: { size: 'md' }  // дефолтные args для всех историй
    }
    
    // 3. На уровне истории
    export const Large: Story = {
      args: { size: 'lg' }  // переопределяет мета args
    }

    Decorators

    Decorators оборачивают истории в дополнительный контекст (провайдеры, стили):

    // В meta — для всех историй компонента
    const meta = {
      decorators: [
        (Story) => (
          <ThemeProvider theme="dark">
            <div style={{ padding: 20 }}>
              <Story />
            </div>
          </ThemeProvider>
        ),
      ],
    }
    
    // В preview.ts — глобально для всех историй
    export const decorators = [
      (Story) => (
        <ReduxProvider store={store}>
          <RouterProvider>
            <Story />
          </RouterProvider>
        </ReduxProvider>
      ),
    ]

    Addons

    Storybook расширяется через аддоны:

    | Аддон | Что даёт |

    |---|---|

    | @storybook/addon-essentials | Controls, Actions, Docs, Viewport |

    | @storybook/addon-a11y | Проверка доступности |

    | @storybook/addon-interactions | Тесты взаимодействий |

    | chromatic | Визуальное тестирование |

    | @storybook/addon-themes | Переключение тем |

    Настройка (.storybook/)

    // .storybook/main.ts
    const config = {
      stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
      addons: [
        '@storybook/addon-essentials',
        '@storybook/addon-a11y',
      ],
      framework: {
        name: '@storybook/react-vite',
        options: {},
      },
    }
    
    // .storybook/preview.ts — глобальные настройки
    export const parameters = {
      actions: { argTypesRegex: '^on[A-Z].*' },  // авто-логирование onXxx пропсов
      controls: {
        matchers: {
          color: /(background|color)$/i,
          date: /Date$/i,
        },
      },
    }

    Component-Driven Development (CDD)

    Философия разработки снизу вверх:

    Атомы (Button, Input, Icon)
      ↓
    Молекулы (SearchBar = Input + Button)
      ↓
    Организмы (Header = Logo + Nav + SearchBar)
      ↓
    Шаблоны (PageLayout = Header + Content + Footer)
      ↓
    Страницы (HomePage)

    Storybook позволяет разрабатывать каждый уровень изолированно, а потом собирать из готовых кирпичиков.

    Примеры

    Симуляция реестра Storybook на ванильном JS: регистрация историй, работа с args, рендеринг нужных вариантов

    // Симулируем работу Storybook: регистрацию историй,
    // хранение args и рендеринг компонентов с разными пропсами.
    
    // --- Реестр историй ---
    
    class StorybookRegistry {
      constructor() {
        this.registry = new Map()  // component -> { meta, stories }
      }
    
      // Регистрация компонента и его историй
      register(meta, stories) {
        const componentName = meta.title
        this.registry.set(componentName, {
          meta,
          stories: new Map(Object.entries(stories))
        })
        console.log('Зарегистрирован компонент:', componentName)
        console.log('  Историй:', Object.keys(stories).length)
        return this
      }
    
      // Получить все истории компонента
      getStories(componentTitle) {
        const entry = this.registry.get(componentTitle)
        if (!entry) return []
        return Array.from(entry.stories.entries()).map(([name, story]) => ({
          name,
          // Мерджим args: мета -> история (история переопределяет)
          args: { ...entry.meta.args, ...story.args }
        }))
      }
    
      // Рендеринг истории — вызов компонента с нужными args
      render(componentTitle, storyName) {
        const entry = this.registry.get(componentTitle)
        if (!entry) throw new Error('Компонент не найден: ' + componentTitle)
    
        const story = entry.stories.get(storyName)
        if (!story) throw new Error('История не найдена: ' + storyName)
    
        const mergedArgs = { ...entry.meta.args, ...story.args }
        const result = entry.meta.component(mergedArgs)
    
        console.log('Рендер [' + componentTitle + ' / ' + storyName + ']:')
        console.log('  Args:', JSON.stringify(mergedArgs))
        console.log('  Результат:', result)
        return result
      }
    
      // Показать документацию компонента
      docs(componentTitle) {
        const entry = this.registry.get(componentTitle)
        if (!entry) return
    
        console.log('
    === Документация:', componentTitle, '===')
        console.log('Компонент:', entry.meta.component.name)
        if (entry.meta.argTypes) {
          console.log('Пропсы:')
          for (const [prop, config] of Object.entries(entry.meta.argTypes)) {
            console.log('  -', prop + ':', config.description || config.control || 'any')
          }
        }
        console.log('Истории:')
        for (const [name, story] of entry.stories) {
          console.log('  -', name, story.args ? JSON.stringify(story.args) : '(нет args)')
        }
      }
    }
    
    // --- Пример компонента ---
    
    function Button({ variant = 'primary', size = 'md', children = 'Кнопка', disabled = false }) {
      const variantStyle = { primary: 'синяя', secondary: 'серая', danger: 'красная' }
      const sizeStyle = { sm: 'маленькая', md: 'средняя', lg: 'большая' }
      return '[КНОПКА: ' + children + ' | ' + variantStyle[variant] + ' | ' + sizeStyle[size] + (disabled ? ' | disabled' : '') + ']'
    }
    
    // --- Регистрация историй ---
    
    const storybook = new StorybookRegistry()
    
    storybook.register(
      {
        title: 'UI/Button',
        component: Button,
        args: { size: 'md', variant: 'primary' },  // дефолтные args
        argTypes: {
          variant: { control: 'select', description: 'Вариант кнопки' },
          size: { control: 'radio', description: 'Размер кнопки' },
          disabled: { control: 'boolean', description: 'Отключена ли кнопка' },
        },
      },
      {
        Default: { args: { children: 'Нажми меня' } },
        Danger:  { args: { variant: 'danger', children: 'Удалить' } },
        Large:   { args: { size: 'lg', children: 'Большая кнопка' } },
        Disabled: { args: { disabled: true, children: 'Недоступно' } },
      }
    )
    
    // --- Использование ---
    
    console.log('=== Список историй ===')
    storybook.getStories('UI/Button').forEach(s => {
      console.log(' -', s.name, '| args:', JSON.stringify(s.args))
    })
    
    console.log('
    === Рендеринг историй ===')
    storybook.render('UI/Button', 'Default')
    storybook.render('UI/Button', 'Danger')
    storybook.render('UI/Button', 'Disabled')
    
    storybook.docs('UI/Button')

    Задание

    Создай компонент Button с вариантами (variant: primary/secondary/danger) и размерами (size: sm/md/lg). Затем создай компонент StorybookDemo, который отображает все комбинации кнопки как каталог вариантов — подобно тому, как это делает Storybook.

    Подсказка

    В Button используй variants[variant] и sizes[size] для применения стилей. В StorybookDemo передавай variant={variant} и size={size} из циклов map. Для disabled состояния передай disabled={true}.

    Загружаем среду выполнения...
    Загружаем AI-помощника...