01.05 Позитивные технологии
01.05 Позитивные технологии
01.05 Позитивные технологии

Как работает React-паттерн «Составной компонент» (compound component) и для чего он нужен

Разбираем типичные проблемы при разработке компонентов. Изучаем, какие архитектурные подходы вложены в паттерн. Реализуем паттерн на примере компонента Аккордеон и смотрим на плюсы и минусы подходов

232 открытий2К показов
Как работает React-паттерн «Составной компонент» (compound component) и для чего он нужен
Знаете про составной компонент?
Нет, мне достаточно обычный
Да, но не знаю, как применять
Да, и активно применяю

Создавая компоненты для дизайн-систем важно предусмотреть несколько вещей:

  • Как безболезненно масштабировать компонент?
  • Как быстро и просто доставлять изменения?
  • Как сделать компонент устойчивым к рефакторингу?

Для решения этих проблем разработчики прибегают к использованию различных паттернов проектирования. Один из таких — «Составной компонент». Сегодня рассмотрим его со всех сторон и дадим рекомендации по использованию.

Дисклеймер:

  • Примеры кода упрощены для наглядности и могут требовать доработки под конкретные задачи.
  • Подходы в статье не являются универсальными, правильными или неправильными. Каждый из них решает конкретные задачи и проблемы, выбирайте подход согласно требованиям в вашем проекте.
  • Автор под понятием дизайн-система подразумевает такие вещи, как UI-кит и библиотека компонентов .

Какие проблемы помогает решить «Составной компонент»?

При создании переиспользуемых компонентов разработчики вынуждены решать одни и те же проблемы:

  • Как сделать строение компонента достаточно гибким, чтобы оно покрывало как можно больше вариантов использования? Если об этом не подумать — вокруг появятся вспомогательные компоненты и утилитарные функции.
  • Как сделать API компонента устойчивым к изменениям? Если об этом не подумать props начинают обрастать пометками deprecated, появляются проблемы с обратной совместимостью старых и новых props, а время рефакторинга и выхода новой версии неизбежно приближается.

Важно помнить: даже незначительные изменения в компоненте запускают дорогой и долгий релизный цикл, который обычно включает в себя:

  • Создание новых тестов и обновление старых (снапшоты, скриншоты, e2e),
  • Обновление документации или сторибука,
  • Проверка изменений в реальном проекте,
  • Запуск CI-CD пайплайнов для доставки изменений.

…и многие другие прелести продуктовой разработки.

Все эти проблемы стремится решить «Составной компонент»

Из чего состоит «Составной компонент»?

Компонент становится «составным» при наличии следующих свойств:

Есть иерархия:

  • Есть главный и дочерние компоненты,
  • Дочерние компоненты зависят от главного.

Есть общая логика:

  • У компонентов есть общее внутреннее состояние, например: открыть/закрыть компонент, выбрать вид отображения и т.п.
  • Главный компонент содержит логику управления дочерними компонентами, например: выбрать активный элемент, раскрыть элементы, добавить элемент в список и т.п.

Нет противоречий с основными принципами SOLID:

  • Принцип единственной ответственности: каждый дочерний компонент отвечает только за свою часть логики,
  • Инкапсуляция состояния: состояние управляется родительским компонентом, но не требует явной передачи через props,
  • Инверсии контроля: пользователь свободно комбинирует дочерние компоненты, меняет их порядок, добавляет свои элементы без внесения изменений в исходный код компонента.

Как создать свой «Составной компонент»?

Для примера сделаем компонент Аккордеона.

Но, в начале сделаем классический React-компонент через props — так мы лучше поймем преимущества одного подхода и недостатки другого:

