Когда повторно используемые компоненты React дают сбой: взрыв prop и способ его устранения
Посмотрите, как преждевременное повторное использование превращает простой компонент React в сложный объект с большим количеством параметров, и как дублирование, составные компоненты и правило трёх помогают этого избежать.
Общие компоненты должны экономить время, однако во многих фронтенд-кодбазах существует компонент, который все боятся изменять: <Modal /> или <Card /> с десятками атрибутов, при котором исправление ошибки на одном экране ломает три других. Такой результат редко является следствием небрежности; это естественный исход чрезмерного использования повторяющихся UI-элементов. В этой статье рассматривается, как компонент превращается в источник проблем, объясняется, почему дублирование UI часто оказывается более экономичным решением, и показаны два практических инструмента для избежания этого: составные компоненты и правило трёх.
Как безобидная ускоряющая функция превращается в компонент с тридцатью двумя атрибутами
Как правило, процесс начинается с разумной просьбы. Дизайнер предоставляет экран оформления заказа с диалоговым окном, почти идентичным диалоговому окну подтверждения на странице настроек. Различия незначительны: кнопки расположены с левой стороны, рядом с заголовком находится оранжевый значок, а тонкая линия разделяет кнопки и содержимое.
Во время рассмотрения кто-то отмечает, что уже существует элемент <Modal />, и предлагает использовать его заново. Через несколько часов поступает запрос на интеграцию, в котором добавляются четыре свойства: hasOrangeBadge, alignActionsLeft, showDividerLine и badgeText.
Повторяйте это десяток раз в год. Теперь тот же модал требует тридцати двух параметров, содержит пятнадцать вложенных тернарных выражений и двенадцать логических флагов, а также опирается на три хука useEffect, чтобы синхронизировать внутренние анимации с различными комбинациями параметров. Затем кто-то меняет одну строку с параметром padding, чтобы исправить ошибку в расчётах, и непреднамеренно ломает работу модала на четырёх не связанных между собой экранах. Попытка сделать код чистым и повторно используемым привела к созданию компонента, которым никто не хочет заниматься.
Почему принцип DRY применяется иначе к UI
Принцип «Не повторяйте себя» имеет под собой веские основания. Когда бизнес-логика, такая как расчёт оплаты или проверка прав доступа, дублируется, исправление, внесённое в одну копию, оставляет остальные неисправными, и копии постепенно отдаляются друг от друга.
Компоненты интерфейса меняются по разным причинам. Бизнес-правила изменяются при смене области применения. Интерфейсы трансформируются под влиянием пути пользователя, порогов чувствительности к размеру экрана, требований к доступности, решений по продукту и дизайнерских экспериментов, причем все эти факторы влияют на каждый экран независимо друг от друга.
Два внешне похожих элемента не обязательно представляют собой одну и ту же концепцию. Слияние двух шаблонов интерфейса в один общий компонент до того, как их дизайн будет окончательно утвержден, приводит к возникновению взаимосвязи между функциями, которой раньше не существовало. С этого момента любая корректировка дизайна процесса настройки может потребовать повторной проверки настроек, систем оплаты и аналитики лишь потому, что они используют один и тот же компонент. В такой ситуации повторное использование становится препятствием.
Анатомия «взрыва» пропсов
Полезно наблюдать за процессом развития по одному спринту за раз. Начальной точкой является карточка с простым, минимальным контрактом: заголовком, описанием и необязательным обработчиком клика.
interface CardProps {
title: string;
description: string;
onClick?: () => void;
}
Реализация также небольшая и легкая для понимания:
export function Card({ title, description, onClick }: CardProps) {
return (
<div className="card" onClick={onClick}>
<h3>{title}</h3>
<p>{description}</p>
</div>
);
}
С этим компонентом всё в порядке. Затем, на 4-м спринте, отдел маркетинга хочет карточки для блога с изображением сверху, поэтому появляются два необязательных свойства для изображения:
interface CardProps {
// ...
imageUrl?: string;
imageAlt?: string;
}
На 7-м спринте команда панели управления просит кнопку действия в углу, видимую только для активных элементов. Для этого требуются флаг, иконка и обработчик:
interface CardProps {
// ...
hasTopRightAction?: boolean;
topRightActionIcon?: React.ReactNode;
onTopRightAction?: () => void;
}
К 12-му спринту команда аналитики хочет вторую строку под заголовком, значок статуса в трех цветах и футер, который может растягиваться, что добавляет ещё шесть свойств:
interface CardProps {
// ...
subtitle?: string;
badgeText?: string;
badgeVariant?: "success" | "warning" | "danger";
isExpandable?: boolean;
expandedContent?: React.ReactNode;
defaultExpanded?: boolean;
}
К 20-му спринту функция отрисовки превратилась в набор условий. Корневой элемент определяет свой класс на основе флага, а изображение отрисовывается только при наличии URL:
export function Card(props: CardProps) {
return (
<div
className={`card ${
props.isExpandable ? "card-expandable" : ""
}`}
>
{props.imageUrl && (
<img src={props.imageUrl} alt={props.imageAlt} />
)}
Обертка заголовка открывается с названием:
<div className="card-header">
<div>
<h3>{props.title}</h3>
за которым следует подзаголовок, отображаемый только при его наличии:
{props.subtitle && <h4>{props.subtitle}</h4>}
</div>
Действие в верхнем правом углу зависит от булевого флага, а не от наличия обработчика; поэтому возможно установить флаг и забыть об иконке, или передать иконку и забыть о флаге:
{props.hasTopRightAction && (
<button onClick={props.onTopRightAction}>
{props.topRightActionIcon}
</button>
)}
</div>
Бадж создает имя класса на основе своей версии и молча переходит к варианту default, который даже не указан в типе:
{props.badgeText && (
<span
className={`badge badge-${
props.badgeVariant || "default"
}`}
>
{props.badgeText}
</span>
)}
Описание, единственный элемент оригинального дизайна, располагается посередине:
<p>{props.description}</p>
Расширяемый футер закрывает компонент. Обратите внимание, что isExpandable управляет как корневым классом, так и футером, в то время как параметр defaultExpanded из интерфейса нигде не используется в маркировке:
{props.isExpandable && (
<div className="card-footer">
{props.expandedContent}
</div>
)}
</div>
);
}
Подобные небольшие несоответствия встречаются часто. Когда у компонента столько флагов, никто не может одновременно увидеть все возможные комбинации, в результате появляются недопустимые или частично реализованные состояния. Каждый пользователь элемента <Card /> вынужден изучать всё более длинный список опций и выяснять, какие комбинации поддерживаются. Такая абстракция становится труднее для понимания, чем простая маркировка, которую она должна была заменить.
Часто дублирование дешевле, чем неправильная абстракция
Известная фраза из области проектирования программного обеспечения, популяризированная Сэнди Метц, прямо говорит об этом: «Дублирование стоит гораздо дешевле, чем неправильная абстракция». Этот совет наиболее полезен при работе над фронтэндом.
Когда два компонента лишь похожи друг на друга, их разделение часто является более безопасным выбором. Предположим, что BillingModal и OnboardingModal — это независимые компоненты:
- Изменения в
BillingModalне могут повлиять наOnboardingModal. - Удаление функции ознакомления с продуктом одновременно удаляет соответствующий модал и всю логику, связанную с этой функцией.
- Каждый модал развивается в соответствии со своими собственными требованиями.
- Небольшие визуальные изменения не требуют понимания десятков параметров, принадлежащих другим функциям.
Дублирование JSX стоит лишь нескольких дополнительных строк кода. Однако неправильная абстракция обходится гораздо дороже — в виде времени на отладку, тестирование регрессий и постоянное обслуживание. Не каждый повторяющийся фрагмент маркировки заслуживает превращения в общий компонент.
Композитные компоненты: комбинирование вместо настройки
Настоящее повторное использование всё ещё имеет своё место, особенно в системах дизайна. Ключевым моментом является то, как общий компонент обеспечивает гибкость. Вместо одного компонента, управляемого растущим списком логических значений, композитный компонент предоставляет набор небольших, взаимосвязанных частей, которые пользователи сами собирают.
Вот пример диалогового окна для выставления счётов, созданного таким образом. Компонент-владелец хранит состояние открытия локально:
export function BillingSettings() {
const [isOpen, setIsOpen] = useState(false);
Корневой элемент <Modal> получает только то, что действительно принадлежит ему — состояние открытия и функцию обратного вызова при изменениях, а надстройка является отдельной частью:
return (
<Modal open={isOpen} onOpenChange={setIsOpen}>
<Modal.Overlay />
В области содержимого находится заголовок, а в заголовке — название:
<Modal.Content>
<Modal.Header>
<Modal.Title>Update Billing Plan</Modal.Title>
Индикатор представляет собой ещё один дочерний элемент заголовка, причем его вариант указывается как свойство непосредственно у самого индикатора:
<Modal.Badge variant="warning">
Action Required
</Modal.Badge>
</Modal.Header>
Тело содержит произвольное содержимое, начинающееся с сообщения:
<Modal.Body>
<p>
Please update your payment method to avoid account suspension.
</p>
а затем следует форма, специфичная для определённой функции, о которой сам модальный окно ничего не знает:
<CreditCardForm />
</Modal.Body>
Подвал управляет собственным выравниванием и содержит обычные кнопки; первая из них закрывает диалог:
<Modal.Footer align="right">
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
Основная операция завершает подвал и всю структуру:
<Button variant="primary">
Save Changes
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}
Структурные различия имеют важное значение. Когда модальное окно требует индикатора, не существует свойства showBadge, которое можно было бы добавить; в этом случае используется элемент <Modal.Badge />. Когда экрану нужен пользовательский контент, его размещают там, где это уместно, вместо того чтобы создавать новый флаг. Преимущества очевидны:
- Отсутствие избыточности свойств. Модальное окно без индикатора или подвала просто не отображает элементы
<Modal.Badge />или<Modal.Footer />. - Большая гибкость. Иконка рядом с заголовком помещается внутрь элемента
<Modal.Header>; для этого не требуется новое свойство. - Изолированное стилирование. Изменения в элементе
<Modal.Badge />не влияют на основной контейнер.
По сути, составные компоненты обычно делятся состоянием, таким как open, через контекст React, благодаря чему <Modal.Footer> или кнопка закрытия могут к нему обращаться без необходимости прохода через все свойства. Метод композиции передает управление потребителю, не превращая компонент в объект конфигурации. Однако этот подход имеет свои недостатки: система дизайна должна описывать, какие части могут находиться внутри других, а потребители вынуждены писать немного больше маркировки при каждом использовании. Чтобы узнать другой подход к такой рефакторинговой операции, ознакомьтесь с нашим руководством по устранению перегрузки свойств с помощью композиции и слотов.
Правило трех для извлечения общих компонентов
Простой эвристический принцип помогает определить, когда абстракция оправдана: дождитесь третьего реального случая использования.
Первый случай: записывайте прямо на месте
Размещайте маркировку непосредственно в том элементе интерфейса, который её требует. Сопротивляйтесь желанию делать абстракцию и сохраняйте стили и структуру рядом с конкретной функцией.
Второй случай: скопируйте и адаптируйте
Когда другой экран нуждается в чем-то подобном, возникает сильное желание создать глобальный компонент. Вместо этого скопируйте маркировку и адаптируйте её к новому контексту. Теперь у вас есть два конкретных примера, и со временем вы сможете наблюдать, в чём они действительно отличаются, вместо того чтобы гадать о будущих требованиях.
Третий случай: извлеките с доказательствами
Когда третий, отдельный экран требует того же визуального и поведенческого паттерна, у вас наконец появляется достаточно доказательств, чтобы понять, что на самом деле общее, а что отличается. Такая длительная проверка позволяет выявить истинные неизменяемые элементы — те части, которые остаются прежними в любом случае, — а также реальные вариации, которые должны оставаться гибкими. Именно такие вариации являются хорошими кандидатами для использования в слотах композиции, а не в булевых свойствах.
Цель не в том, чтобы избегать повторно используемых компонентов. Цель — не создавать абстракции на основе предположений.
Основные выводы
- Следите за умножением свойств. Когда компонент постоянно получает дополнительные свойства конфигурации для несвязанных сценариев использования, задумайтесь над этой абстракцией перед добавлением еще одного свойства.
- Отдавайте предпочтение композиции перед конфигурацией. Дочерние элементы, слоты и составные компоненты обеспечивают гибкость без необходимости создавать новые булевые свойства каждый раз при изменении дизайна.
Хорошая архитектура фронтенда оценивается не по количеству строк кода, а по тому, насколько безопасно можно вносить изменения в код без негативных последствий для всего приложения. Иногда лучшим компонентом является не тот, который используется повсюду, а тот, который оставлен в покое.
Связанные материалы
- Уменьшение размера React-компонентов с чрезмерным количеством свойств — Узнайте о семи конкретных шаблонах рефакторинга для разбиения объемных React-компонентов путем изоляции состояния, загрузки данных, прав доступа и логики загрузки вместо простого разделения файлов.
- Устранение перегрузки свойствами в React с помощью композиции и слотов — Узнайте, почему свойства React с большим количеством настроек приводят к проблемам с техническим обслуживанием, и как инверсия контроля, композиция и слоты позволяют создавать по-настоящему переиспользуемые компоненты.