Главная / Статьи / Устранение перегрузки свойств в React с помощью композиции и слотов

Устранение перегрузки свойств в React с помощью композиции и слотов

Узнайте, почему React-пропсы с большим количеством настроек приводят к увеличению нагрузки по техническому обслуживанию, и как инверсия контроля, композиция и слоты позволяют создавать по-настоящему переиспользуемые компоненты.

2466 слов

Рассмотрим, как свойства, определяемые конфигурацией, незаметно накапливают долги по техническому обслуживанию, и как инверсия контроля в сочетании с композицией приводит к по-настоящему модульным компонентам интерфейса.

Представьте пул-реквест от коллеги, в котором он с гордостью представляет то, что называет «Универсальной таблицей данных».

Цель состоит в том, чтобы одним махом решить все задачи отображения таблиц во всем кодовом базисе.

В её состав входят тридцать четыре отдельных свойства:

<UniversalDataTable
  data={users}
  columns={columns}
  enablePagination={true}
  paginationType="cursor"
  enableSorting={true}
  showFilterRow={true}
  hasExportToCsvButton={true}
  exportButtonText="Download CSV"
  headerBackground="light-gray"
  denseRows={false}
  enableRowSelection={true}
  selectionMode="multiple"
  renderCustomRowActions={(row) => <DropdownMenu row={row} />}
  onRowClick={(row) => navigate(`/users/${row.id}`)}
/>

Под капотом находится примерно 1,200 строк логики.

Для отображения списка пользователей она работает отлично.

Через несколько недель отдел маркетинга просит добавить на публичный сайт таблицу сравнения цен.

Требования: три столбца, иконки галочек рядом с каждой строкой функции и выделенная рамка «Популярно» вокруг среднего столбца.

Кто-то обращается к <UniversalDataTable />, ожидая, что он будет работать сразу.

Именно тогда всё и ломается.

Нет возможности настроить границы отдельных ячеек.

Выравнивание заголовков жестко прописано в определенном макете flex.

Чтобы получить чистый результат, приходится передавать hasExportToCsvButton={false} вместе с несколькими другими параметрами для отмены экспорта в CSV.

Поэтому команда добавляет еще четыре параметра:

isPricingTable
popularColumnIndex
customHeaderRenderer
disableGlobalStyles

Объем файла увеличивается до 1,450 строк.

На этом этапе его совершенно нельзя использовать повторно.

То, что изначально было абстракцией, превратилось в клубок специальных случаев, притворяющихся одним компонентом.

1. Ловушка параметров конфигурации

Когда команда хочет, чтобы компонент был «повторно используемым», инстинктивным решением обычно является конфигурирование через параметры.

Каждый новый случай использования приводит к добавлению ещё одного флага, перечисления строк, функции обратного вызова или переопределения стиля в интерфейс свойств.

Типичный пример:

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
  hasLeftIcon?: boolean;
  leftIcon?: React.ReactNode;
  hasRightIcon?: boolean;
  rightIcon?: React.ReactNode;
  isFullWidth?: boolean;
  tooltipText?: string;
  tooltipPosition?: 'top' | 'bottom';
  rounded?: boolean;
}

На первый взгляд это кажется гибким решением.

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

1. Комбинаторный взрыв

Уже десять логических свойств теоретически дают 1 024 возможные комбинации.

Не обязательно тестировать их все, чтобы понять, наскоро ситуация выходит из-под контроля.

Некоторые комбинации противоречат друг другу.

Другие навсегда останутся неиспользованными.

Некоторые будут отображаться по-разному в зависимости от ширины области просмотра, окружающего контента или глубины вложенности компонента.

Со временем у вас получается куча крайних случаев вместо целостного и предсказуемого API.

2. Проникновение в свойства

Представьте, что настройка подсказки должна достичь вложенного оберточного элемента, находящегося на нескольких уровнях глубоко внутри дерева компонентов.

Каждый промежуточный компонент по пути теперь вынужден передавать свойство, которое ему на самом деле не нужно.

Конфигурация проходит через все уровни исключительно потому, что так требует структура, а не потому, что это имеет смысл там.

В этом и заключается механизм проникновения свойств.

3. Жесткие предположения о макете

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

Предположим, новый дизайн требует размещения иконки между двумя строками текста кнопок.

Чтобы это реализовать, можно добавить что-то вроде:

iconPosition="between"

это может временно решить проблему.

Но что будет, как только дизайн снова изменится?

Вскоре вам придется поддерживать целый набор вариантов:

