В последнее время проектирую и строю UI-кит. Заметил, что тут и там использую паттерн Compound Components. Это способ делать компоненты, устойчивые к изменениям в дизайне; их легко расширять и использовать.
Расскажу, как работает паттерн:
Какую задачу решает · Как устроен технически · В чём преимущества для разработчика · Как оптимизировать бандл · Примеры случаев, когда стоит использовать паттерн
Какую задачу решает паттерн
Паттерн помогает организовать код, когда фрагмент вёрстки выглядит для пользователя как одно целое и имеет за собой общую логику, но его отдельные элементы в зависимости от окружения меняют ХТМЛ-атрибуты, стили и порядок расположения в вёрстке.
Пример такого фрагмента — поле формы:
Эл. почта
<FormField.Input /> <FormField.Message /> Не будет опубликованаMessage, как и Label, может отсутствовать, а может стоять сверху или снизу; на Message можно накинуть ЦСС-класс; наконец, на месте каждого из компонентов может стоять произвольный ХТМЛ-элемент.
При компаунд-подходе вёрстку композируют из атомарных элементов, которые нацелены на решение общей задачи и используются только в пределах родительского компонента:
export function CustomFormField() {
return (
<FormField.Root className={styles.field} error="">
<FormField.Label>Эл. почта</FormField.Label>
<FormField.Input
className={styles.input}
type="email"
name="email"
autoComplete="email"
defaultValue={email}
erasable
/>
<FormField.Message />
<FormField.Error />
</FormField.Root>
)
}Как паттерн устроен технически
Компаунды объединены общим состоянием, и их запрещено использовать по отдельности. Это достигается через контекст в Реакте или через DI во Вью.
Вот пример на Реакте:
const FormFieldContext = createContext(null)
// Если компонент используется вне родительского
// компонента, выбрасывает исключение:
export function useFormFieldContext() {
const context = use(FormFieldContext)
if (!context) {
throw new Error(
'FormField.* components must be used within FormField.Root'
)
}
return context
}
export const FormField = {
// Корневой компонент предоставляет доступ к общему состоянию:
Root: ({ id, error, className, children }) => {
const generatedId = useId()
return (
<FormFieldContext.Provider value={{ id: id || generatedId, error }}>
<div className={cn(styles.field, className)}>{children}</div>
</FormFieldContext.Provider>
)
},
Label: ({ children }) => {
const ctx = useFormFieldContext(FormFieldContext)
return <label htmlFor={ctx.id}>{children}</label>
},
Input: forwardRef(
({ as: Component = 'input', className, ...props }, ref) => {
const ctx = useFormFieldContext(FormFieldContext)
return (
<Component
id={ctx.id}
ref={ref}
className={cn(styles.input, className, {
[styles.inputError]: hasError,
})}
aria-invalid={!!ctx.error}
aria-describedby={
!!ctx.error ? `${ctx.id}-error` : undefined
}
{...props}
/>
)
}
),
Message: () => {
const ctx = useFormFieldContext(FormFieldContext)
return ctx?.error ? (
<div id={`${ctx.id}-error`} role="alert">
{ctx.error}
</div>
) : null
},
}Общего состояния может и не быть. Тогда компоненты объединены только стилями, учитывающими их близкое расположение в вёрстке, и контекст не нужен. Это тоже удобный сценарий использования:
export const InfoSurface = {
Root: ({ className, ...props }: HTMLAttributes<HTMLElement>) => (
<aside className={cn(styles.aside, className)} {...props} />
),
Content: ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(styles.backdrop, styles.content, className)}
{...props}
/>
),
Paragraph: ({
className,
...props
}: HTMLAttributes<HTMLParagraphElement>) => (
<p className={cn(styles.paragraph, className)} {...props} />
),
Action: ({
className,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a
className={cn(styles.backdrop, styles.action, className)}
{...props}
/>
),
}В чём преимущества для разработчика
Гибкий АПИ. Вы сообщаете разработчику, который будет строить интерфейс на основе компаундов: «Эти компоненты связаны. Они выполняют общую задачу. Их можно вкладывать друг в друга в таком-то порядке».
Разработчик получает полный контроль над вёрсткой, свободно накручивает стили и логику на любой из вложенных компонентов. Поэтому компаунды используют в компонентных библиотеках, таких как Ark UI и Mantine.
Инкапсуляция. В каждый из компаунд-компонентов можно внести функционал, скрытый для конечного пользователя. Например — автоматизировать назначение ARIA-атрибутов, потому что в повседневной разработке про них забывают. Выше мы разбирали пример работы с семантической вёрсткой в компоненте FormField:
<Label htmlFor={ctx.id} />
<Input
id={ctx.id}
aria-invalid={!!ctx.error}
aria-describedby={!!ctx.error ? `${ctx.id}-error` : undefined}
/>Устойчивость к перестановкам в дизайне. Известная задача: в одном месте нужна карточка без кнопки, во втором с кнопкой, в третьем — при наведении на кнопку нужен тултип, в четвёртом — кнопки меняются местами. Если на макетах часто меняется вид стандартных компонентов, компаунды избавляют от необходимости прописывать условный рендеринг для каждого из возможных состояний.
Сравните слоты:
function Composer({
renderHeader,
renderFooter,
renderActions,
showAttachments,
showFormatting,
showEmojis,
}: Props) {
return (
<form>
{renderHeader?.()}
<Input />
{showAttachments && <Attachments />}
{renderFooter ? (
renderFooter()
) : (
<Footer>
{/* Что, если нужно поменять компоненты местами? */}
{showFormatting && <Formatting />}
{showEmojis && <Emojis />}
{renderActions?.()}
</Footer>
)}
</form>
)
}И компаунды:
<Composer.Provider state={state} actions={actions} meta={meta}>
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Emojis />
<Composer.Formatting />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</Composer.Provider>Слоты — хороший инструмент, когда нужно зафиксировать компоненты в конкретных частях макета. Например, слоты подходят для лэйаутов, когда одни компоненты однозначно идут в левую, другие — в правую панель. Но компаунды в большинстве случаев дают большую гибкость.
Снижение ментальной нагрузки. Когда разработчик управляет состоянием через пропы, ему нужно следить за тем, как состояние родителя влияет на дочерние элементы. Если состояние передано в контекст, разработчик думает только о логике отдельного элемента и не сорит пропами.
Сравните:
function Composer({
onSubmit,
isThread,
channelId,
isDMThread,
dmId,
isEditing,
isForwarding,
}: Props) {
return (
<form>
<Header />
<Input />
{isDMThread ? (
<AlsoSendToDMField id={dmId} />
) : isThread ? (
<AlsoSendToChannelField id={channelId} />
) : null}
{isEditing ? (
<EditActions />
) : isForwarding ? (
<ForwardActions />
) : (
<DefaultActions />
)}
<Footer onSubmit={onSubmit} />
</form>
)
}function ChannelComposer() {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Attachments />
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}
function ThreadComposer({ channelId }: { channelId: string }) {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<AlsoSendToChannelField id={channelId} />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}Все преимущества компаундов вытекают из декларативности. В случае слотов разработчик, используя пропы, говорит компоненту, как ему выглядеть. В случае компаундов — описывает, что входит в компонент. Собрать большой компонент из деталей проще, чем управлять чёрным ящиком через рычаги.
Как оптимизировать бандл
Во многих реализациях компаундов родительский компонент содержит дочерние как свойства:
EntityLayout.Aside = Aside
EntityLayout.Actions = Actions
EntityLayout.Cover = Cover
export default EntityLayoutЭто не более чем соглашение по кодстайлу, которое подсказывает разработчику, что компонент не должен использоваться вне родителя. Но сгруппированные в объект компоненты не поддаются тришейкингу при импорте и этим раздувают бандл. Если на конкретной странице вы используете только пару компонентов из группы, в бандл попадут все, потому что распространяются как одно целое.
Когда среди компаундов появляются ситуативные компоненты, которые используются только на конкретных страницах, лучше перейти на именованные экспорты:
export {
EntityLayout,
EntityLayoutAside,
EntityLayoutActions,
EntityLayoutCover,
}Так неиспользуемый код не попадёт в бандл.
Примеры случаев, когда стоит использовать паттерн
Область применения компаундов — UI-киты и компонентные системы; в общем случае — любые компоненты, которые переиспользуются в нескольких комбинациях. Чаще всего компаунды работают с интерфейсной логикой, но могут быть привязаны и к бизнес-логике.
Паттерн гибок в применении. Вы можете использовать компоненты вроде Composer.Input в чистом виде прямо в компоненте страницы, а можете построить абстракцию, такую как ThreadComposer, чтобы скрыть сложность. Тогда компаунды становятся инструментом декомпозиции.
Вот где можно встретить компаунды:
Базовые единицы интерфейса
Компаунды идеально подходят для построения базовых единиц интерфейса — таких, как аккордеон, карусель, селект, табы.
Например, в реализованную с помощью компаундов карусель необходимые стили вшиты в компоненты:
// Было:
import 'swiper/css'
import { Navigation } from 'swiper/modules'
import { Swiper } from 'swiper/react'
<Swiper
className={styles.swiper}
modules={[Navigation]}
navigation={{
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-previous',
}}
slidesPerView="auto"
spaceBetween={20}
wrapperTag="ul"
>
{timeline.events.map((event) => (
<li className={cn(styles.slide, 'swiper-slide')}></li>
))}
</Swiper>
// Стало:
import { Carousel } from '@ark-ui/react/carousel'
<Carousel.Root autoSize>
<Carousel.Control>
<Carousel.PrevTrigger />
<Carousel.NextTrigger />
</Carousel.Control>
<Carousel.ItemGroup>
<Carousel.Item />
</Carousel.ItemGroup>
<Carousel.IndicatorGroup>
<Carousel.Indicator />
</Carousel.IndicatorGroup>
</Carousel.Root>Компаунды удобны, когда в какую-то часть вёрстки нужно вложить произвольные элементы:
<Carousel.Root
ref="carouselRef"
:slide-count="reviews.length"
:slides-per-page="slidesPerPage"
aria-labelledby="carousel-heading"
>
<motion.div class="headline animated-on-scroll">
<Heading id="carousel-heading" class="heading" />
<Carousel.Control />
</motion.div>
<motion.div class="animated-on-scroll">
<Carousel.Item />
</motion.div>
</Carousel.Root>Если вы используете библиотеку компонентов, такую как shadcn, вы уже знакомы с подходом. Используйте его при разработке собственных, уникальных для проекта примитивов.
Лэйауты. На примере пошаговой карточки
С помощью компаундов удобно верстать целые экраны.
Вот пример из рабочей задачи — карточка авторизации. У неё несколько возможных состояний: регистрация, вход, восстановление пароля:
Рассмотрим реализацию на слотах.
export const AuthCard = () => (
<Card className="auth-card">
<header className="auth-card__header">
<h2 className="auth-card__title">{title}</h2>
{subtitle && <p className="auth-card__subtitle">{subtitle}</p>}
</header>
{socials}
<form className="auth-card__form" onSubmit={handleSubmit}>
{children}
</form>
<footer className="auth-card__footer">
{footer}
{legalNotice}
</footer>
</Card>
)
// Использование:
<AuthCard
title="Восстановление пароля"
subtitle="На указанный email мы пришлем вам ссылку для восстановления пароля"
footer={<a href="#">Вернуться ко входу</a>}
>
<Input
label="Email"
type="email"
name="email"
placeholder="Введите email"
required
/>
<Button type="submit" variant="primary" fullWidth>
Получить ссылку
</Button>
</AuthCard>Неудобно, что часть вёрстки задаётся в пропах, часть — в теле компонента. Часть вёрстки намертво закреплена в одном месте; для добавления новых частей компонент нужно модифицировать, что усложняет поддержку.
Используем компаунды. Здесь вёрстка и стили описаны в одном месте, компоненты содержат только специфичную для каждого шага логику, и порядок компонентов может быть каким угодно:
const PaymentForm = () => (
<AuthCardLayout>
<AuthCardLayout.Header />
<AuthCardLayout.Field
name="email"
placeholder="Email для регистрации"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
/>
<AuthCardLayout.Action>Перейти к оплате</AuthCardLayout.Action>
<AuthCardLayout.ActionLink onClick={() => setAuthStep(authSteps.Login)}>
Уже есть аккаунт. Войти
</AuthCardLayout.ActionLink>
<AuthCardLayout.Footnote />
</AuthCardLayout>
)См. также
React Hooks: Compound Components. Kent C. Dodds рассказывает, почему АПИ, построенный на компаундах, — выразительный и гибкий.
В тексте есть удачный пример из ХТМЛ, чтобы понять идею компаунда. Это <select> и <option>:
<select>
<option value="value1">key1</option>
<option value="value2">key2</option>
<option value="value3">key3</option>
</select>Автор предлагает представить АПИ в случае, если бы компаундов не было:
<select options="key1:value1;key2:value2;key3:value3"></select>Улучшаем качество кода React-приложения с помощью Compound Components. Александр Дунай, старший разработчик «Альфа-Банка», сделал статью и доклад про компаунды. Две мысли отсюда — золото:
- Компаунды похожи на БЭМ в том плане, что родительский компонент — блок, дочерние компоненты — БЭМ-элементы, а пропы — это модификаторы.
- Цель подхода — не написать абстрактный «выразительный АПИ» в вакууме. Компаунды буквально разгружают разработчика, который работает над сложным компонентом после вас. Он будет благодарен вам.
Советую прочитать статью, чтобы посмотреть пример развесистого компонента из реального приложения.
Compound Components. Впервые я познакомился с концепцией компаундов, когда начал использовать компонентную библиотеку Ark UI. Первая объясняющая статья про компаунды, которую я прочитал, — этот текст Кайла Шевлина. Советую с благодарностью и любовью к индивебу.
Реактивные панели. Об отличии между слотами и свободной композицией компонентов написал Дмитрий Карловский, автор фреймворка Мол. В статье он на примере компонента панели показывает наименее костыльный и наиболее расширяемый способ описания вёрстки. Полезно прочитать, чтобы проследить ход мысли.
Финальная версия на JSX — классические компаунды:
return (
<MyPanel className="my-panel-skin-pretty">
<MyPanelHead>
<MyPanelTitle>Привет, мир!</MyPanelTitle>
<button onclick={this.onClose.bind(this)}>Закрыть</button>
</MyPanelHead>
<MyPanelBody>
<p>Ты прекрасен!</p>
</MyPanelBody>
<MyPanelFoot>
<button onclick={this.onSuccess.bind(this)}>О, да!</button>
</MyPanelFoot>
</MyPanel>
)