Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Компонентный подход является основополагающим при создании приложений с помощью react. Компоненты - это главные строительные блоки, которые в своей композиции помогают реализовать сложные системы. В основе каждого компонента, в идеале, всегда лежит какой-то обособленный набор функционала, своего рода микро-решение микро-задачи.
Существует несколько разных способов организации компонентов и каждый из них может быть хорош в конкретной ситуации. Все дело в том, что компоненты бывают разные, как и задачи, которые они решают. Получается, что в зависимости от функциональности компонента, его предназначения, нужно выбирать подходящий дизайн его реализации.
Сегодня я бы хотел с вами поделиться одним из моих любимых паттернов организации сложных react-компонентов, рассказать о его сильных и слабых сторонах (да, некоторые минусы есть). Однако сперва для того, чтобы оценить сильные стороны данного подхода, нужно погрузиться в процесс разработки нового react компонента и сопутствующие ему проблемы, которые подход помогает решить.
Представим, что перед нами стоит задача реализовать вот такой компонент:
Исходная структура проекта:
├── shared/
│ └── components/
│ ├── EventCard.tsx /** наш компонент, с которым будем работать */
│ └── ...other components
├── App.tsx
└── index.ts
Мы как простые работяги, сразу же беремся за работу. Компонент нам кажется простым, так зачем усложнять его реализацию? Скомпонуем всю необходимое внутри и получим примерно вот такой результат:
/** src/shared/components/EventCard.tsx */
type EventCardProps = {
/** Здесь не просто string, а ReactNode, т.к. string слишком узкий тип, он может связывать руки при использовании компонента */
title: ReactNode;
description: ReactNode;
onShare: () => void;
onMore: () => void;
onRemove: () => void;
onLike: () => void;
};
export const EventCard: React.FC<EventCardProps> = ({
title,
description,
onLike,
onMore,
onRemove,
onShare
}) => {
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
<footer className="action-bar">
<MoreInfoBtn onClick={onMore} />
<RemoveBtn onClick={onRemove} />
<LikeBtn onClick={onLike} />
</footer>
</article>
);
};
import { EventCard } from "./components/EventCard";
/** src/App.tsx */
/** Как выглядит интерфейс нашего компонента при использовании */
export default function App() {
/* .... */
return (
<div className="App">
<EventCard
title={title}
description={description}
onLike={handleEventLike}
onRemove={handleEventRemove}
onMore={handleEventMore}
onShare={handleEventShare}
/>
</div>
);
}
Фух, утерли пот со лба, задача реализована. Однако что это? Неужели дизайнер обновил макеты с примерами нашего компонента?
Что ж, выглядит не сложно, похоже action bar не обязателен для нашего компонента. Правок на 5 минут. Исправить ситуацию можем либо пропсом-флагом для скрытия/показа нашего action bar, либо сделаем необязательными пропсы калбеков для действий внутри компонента. Второй вариант выглядит получше, давайте так и сделаем:
/** src/shared/components/EventCard.tsx */
type EventCardProps = {
/** ... */
onMore?: () => void;
onRemove?: () => void;
onLike?: () => void;
};
export const EventCard: React.FC<EventCardProps> = ({
title,
description,
onLike,
onMore,
onRemove,
onShare
}) => {
const showActionBar = onMore || onRemove || onLike;
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
{showActionBar && (
<footer className="action-bar">
{onMore && <MoreInfoBtn onClick={onMore} />}
{onRemove && <RemoveBtn onClick={onRemove} />}
{onLike && <LikeBtn onClick={onLike} />}
</footer>
)}
</article>
);
};
Задача снова вроде решена (хотя подозрения по поводу кода уже начинают появляться), но вот незадача, макет снова был обновлен и теперь ясно, что область, где раньше располагался action bar может выглядеть совершенно иначе для нашего компонента.
Решение, как обычно, не одно. Во-первых, мы можем реализовать второй вариант action bar рядом с предыдущим, внутри компонента. Выглядеть это может примерно вот так:
/** src/shared/components/EventCard.tsx */
type EventCardProps = {
/** ... */
onMore?: () => void;
onRemove?: () => void;
onLike?: () => void;
currentBar: "status" | "action";
statusBarSettings?: {
status: "active" | "disabled";
percent: 35;
step: "day" | "month" | "year";
};
};
export const EventCard: React.FC<EventCardProps> = ({
title,
description,
onLike,
onMore,
onRemove,
onShare,
currentBar,
statusBarSettings
}) => {
const showStatusBar = Boolean(statusBarSettings);
const showActionBar = onMore || onRemove || onLike;
const actionBar = showActionBar ? (
<footer className="action-bar">
{onMore && <MoreInfoBtn onClick={onMore} />}
{onRemove && <RemoveBtn onClick={onRemove} />}
{onLike && <LikeBtn onClick={onLike} />}
</footer>
) : null;
const statusBar = showStatusBar ? (
<footer className="status-bar">
<StatusTag status={statusBarSettings!.status} />
<StatView
percent={statusBarSettings!.percent}
step={statusBarSettings!.step}
/>
</footer>
) : null;
const currentBarRender = currentBar === "action" ? actionBar : statusBar;
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
{currentBarRender}
</article>
);
};
Однако получилось в итоге не очень красиво и недостаточно понятно (несмотря на ряд допущений, т.к. это лишь пример). Интерфейс компонента становится запутанным, пропсы неочевидными. Дальнейшее развитие компонента в таком ключе лишь породит больше проблем и целый скоп багов. Скорее всего, коллеги не одобрят на ревью такой код. Похоже настало время для декомпозиции.
Тут нам может помочь второй вариант, а именно вынесение логики отображения этой области наружу, а сам компонент будет принимать лишь проп, который отобразим в нужном месте:
/** src/shared/components/EventCard.tsx */
type EventCardProps = {
title: ReactNode;
description: ReactNode;
onShare: () => void;
/** Теперь 1 проп отвечает полностью за рендер контента футера */
footer: ReactNode
};
/** Вынесли наружу всю логику связанную с контентом футера и компонент стал сразу намного очевиднее */
export const EventCard: React.FC<EventCardProps> = ({
title,
description,
onShare,
footer
}) => {
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
{/** Сложная логика проверки и выбора текущего футера упразднилась в пользу простого рендера */}
{footer}
</article>
);
};
/** src/App.tsx */
/** Как выглядит интерфейс нашего компонента при использовании */
export default function App() {
/** ... */
return (
<div className="App">
{/** Использование с action-bar */}
<EventCard
title={title}
description={description}
onShare={handleEventShare}
footer={
<footer className="action-bar">
<MoreInfoBtn onClick={handleEventMore} />
<RemoveBtn onClick={handleEventRemove} />
<LikeBtn onClick={handleEventLike} />
</footer>
}
/>
{/** Использование со status-bar */}
<EventCard
title={title}
description={description}
onShare={handleEventShare}
footer={
<footer className="status-bar">
<StatusTag status={"active"} />
<StatView percent={35} step={"month"} />
</footer>
}
/>
</div>
);
}
Кажется, что мы переложили проблему с больной головы на здоровую, но это не совсем так, ведь нам удалось избавиться от ветвления при выборе текущего футера внутри компонента. Так же важно заметить, что при таком подходе очень хорошо для нас если разметка, создаваемая снаружи компонента, может быть организована с использованием уже готовых виджетов (как в нашем случае, я организовал уже существующие компоненты внутри разметки), а так же не будет переиспользоваться в рамках других страниц приложения. В противном случае ситуация драматически ухудшается. Особенно если отображение элементов разметки является уникальным в рамках приложения, а виджетов нет и не предвидится или же логика пользовательского взаимодействия с этим элементом приобретает значительную сложность (виджеты есть, но мы работаем с их группировкой, организовывая в сложный сценарий, плюсом не забывая о переиспользовании результата!).
В вышеуказанных обстоятельствах у нас появляется потребность вынесения таких кусков кода в отдельные компоненты:
/** src/shared/components/EventCardActionBar.tsx */
type EventCardActionBarProps = {
/**
* Все наши пропы теперь могут быть обязательными,
* ведь мы работаем с отедльным компонентом
* и можем проектировать его апи как нам удобно
*/
onMore: () => void;
onRemove: () => void;
onLike: () => void;
};
/** Длинное составное имя выглядит ужасно, но скоро мы это исправим */
export const EventCardActionBar: React.FC<EventCardActionBarProps> = ({
onMore,
onRemove,
onLike
}) => {
return (
<footer className="action-bar">
<MoreInfoBtn onClick={onMore} />
<RemoveBtn onClick={onRemove} />
<LikeBtn onClick={onLike} />
</footer>
);
};
/** src/shared/components/EventCardStatusBar.tsx */
type EventCardStatusBarProps = {
status: "active" | "disabled";
percent: 35;
step: "day" | "month" | "year";
};
export const EventCardStatusBar: React.FC<EventCardStatusBarProps> = ({
status,
percent,
step
}) => {
return (
<footer className="status-bar">
<StatusTag status={status} />
<StatView percent={percent} step={step} />
</footer>
);
};
Но такие компоненты, увы, самостоятельными не являются. Без родительского компонента смысла они не имеют (мы даже не можем дать им имя без префикса родительского компонента, ведь вдруг нам понадобится общий компонент ActionBar), использоваться вне контекста не будут (так чаще всего и случается), поэтому выделять для них место среди по-настоящему shared компонентов типа Button или Input на самом деле у нас нет никакого желания. Поэтому мы поступим таким образом, организуем их как подкомпоненты нашего исходного компонента:
├── shared/
│ └── components/
│ ├── EventCard/
│ │ ├── StatusBar.tsx (так как подкомпоненты теперь не на уровне shared/components, то можем дать упрощенное имя имя)
│ │ ├── ActionBar.tsx
│ │ ├── EventCard.tsx
│ │ └── index.ts (отсюда будет делать ре-экспорт, чтобы создать публичный апи компонента)
│ └── ...other components
├── App.tsx
└── index.ts
Отлично! Уже неплохо, но еще не идеально. Теперь мы можем без боли переиспользовать подкомпоненты. У нас получился составной компонент (compound-component):
import { EventCard } from "./shared/components/EventCard";
/** Обратите внимание на импорт, мы залезли во внутренности EventCard и выдернули необходимое. Выглядит это посредственно */
import { ActionBar } from "./shared/components/EventCard/ActionBar";
import { StatusBar } from "./shared/components/EventCard/StatusBar";
/** Как выглядит интерфейс нашего компонента при использовании */
export default function App() {
/** ... */
return (
<div className="App">
{/** Использование с action-bar */}
<EventCard
title={title}
description={description}
onShare={handleEventShare}
footer={
<ActionBar
onLike={handleEventLike}
onMore={handleEventMore}
onRemove={handleEventRemove}
/>
}
/>
{/** Использование со status-bar */}
<EventCard
title={title}
description={description}
onShare={handleEventShare}
footer={<StatusBar percent={35} status={"active"} step={"month"} />}
/>
</div>
);
}
Но обратите внимание на то, как выглядит импорт. Мы обращаемся к сущностям, которые не являются частью публичного апи нашего компонента (импорт не с верхнего уровня, а изнутри реализации компонента, что является не очень хорошей практикой). При этом хотелось бы подчеркнуть связь между нашими подкомпонентами и их родительским компонентом.
Теперь мы как раз и подошли к ситуации, в которой организация компонентов через dot-notation выглядит максимально привлекательно. Давайте немного реорганизуем наш код:
/** src/shared/components/EventCard.tsx */
/** Интерфейс, который расширит наш компонент, давая ему возможность содержать внутри себя виджеты */
type EventCardExtensions = {
ActionBar: typeof ActionBar;
StatusBar: typeof StatusBar;
/** И любые другие виджеты нашего компонента */
};
type EventCardProps = {
title: ReactNode;
description: ReactNode;
onShare: () => void;
/**
* Этот момент по желанию, можно добавить немного строгости,
* определив конкретные компоненты, которые могут стать футером
* или же разрешить рендер любой ноды
*/
footer: typeof ActionBar | typeof StatusBar;
// footer: ReactNode
};
/** К пропсам добавляем расширения компонента */
export const EventCard: React.FC<EventCardProps> & EventCardExtensions = ({
title,
description,
onShare,
footer
}) => {
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
{footer}
</article>
);
};
/** Зашьем внутри нашего компонента, виджеты расширяющие его функционал */
EventCard.ActionBar = ActionBar;
EventCard.StatusBar = StatusBar;
Теперь наши компоненты получили явную связь, они находятся в общем пространстве, но обособившись от несвязанных с ними сущностей. Получилась своего рода инкапсуляция, если это можно так назвать.
Выглядит уже очень здорово, но мне кажется можно улучшить еще немного. В нашем случае исходный компонент содержит несколько пропсов, каждый из которых отвечает за рендер соответствующего виджета. Порядок отображения виджетов является строго зашитым, т.к. мы его закрепили в разметке, но при необходимости мы можем дать возможность разработчикам самим определять порядок виджетов внутри компонента (например отображать ActionBar не внизу компонента, а сразу под заголовком). Для этого мы заменим специфичные пропсы для виджета на использование только лишь общего children. Теперь порядком можно управлять снаружи, а это дополнительная гибкость, при необходимости
type EventCardExtensions = {
ActionBar: typeof ActionBar;
StatusBar: typeof StatusBar;
Title: typeof Title;
Content: typeof Content;
/** И любые другие виджеты нашего компонента */
};
type EventCardProps = {
children: ReactNode;
};
export const EventCard: React.FC<EventCardProps> & EventCardExtensions = ({
children
}) => {
return <article>{children}</article>;
};
EventCard.ActionBar = ActionBar;
EventCard.StatusBar = StatusBar;
EventCard.Title = Title;
EventCard.Content = Content;
/** Импортируем мы теперь только целевой компонент */
import { EventCard } from "./shared/components/EventCard";
/** Как выглядит интерфейс нашего компонента при использовании */
export default function App() {
/** ... */
return (
<div className="App">
{/** Доступна любая комбинация, любой порядок группировки */}
<EventCard>
<EventCard.Title label={title} onShare={handleEventShare} />
<EventCard.ActionBar
onLike={handleEventLike}
onMore={handleEventMore}
onRemove={handleEventRemove}
/>
<EventCard.Content text={description} />
</EventCard>
</div>
);
}
Теперь нас ничем не напугать. И если в дизайне нашего компонента появляется совершенно неожиданная часть и она не является самодостаточным элементом нашего ui-kit, мы знаем как ее организовать. И композиция компонента такого вида
или вот такого
не вызывает смущения. Мы четко знаем как и где организовать максимально удобно код с четкими связями и иерархией, общим пространством и адекватным интерфейсом.
А при появлении состояния для нашего уже составного компонента, оно может переехать в обертку, которая будет одновременно являться и провайдером для контекста и все зависимые компоненты смогут получить к нему доступ
/** src/shared/components/EventCard.tsx */
const EventCardContext = React.createContext({});
export const EventCard: React.FC<EventCardProps> & EventCardExtensions = ({
children
}) => {
const [state, setState] = useState();
return (
<article>
<EventCardContext.Provider
value={{ value: state, changeValue: setState }}
>
{children}
</EventCardContext.Provider>
</article>
);
};
EventCard.ActionBar = ActionBar;
EventCard.StatusBar = StatusBar;
EventCard.Title = Title;
EventCard.Content = Content;
Какие минусы? У вас могут возникнуть трудности с tree-shaking по очевидным причинам, поэтому реально огромные иерархии все равно лучше декомпозировать (или поколдовать над тем, как работает у вас tree-shaking?)
В результате у нас получилось:
Разбить сложный компонент на несколько виджетов и организовать их в виде композиции
Подчеркнуть связь между главным и зависимыми компонентами
Спрятать несамостоятельные части за публичным интерфейсом
Реализовать все вышеуказанное не нанося ущерб структуре проекта
Создать пространство для будущего расширения функционала, при необходимости