iconPosition="left"
iconPosition="right"
iconPosition="top"
iconPosition="between"
iconPosition="absolute"

Список продолжает расти, потому что внутренний JSX компонента по-прежнему полностью определяет его макет.

Настоящая проблема не в количестве свойств.

Проблема в том, что компонент берет на себя слишком много обязанностей по отношению к вещам, выходящим за рамки его задачи.

2. Инверсия контроля: лучшая модель мышления

Настоящая переиспользуемость достигается не путем попыток заранее предсказать все будущие требования.

Она достигается за счет применения инверсии контроля (IoC).

Концепция проста:

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

Именно по этой причине композиция является таким сильным паттерном в React.

Вместо создания кнопки, встроенно учитывающей подсказки, иконки, индикаторы загрузки, бейджи и все возможные варианты макета, создайте кнопку, основная функция которой — просто быть кнопкой.

Например:

export function Button({
  children,
  variant = 'primary',
  className = '',
  ...props
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
}

Контроль над содержимым теперь находится у пользователя:

<Tooltip content="Save your profile">
  <Button>
    <SaveIcon />
    <span>Save Changes</span>
  </Button>
</Tooltip>

Обратите внимание, что теперь не нужно:

Кнопка совершенно не знает о существовании подсказок.

Не существует свойства hasLeftIcon.

Не существует свойства leftIcon.

Не существует свойства iconSpacing.

Не существует свойства hasSpinner.

Нужен индикатор загрузки? Просто разместите его прямо внутри кнопки.

<Button>
  <Spinner />
  Saving...
</Button>

Размер компонента уменьшается, но при этом повышается гибкость.

Именно это соотношение является ключевым моментом здесь.

Многоразовый компонент не обязан внутри себя обрабатывать каждую возможную ситуацию. Ему нужны четко определенные границы, с помощью которых другие компоненты могут его составлять.

3. Слоты делают сложные компоненты более удобными для расширения

Использование параметра children хорошо подходит для простых элементов. Но что делать с более сложными компонентами, такими как карточки, диалоговые окна, панели управления или личные профили?

Именно здесь пригождаются слоты и составные компоненты.

Представьте себе традиционную карточку профиля, построенную на наборе параметров:

<UserProfileCard
  avatarUrl="/avatar.jpg"
  name="Sarah Connor"
  role="Tech Lead"
  status="Active"
  bio="Building reliable frontend systems."
  primaryActionLabel="Send Message"
  onPrimaryAction={sendMessage}
  secondaryActionLabel="View Profile"
  onSecondaryAction={viewProfile}
  badgeColor="green"
/>

На первый взгляд это кажется удобным.

Но затем требования начинают меняться.

Команда по маркетингу хочет, чтобы социальный аккаунт человека отображался прямо рядом с его именем. Команда администраторов корпоративных аккаунтов требует, чтобы по всей высоте карточки был размещен баннер с надписью «Приостановлено». Другая команда, отвечающая за продукт, хочет три кнопки действий вместо двух. Дизайнер решает, что аватар должен находиться справа, а не слева.

Каждая из этих просьб превращается в еще один элемент интерфейса.

Лучший подход — представлять структуру карточки в виде составных частей:

<Card>
  <Card.Header>
    <Avatar
      src={user.avatarUrl}
      alt={user.name}
    />
    <div>
      <Card.Title>{user.name}</Card.Title>
      <Card.Subtitle>{user.role}</Card.Subtitle>
    </div>    <Badge variant={user.statusColor}>
      {user.status}
    </Badge>
  </Card.Header>  <Card.Body>
    <p>{user.bio}</p>
  </Card.Body>  <Card.Actions>
    <Button
      variant="secondary"
      onClick={viewProfile}
    >
      View Profile
    </Button>    <Button
      variant="primary"
      onClick={sendMessage}
    >
      Send Message
    </Button>
  </Card.Actions>
</Card>

Теперь вы можете видеть структуру карточки в JSX. И поскольку вы ее видите, вы можете ее изменять.

Хотите еще одну кнопку действий? Просто добавьте ее. Нужен другой значок? Замените его. Надо добавить еще один баннер? Вставьте его там, где это нужно. Хотите совершенно другую компоновку заголовка? Просто напишите новый заголовок.

Для поддержки одного нового варианта совсем не обязательно трогать перегруженный общий компонент.

4. Композиция лучше конфигурации

Эти два подхода ставят совершенно разные вопросы.

Конфигурация задаёт вопрос:

«Какой набор опций должен предоставлять этот компонент?»

Композиция задаёт вопрос:

«Какие небольшие строительные блоки могут быть использованы пользователями для создания того, что им действительно нужно?»

Конфигурация постоянно перекладывает ответственность за принятие решений на сам компонент. Композиция возвращает эту ответственность тем, кто использует компонент. Обычно это более подходящее место для неё.

Возьмём это сравнение в качестве примера:

<Button
  hasLeftIcon
  leftIcon={<SaveIcon />}
  hasTooltip
  tooltipText="Save your profile"
  showSpinner={isSaving}
  spinnerPosition="left"
/>

против:

<Tooltip content="Save your profile">
  <Button>
    {isSaving && <Spinner />}
    <SaveIcon />
    Save Profile
  </Button>
</Tooltip>

Во втором фрагменте действительно больше JSX. Но при этом значительно меньше взаимосвязей между частями, которым не нужно знать друг о друге. Такой компромисс обычно оправдан — небольшое количество дополнительных маркапов в месте вызова с течением времени обходится дешевле, чем добавление ещё одной постоянной функции в общий компонент.

5. Учитесь у библиотек без головы

Несколько современных библиотек фронтенда исключительно хорошо демонстрируют эту философию.

Проекты вроде Radix UI, TanStack Table и shadcn/ui сыграли важную роль в популяризации подхода, основанного на композиции.

TanStack Table — один из самых ясных примеров. Он не привязывает вас к одной фиксированной структуре таблицы. Вместо этого он предоставляет логику и механизмы управления состоянием для таких задач, как:

  • Сортировка
  • Фильтрация
  • Пагинация
  • Выбор строк
  • Управление столбцами
  • Состояние таблицы
  • Преобразование этого состояния в фактический маркап полностью зависит от вас. Например:

    <table>
      <thead>
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <th key={header.id}>
                {flexRender(
                  header.column.columnDef.header,
                  header.getContext()
                )}
              </th>
            ))}
          </tr>
        ))}
      </thead>
    
      <tbody>
        {/* Your own rendering logic */}
      </tbody>
    </table>
    

    Точная структура API здесь не так уж и важна. Важна основная архитектура: движок таблиц определяет поведение, а вы отвечаете за визуальное представление. Поскольку эти два аспекта разделены, вы можете свободно перерабатывать визуальный вид без необходимости превращения основной логики таблицы в один огромный визуальный компонент.

    6. Разделение поведения и внешнего вида

    Это естественным образом приводит к еще одному рекомендуемому принципу:

    Разделяйте то, что делает компонент, от того, как он отображается.

    Один компонент может действительно требовать управления значительной внутренней сложностью, включая такие аспекты, как:

    • Навигация с помощью клавиатуры
    • Управление фокусом
    • Выбор
    • Сортировка
    • Фильтрация
    • Состояние
    • Доступность
    • Позиционирование

    Хорошее управление всем этим редко бывает простым делом. Однако ни один из этих аспектов не требует, чтобы тот же компонент определял и точный внешний вид окончательного результата.

    В этом заключается основная идея большинства безголовых систем интерфейса. Поведение компонента может использоваться во многих визуальных реализациях, при этом слой представления остается полностью открытым. Такое разделение обычно обеспечивает гораздо более высокую переиспользуемость, чем попытка создать один огромный компонент с десятками настроек визуального оформления.

    7. Когда пропсы действительно имеют смысл

    Всё это не означает, что пропсы — плохая практика.

    Это один из самых фундаментальных инструментов, предоставляемых React.

    Проблема не в существовании пропсов, а в использовании их для кодирования каждой возможной структурной вариации, которая может понадобиться компоненту.

    Подобные примеры совершенно нормальны:

    <Button variant="primary" />
    <Button size="sm" />
    <Input disabled />
    <Card className="featured" />
    

    Красный флаг появляется, когда компонент начинает накапливать список в таком виде:

    <Component
      showHeader
      showFooter
      showBadge
      showIcon
      showActions
      iconPosition="left"
      badgePosition="right"
      footerAlignment="center"
      actionLayout="horizontal"
      compact
      dense
      bordered
      rounded
      disableAnimation
      customHeaderRenderer={...}
      customFooterRenderer={...}
    />
    

    Как только вы заметите такую закономерность, стоит на мгновение остановиться и задаться вопросом:

    «Действительно ли им следует оставаться в качестве параметров, или некоторые из них лучше использоваться как составные дочерние элементы?»

    Уже сам вопрос помогает избежать множества ненужных сложностей в будущем.

    8. Что на самом деле стоит вам из-за чрезмерной абстракции

    Подлинный ущерб от чрезмерно настроенных компонентов не связан с внешним видом.

    Проблема в том, что их становится дорого модифицировать.

    Широко используемый компонент оказывает значительное влияние на всю систему. Если двадцать мест в вашем кодовом базисе зависят от него, то каждый новый параметр, добавленный к нему, может повлиять на все эти двадцать компонентов.

    Инженеры становятся неохотными к его изменению.

    Написание документации для него становится сложнее.

    Тестирование его становится сложнее.

    Выявление ошибок в нем становится сложнее.

    В конечном итоге компонент становится настолько полностью «переиспользуемым», что никто на самом деле не хочет им пользоваться.

    В этом и заключается ирония данной паттерна.

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

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

    Практическая рамка для создания компонентов

    Прежде чем обращаться к еще одному свойству конфигурации, пройдитесь по этим проверкам.

    1. Работаю ли я с поведением или с отображением?

    Если речь идет о поведении, то подходящим инструментом, скорее всего, будет свойство или пользовательский хук. Если же дело касается исключительно визуальной структуры, то здесь обычно более уместен подход композиции.

    2. Действительно ли этому компоненту нужна эта информация?

    Если от одного компонента ожидается понимание подсказок, хуков для аналитики, значков, иконок, индикаторов загрузки и любой комбинации его дочерних элементов, то он практически наверняка берет на себя больше обязанностей, чем следует.

    3. Разве children не сможет с этим справиться?

    Если да, то лучше использовать подход композиции, чем вводить еще одно свойство, определяющее структуру контента.

    4. Смогут ли именованные слоты упростить API?

    Для более сложных компонентов определение четких областей, таких как:

    <Card.Header />
    <Card.Body />
    <Card.Footer />
    

    может значительно упростить понимание их структуры.

    5. Можно ли отделить логику от маркиупа?

    Если да, стоит рассмотреть подход без головного элемента или подход, ориентированный на логику.

    6. Решает ли этот компонент реальную проблему или вымышленную?

    Этот вопрос важнее, чем кажется.

    Не стоит добавлять что-то вроде:

    futureFeature={true}
    

    только потому, что кто-то в будущем может этого захотеть.

    Проектируйте на основе требований, для которых у вас есть доказательства, а не на основе предположений.

    Заключительные мысли

    Цель создания повторно используемых компонентов React — не производить что-то, что может делать абсолютно всё.

    Цель — создавать элементы, которые можно собирать разными способами.

    Это различие кажется незначительным, но с архитектурной точки зрения оно меняет всё.

    Компонент, принимающий тридцать параметров, на первый взгляд может казаться гибким, но каждый из этих параметров добавляет ещё одну обязанность, ещё одно состояние, которое необходимо учитывать, и ещё одну комбинацию, которую кто-то в конечном итоге будет вынужден поддерживать.

    В отличие от этого, компонент с узким API и чётко определёнными границами составления может показаться незаметным.

    Однако на практике он обычно лучше выдерживает испытание временем.

    Поэтому в следующий раз, когда у вас возникнет желание добавить ещё один параметр к общему компоненту, остановитесь на мгновение и спросите себя:

    «Я делаю его более переиспользуемым или просто более настраиваемым?»

    Эти два аспекта не являются взаимозаменяемыми.

    Компонент становится переиспользуемым не за счёт накопления тридцати опций.

    Он становится переиспользуемым, хорошо выполняя одну функцию, задаёт разумные границы и не мешая в остальном.

    Архитектура надежных компонентов не сводится к прогнозированию всех будущих потребностей.

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

    Связанные статьи

  • React Query и Redux: переосмысление состояния сервера в крупных приложениях — Узнайте, почему в производственном чат-приложении использовался TanStack Query вместо Redux для управления серверными данными, и где Redux всё ещё находит своё место в современной архитектуре React.
  • Как рисует браузер и какова роль React — Узнайте, как критический путь отрисовки, процесс согласования данных, технология Fiber и планировщик взаимодействуют друг с другом, превращая обновления React в пиксели на экране.
  • Уменьшение размера React-компонентов с использованием метода prop-drilling и концепции God Components — Узнайте о семи конкретных шаблонах рефакторинга для разбиения крупных React-компонентов путем изоляции состояния, загрузки данных, прав доступа и логики загрузки вместо простого разделения файлов.