Реализация

			const Accordion = ({ items }) => {
  const [active, setActive] = useState(0)
 
  const onClick = 
    (index: number) => setActive(active === index ? -1 : index)
  
  return <div>
    {items.map(({ title, content }) => 
      <div>
        <div onClick={() => onClick(index)}>{title}</div>
        {active === index && <div>{content}</div>
      </div>
    )}
  </div>
}
		

Применение

			const items = [
  { title: "Dwight", content: "Assistant to the Regional Manager" },
  { title: "Jim", content: "Sales Representative" },
  { title: "Pam", content: "Receptionist" },
]

const App = () => <Accordion items={items} />
		

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

Создаём «Составной компонент»

Теперь сделаем компонент на основе паттерна, подробно разобрав его архитектуру. Начнём с декомпозиции компонента на составные части:

Основной компонент

Назначение:

  • Управлять своим состоянием,
  • Выступать контроллером для дочерних компонентов, обеспечивая их согласованную работу.

Во избежание props-дриллинг для передачи состояния от основного компонента к дочерним будем использовать React Context API:

			const Ctx = createContext()

const Accordion = (props) => {

  const { children } = props
  
  // Управляем состоянием
  
  const [active, setActive] = useState(0)
  
  const toggle = (index) => { 
    setActive(index === index ? -1 : index)
  }

  const ctx: AccordionContext = {
    active,
    toggle
  }
  
  // Передаем контекст
  
  return (
     <Ctx.Provider value={ctx}>
       {Children.map(children, (child, index) => 
          cloneElement(child, { index })
       )}
     </Ctx.Provider>
  )
}
		

Дочерние компоненты

Назначение:

  • Принимать логику, состояние и методы из главного компонента через React Context API
  • Реагировать на изменение собственных props

Функциональная обёртка

  • Помогает дочерним компонентам быть неконтролируемыми
			Accordion.Item = ({ index, children }) => {
  return Children.map(children, (child) => 
    cloneElement(child, { index })
  ) 
}
		

Компонент заголовка

  • Отображает заголовок и реагирует на действия пользователя
			Accordion.Header = ({ index, children }) => { 
  const { active, toggle } = useContext<AccordionContext>(Ctx) 

  return <div onClick={() => toggle(index)}> 
    {children} 
  </div> 
}

		

Компонент контента

  • Отображает контент и реагирует на изменение состояния в ответ на действия пользователя
			Accordion.Content = ({ index, children }) => {
  const { active } = useContext<AccordionContext>(Ctx)

  const isOpen = active === index
  
  return isOpen && <div>{children}</div>
}
		

Теперь сложим все составные части и попробуем собрать полноценный компонент:

			const items = [
  { title: "Dwight", content: "Assistant to the Regional Manager" },
  { title: "Jim", content: "Sales Representative" },
  { title: "Pam", content: "Receptionist" },
]

<Accordion>
  {items.map(({ title, content }) => (
    <Accordion.Item>
      <Accordion.Header>{title}</Accordion.Header>
      <Accordion.Content>{content}</Accordion.Content>
    </Item>
  ))}
</Accordion>

		

Что произойдет при внесении изменений в компонент?

Теперь внесём изменения в структуру компонента, добавив новые элементы. Далее сравним сложность внесения изменений в обоих подходах:

Добавим кнопку «Закрыть»

Как сработает обычный компонент

			const Accordion = ({ items, onClose }) => {
  const [active, setActive] = useState(0)
 
  const onClick = (index) => setActive(active === index ? -1 : index)
  
  return <div>
    {items.map(({ title, content }) => 
      <div>
        <div onClick={() => onClick(index)}>{title}</div>
        <div>{active === key && content}</div>
        <div onClick={() => toggle(-1)} onClose={onClose}>Закрыть</div>
      </div>
    )}
  </div>
}
		

Плюсы:

  • Подход очевиден, прост и интуитивно понятен,
  • Положение кнопки «Закрыть» определяется один раз.

Минусы:

  • Местоположение компонента жестко закреплено,
  • Изменить лэйбл и навесить дополнительное событие можно только через создание новых props в основном компоненте.

Как сработает составной компонент

			Accordion.Close = function ({ children, ...rest }) {
  const { toggle } = useContext<AccordionContext>(Ctx)

  return (
    <div onClick={() => toggle(-1)} {...rest}>
      {children}
    </div>
  )
}

<Accordion>
  {items.map(({ title, content }) => (
    <Accordion.Item>
      <Accordion.Header>
        {title} 
        <Accordion.Close onFocus={() => console.log("prepare to close")}>×<Accordion.Close/>
      </Header>
      <Accordion.Content>{content}</Accordion.Content>
    </Accordion.Item>
  ))}
</Accordion>

		

Плюсы:

  • Компонент может свободно перемещается внутри Accordion.Item,
  • На компонент можно навесить любой хэндлер без изменения props.

Минусы:

  • Свобода создания структуры компонента влечёт необходимость полностью описывать все внутренние элементы, вместо обычной передачи свойств.

Добавим компонент «Разделитель»

Теперь попробуем изменить верстку, добавив разделитель. Также поменяем местами заголовок и контент:

Обычный компонент

			const Accordion = ({ items }) => {
  const [active, setActive] = useState(0)
 
  const onClick = (index) => setActive(active === index ? -1 : index)
  
  return <div>
    {items.map(({ title, content }) => 
      <div>
        <div>{active === index && content}</div>
        <hr />
        <div onClick={() => onClick(key)}>{title}</div>
      </div>
    )}
  </div>
}
		

Плюсы:

  • Положение разделителя определяется один раз. 

Минусы:

  • Изменить расположение разделителя можно только через изменение кода основного компонента.
  • Добавить новые свойства компоненту можно только через props основного компонента.

Составной компонент

			<Accordion>
  {items.map(({ title, content }) => (
    <Accordion.Item>
      <Accordion.Content>{content}</Accordion.Content>
      <hr />
      <Accordion.Header>{title}</Accordion.Header>
    </Item>
  ))}
</Accordion>
		

Плюсы:

  • Положение элемента можно свободно перемещать без изменений в основном компоненте

О каких особенностях паттерна важно знать?

Тришейкинг

Как только компонент становится свойством объекта — сборщик перестает считать неиспользуемые составные компоненты «мертвым кодом». Следовательно, такие компоненты будут всегда попадать в конечный бандл.

Next.JS

Использование подхода в среде Next.JS приводит к ошибке. Ошибка связана с разделением компонентов внутри фреймворка на клиентские и серверные. Проблема решается указанием директивы 'use client' в начале файла там, где используются составные компоненты.

Выводы

  • Паттерн приносит наибольшую пользу при разработке компонентов для дизайн-систем, где обновление компонента может быть дорогим, долгим и опасным. Использование паттерна в обычном приложении может стать излишним усложнением.
  • Строение компонента позволяет свободно комбинировать дочерние компоненты, изменять их порядок и добавлять новые элементы без модификации родительского кода.
  • Состояние и логика управления инкапсулированы в главном компоненте, это снижает сложность конечного кода, упрощает тестирование и повышает надежность компонента.
Если React-компонент внутри дизайн-системы обладает сложной логикой, управляет внутренним состоянием, имеет зависимые дочерние компоненты, требует гибкости и кастомизации, то он — хороший кандидат для реализации с использованием паттерна «Составной компонент». Примеры таких компонентов: RadioGroup, TagGroup, Select, Dropdown, Form, Tab.
Как работает React-паттерн «Составной компонент» (compound component) и для чего он нужен 1

Кстати, если хотите узнать больше про React, недавно мы выпустили подборку наших материалов. Скорее смотрите!

Следите за новыми постами
Следите за новыми постами по любимым темам
232 открытий2К показов