Для создания интерфейсов React рекомендует использовать композицию и библиотеки по управлению состоянием (state management libraries) для построения иерархий компонентов. Однако при сложных паттернах композиции появляются проблемы:
- Нужно излишне структурировать дочерние элементы
- Или передавать их в качестве пропсов, что усложняет читабельность, семантичность и структуру кода
Для большинства разработчиков проблема может быть неочевидна, и они перекидывают ее на уровень управления состоянием. Это обсуждается и в документации React:
Если вы хотите избавиться от передачи некоторых пропсов на множество уровней вниз, обычно композиция компонентов является более простым решением, чем контекст.
Документация React. Контекст.
Если пройти по ссылке, мы увидим еще один аргумент:
В Facebook мы используем React в тысячах компонентов, и не находили случаев, когда бы рекомендовали создавать иерархии наследования компонентов.
Документация React. Композиция против наследования.
Конечно, если бы каждый, кто использует инструмент, читал документацию и признавал авторитет авторов, то этой публикации не было. Поэтому разберем проблемы существующих подходов.
Паттерн №1 – Напрямую контролируемые компоненты
Я начинал с этого решения из-за фреймворка Vue, который рекомендует такой подход. Берем структуру данных, которая приходит с бэка или дизайна в случае формы. Пробрасываем в наш компонент – например, в карточку фильма:
const MovieCard = (props) => {
const {title, genre, description, rating, image} = props.data;
return (
<div>
<img href={image} />
<div><h2>{title}</h2></div>
<div><p>{genre}</p></div>
<div><p>{description}</p></div>
<div><h1>{rating}</h1></div>
</div>
)
}
Стоп. Мы уже знаем о бесконечном расширении требований к компоненты. Вдруг у названия будет ссылка на рецензию фильма? А у жанра – на лучшие фильмы из него? Не добавлять же теперь:
const MovieCard = (props) => {
const {title: {title},
description: {description},
rating: {rating},
genre: {genre},
image: {imageHref}
} = props.data;
return (
<div>
<img href={imageHref} />
<div><h2>{name}</h2></div>
<div><p>{genre}</p></div>
<div><h1>{rating}</h1></div>
<div><p>{description}</p></div>
</div>
)
}
Так мы обезопасим себя от проблем в будущем, но открываем двери ошибке нулевого поинтера. Изначально мы могли прокидывать структуры прямиком из бэка:
<MovieCard data={res.data} />
Теперь каждый раз нужно дублировать всю информацию:
<MovieCard data={{
title: {res.title},
description: {res.description},
rating: {res.rating},
image: {res.imageHref}
}} />
Однако мы забыли про жанр – и компонент упал. А если не поставили ограничители ошибок, то с ним и все приложение.
На помощь приходит TypeScript. Упрощаем схему с помощью рефакторинга карточки и элементов, которые ее используют. Благо, все подсвечивается в редакторе или при сборке:
interface IMovieCardElement {
text?: string;
}
interface IMovieCardImage {
imageHref?: string;
}
interface IMovieCardProps {
title: IMovieCardElement;
description: IMovieCardElement;
rating: IMovieCardElement;
genre: IMovieCardElement;
image: IMovieCardImage;
}
...
const {title: {text: title},
description: {text: description},
rating: {text: rating},
genre: {text: genre},
image: {imageHref}
} = props.data;
Чтобы сэкономить время, все равно прокидываем данные «as any» или «as IMovieCardProps». Что же получается? Мы уже три раза (если используем в одном месте) описали одну структуру данных. И что имеем? Компонент, который до сих пор нельзя модифицировать. Компонент, который потенциально может обвалить все приложение.
Пришло время переиспользовать этот компонент. Рейтинг больше не нужен. У нас два варианта:
Пропс withoutRating ставьте везде, где не нужен рейтинг
const MovieCard = ({withoutRating, ...props}) => {
const {title: {title},
description: {description},
rating: {rating},
genre: {genre},
image: {imageHref}
} = props.data;
return (
<div>
<img href={imageHref} />
<div><h2>{name}</h2></div>
<div><p>{genre}</p></div>
{ withoutRating &&
<div><h1>{rating}</h1></div>
}
<div><p>{description}</p></div>
</div>
)
}
Быстро, но мы нагромождаем пропсы и сооружаем четвертую структуру данных.
Делаем rating в IMovieCardProps необязательным. Не забываем сделать его пустым объектом по умолчанию
const MovieCard = ({data, ...props}) => {
const {title: {text: title},
description: {text: description},
rating: {text: rating} = {},
genre: {text: genre},
image: {imageHref}
} = data;
return (
<div>
<img href={imageHref} />
<div><h2>{name}</h2></div>
<div><p>{genre}</p></div>
{
data.rating &&
<div><h1>{rating}</h1></div>
}
<div><p>{description}</p></div>
</div>
)
}
Хитрее, но становится сложно читать код. Опять же, повторяемся четвертый раз. Контроль над компонентом не очевиден, так как непрозрачно управляется структурой данных. Скажем, нас попросили сделать пресловутый рейтинг ссылкой, но не везде:
rating: {text: rating, url: ratingUrl} = {},
...
{
data.rating &&
data.rating.url ?
<div>><h1><a href={ratingUrl}{rating}</a></h1></div>
:
<div><h1>{rating}</h1></div>
}
И тут упираемся в сложную логику, которую диктует непрозрачная структура данных.
Паттерн №2 – Компоненты с собственным состоянием и редюсерами
Одновременно странный и популярный подход. Я использовал его, когда начал работать с React и функционала JSX во Vue стало не хватать. Не раз слышал от разработчиков на митапах, что такой подход позволяет пропускать более обобщенные структуры данных:
- Компонент может принимать множество структур данных, верстка остается прежней
- При получении данных он обрабатывает их по нужному сценарию
- Данные сохраняются в состоянии компонента, чтобы не запускать редюсер при каждом рендере (опционально)
Естественно, к проблеме непрозрачности (1) добавляется проблема перегруженности логикой (2) и добавление состояния в финальный компонент (3).
Последнее (3) диктуется внутренней сохранностью объекта. То есть глубинно проверяем объекты через lodash.isEqual. Если случай продвинут или JSON.stringify, все только начинается. Еще можно добавить timestamp и проверять по нему, если все потеряно. Надобность в сохранении или мемоизации отпадает, так как по сложности вычисления оптимизация может оказаться сложнее редюсера.
Данные пробрасываются с названием сценария (как правило, строкой):
<MovieCard data={{
type: 'withoutRating',
data: res.data,
}} />
Теперь пишем компонент:
const MovieCard = ({data}) => {
const card = reduceData(data.type, data.data);
return (
<div>
<img href={card.imageHref} />
<div><h2>{card.name}</h2></div>
<div><p>{card.genre}</p></div>
{ card.withoutRating &&
<div><h1>{card.rating}</h1></div>
}
<div><p>{card.description}</p></div>
</div>
)
}
И логику:
const reduceData = (type, data) = {
switch (type) {
case 'withoutRating':
return {
title: {data.title},
description: {data.description},
rating: {data.rating},
genre: {data.genre},
image: {data.imageHref}
withoutRating: true,
};
...
}
};
На этом шаге возникает несколько проблем:
- Добавляя слой логики, мы окончательно теряем прямую связь между данными и отображением
- Дублирование логики для каждого кейса значит, что в случае, когда всем карточкам понадобится возрастной рейтинг, его нужно будет прописать в каждом редюсере
- Остаются прочие проблемы из шага №1
Паттерн №3 – Перенос логики отображения и данные в управление состоянием
Тут мы отказываемся от шины данных для построения интерфейсов, которой является React. Используем технологию с собственной моделью логики. Вероятно, это самый распространенный способ создания приложений на React, хоть руководство предупреждает, что не стоит использовать контекст таким образом.
Используйте подобные инструменты там, где React не предоставляет достаточного инструментария – например, в маршрутизации. Скорее всего, вы используете react-router. В этом случае для пробрасывания сессии во все страницы больший смысл имело бы использование контекста, а не пробрасывания коллбэка из компонента каждого маршрута верхнего уровня. В React нет отдельной абстракции для асинхронных действий кроме той, что предлагает язык Javascript.
Казалось бы, есть плюс: можем переиспользовать логику в будущих версиях приложения. Но это обман. С одной стороны, она привязана к API, с другой – к структуре приложения, а логика обеспечивает эту связь. При изменении одной из частей, ее требуется переписывать.
Решение: Паттерн №4 – композиция
Метод композиции очевиден, если следовать следующим принципам (не считая аналогичного подхода в книге Design Patterns):
- Фронтенд-разработка – разработка пользовательских интерфейсов – использует для верстки язык HTML
- Для получения, передачи и обработки данных используется язык Javascript
Поэтому переводите данные из одного домена в другой как можно раньше. В React для шаблонизации HTML используется абстракция JSX, а на деле – набор методов createElement. То есть, к JSX и компонентам React, которые тоже являются элементами JSX, стоит относиться как к методу отображения и поведения, а не трансформации и обработки данных, которые должны происходить на отдельном уровне.
На этом шаге многие и используют способы, перечисленные выше, но они не решают ключевую проблему расширения и модификации отображения компонентов. Как это делать по мнению создателей библиотеки, показано в документации:
function SplitPane(props) {
return (
<div className="SplitPane">
<div className="SplitPane-left">
{props.left}
</div>
<div className="SplitPane-right">
{props.right}
</div>
</div>
);
}
function App() {
return (
<SplitPane
left={
<Contacts />
}
right={
<Chat />
} />
);
}
То есть, в качестве параметров вместо строк, чисел и булевых типов передаются уже готовые, сверстанные компоненты.
Увы, этот способ тоже оказался негибким. Вот почему:
- Оба пропса – обязательные. Это ограничивает переиспользование компонента
- Необязательность означала бы перегружение компонента SplitPane логикой
- Вложенности и множественности отображаются не очень семантично.
- Эту логику отображения пришлось бы писать заново для каждого компонента, принимающего пропсы.
В итоге, подобное решение может разрастись в сложности даже для достаточно простых сценариев:
function SplitPane(props) {
return (
<div className="SplitPane">
{
props.left &&
<div className="SplitPane-left">
{props.left}
</div>
}
{
props.right &&
<div className="SplitPane-right">
{props.right}
</div>
}
</div>
);
}
function App() {
return (
<SplitPane
left={
contacts.map(el =>
<Contacts
name={
<ContactsName name={el.name} />
}
phone={
<ContactsPhone name={el.phone} />
}
/>
)
}
right={
<Chat />
} />
);
}
В документации подобный код, в случае компонентов высшего порядка (HOC) и рендер-пропсов называют «адом оберток» (wrapper hell). С каждым добавлением нового элемента читаемость кода становится все сложнее.
Один ответ на эту проблему — слоты — присутствует в технологии Веб-компонентов и в фреймворке Vue. В обеих местах, однако, присутствуют ограничения: во-первых, слоты определены не символом, а строкой, что усложняет рефакторинг. Во-вторых, слоты ограничены по функционалу и не могут сами управлять своим отображением, передавать дочерним компонентам другие слоты или быть переиспользоваными в других элементах.
Если коротко, примерно вот такое, назовем его паттерн №5 – слоты:
function App() {
return (
<SplitPane>
<LeftPane>
<Contacts />
</LeftPane>
<RightPane>
<Chat />
</RightPane>
</SplitPane>
);
}
Об этом расскажу в следующей статье о существующих решениях для слотового паттерна в React и о моем собственном решении.