Головна / Статті / Коли повторно використовувані компоненти React призводять до проблем: вибух prop та спосіб його усунення

Коли повторно використовувані компоненти React призводять до проблем: вибух prop та спосіб його усунення

Побачте, як передчасне повторне використання перетворює простий компонент React на обтяжений великою кількістю параметрів об’єкт, та як дублювання, складні компоненти та правило трьох запобігають цьому.

2023 слів

Спільні компоненти мають допомагати економити час, проте в багатьох кодових базах фронтенду існує один компонент, який усі бояться редагувати: <Modal /> або <Card /> із десятками параметрів, коли виправлення помилки на одному екрані порушує роботу трьох інших. Такий результат рідко є наслідком неуважності; це природний кінець передчасного повторного використання інтерфейсу. У цій статті розглядається, як компонент перетворюється на таку проблему, пояснюється, чому дублювання інтерфейсу часто є дешевшим варіантом, та наводяться два практичні інструменти для уникнення цього: складні компоненти та правило трьох.

Як безневинний скорочений варіант перетворюється на компонент із тридцятьма двома параметрами

Зазвичай цей патерн починається з розумної пропозиції. Дизайнер надсилає екран оплати з діалоговим вікном, яке майже повністю збігається з діалоговим вікном підтвердження на сторінці налаштувань. Різниця незначна: дії розташовані зліва, поруч із заголовком є помаранчева позначка, а тонка лінія розділяє кнопки від контенту.

Під час перегляду хтось зазначає, що вже існує <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 /> не потребують впливу на основний контейнер.
  • Читабельна структура. JSX відображає макет безпосередньо, замість того щоб змушувати читачів відкривати інтерфейс TypeScript та з’ясовувати, яка комбінація властивостей дає певний макет.
  • Під капотом складні компоненти зазвичай діляться станом, таким як open, через контекст React, завдяки чому <Modal.Footer> або кнопка закриття можуть до нього отримати доступ без необхідності проходження через кожну властивість. Композиція передає контроль користувачеві, не перетворюючи компонент на об’єкт конфігурації. Однак цей підхід має свої недоліки: система дизайну мусить документувати, які частини можна вкладати одна в одну, а користувачі мають писати трохи більше коду за кожне використання. Щоб дізнатися більше про цей спосіб рефакторингу, перегляньте наш гайд щодо усунення перевантаження властивостями за допомогою композиції та слотів.

    Правило трьох для вилучення спільних компонентів

    Проста евристика допомагає вирішити, коли абстракція є доцільною: почекайте на третє реальне виникнення.

    Перше виникнення: запишіть його на місці

    Розмістіть маркування безпосередньо всередині елемента, який його потребує. Не піддавайтеся спокусі абстрагувати, а залишайте стилі та структуру поруч із функціоналом.

    Друге виникнення: скопіюйте та адаптуйте

    Коли інший екран потребує чогось подібного, створення глобального компонента здається дуже привабливим. Натомість скопіюйте маркування та пристосуйте його до нового контексту. Тепер у вас є два конкретні приклади, і з часом ви зможете спостерігати, де вони справді відрізняються, замість того щоб припускати щось щодо майбутніх вимог.

    Третє виникнення: витягніть із доказами

    Коли потрібен ще один окремий екран із тим самим візуальним та поведінковим патерном, у вас нарешті є достатньо доказів, щоб зрозуміти, що саме є спільним, а що відрізняється. Така тривала перевірка виявляє справжні інваріанти — ті елементи, які залишаються незмінними завжди, — та справжні варіації, які мають залишатися гнучкими. Саме ці варіації є хорошими кандидатами на використання у слотах композиції замість булевих властивостей.

    Мета не у тому, щоб уникати повторно використовуваних компонентів. Мета — уникати створення абстракцій на основі припущень.

    Ключові висновки

    • Стежте за розмноженням властивостей. Якщо компонент постійно отримує додаткові конфігураційні властивості для непов’язаних сценаріїв використання, перш ніж додати ще одну, замисліться над цією абстракцією.
    • Віддавайте перевагу композиції перед конфігурацією. Дочірні компоненти, слоти та складні компоненти забезпечують гнучкість без необхідності створення нових булевих значень щоразу, коли змінюється дизайн.
  • Допускайте невелику кількість дублювання. Пара невеликих, окремих компонентів, які мають спільну частину маркування, зазвичай легше підтримувати, ніж один компонент, який намагається охопити всі варіанти.
  • Застосовуйте правило трьох. Дозвольте спільним компонентам виникати з кількох реальних сценаріїв використання, а не на основі припущень.
  • Хороша архітектура фронтенду оцінюється не кількістю рядків коду, а тим, наскільки безпечно може змінюватися код без наслідків для всього додатку. Іноді найкращим компонентом є не той, який використовується скрізь, а той, який залишають у спокої.

    Пов’язана література

  • Де має знаходитися межа фреймворку в стеку користувацького інтерфейсу, який можна повторно використовувати — Дізнайтеся, як машини станів, веб-компоненти та лейаут та анімація, засновані на атрибутах, дозволяють поведінці інтерфейсу існувати поза межами фреймворку, та коли така портативність не є доцільною.
  • Дуплікація проти взаємозв’язку: як вирішити, коли слід існувати спільний код — Дізнайтеся, чому об’єднання схожих компонентів React або сервісів NestJS може коштувати більше, ніж їх дуплікація, та скористайтеся трьома запитаннями, щоб вирішити, коли абстракція заслуговує на своє місце.
  • Feature-Sliced Design для React: шари, правила імпорту та коли припинити — Дізнайтеся, як шари, правило однонапрямкового імпорту, спрощені публічні API та механізми крос-імпорту @x допомагають оптимізувати кодові бази React, а також коли варто використовувати лише частину цих підходів.