Разработка интерфейсов

Как сделать переиспользуемый компонент. Паттерн Compound

В последнее время проектирую и строю UI-кит. Заметил, что тут и там использую паттерн Compound Components. Это способ делать компоненты, устойчивые к изменениям в дизайне; их легко расширять и использовать.

Расскажу, как работает паттерн:

Какую задачу решает паттерн

Паттерн помогает организовать код, когда фрагмент вёрстки выглядит для пользователя как одно целое и имеет за собой общую логику, но его отдельные элементы в зависимости от окружения меняют ХТМЛ-атрибуты, стили и порядок расположения в вёрстке.

Пример такого фрагмента — поле формы:

<FormField.Label />

Эл. почта

<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. Александр Дунай, старший разработчик «Альфа-Банка», сделал статью и доклад про компаунды. Две мысли отсюда — золото:

  1. Компаунды похожи на БЭМ в том плане, что родительский компонент — блок, дочерние компоненты — БЭМ-элементы, а пропы — это модификаторы.
  2. Цель подхода — не написать абстрактный «выразительный АПИ» в вакууме. Компаунды буквально разгружают разработчика, который работает над сложным компонентом после вас. Он будет благодарен вам.

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

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>
)

Прожмите реакцию