Главная / Статьи / Сокращение чрезмерного использования React Prop-Drilling и «боговских» компонентов

Сокращение чрезмерного использования React Prop-Drilling и «боговских» компонентов

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

3299 слов

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

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

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

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

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

1. Компоненты, передающие весь функционал дальше

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

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

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

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

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

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

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

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

2. Создание новых объектов и функций при каждой отрисовке

Каждый компонент React автоматически генерирует новые значения во время отрисовки. Вы передаёте объект с параметрами дочернему компоненту, фильтруете массив или пишете обработчик событий внутри кода. В большинстве случаев всё это не имеет значения.

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

Рассмотрим дочерний компонент, построенный с использованием memo: он будет отрисовываться заново каждый раз, когда объект, который он получает, имеет новую идентичность при каждой передаче от родителя, даже если сами значения полей внутри этого объекта никогда не менялись. Эффект перезапускается, потому что его массив зависимостей содержит новый объект конфигурации. Таблица пересчитывает свои столбцы, поскольку определения столбцов пересоздаются после изменения какого-то совершенно не связанного состояния модального окна.

Код может казаться совершенно стабильным, скрывая эту проблему:

<ResultsTable
  columns={[
    { key: "name", label: "Name" },
    { key: "status", label: "Status" },
  ]}
  options={{
    selectable: true,
    compact: false,
  }}
/>

С точки зрения JavaScript, как массив, так и объекты внутри него полностью обновляются при каждой отрисовке. То, приводит ли это к проблемам, полностью зависит от того, что с ними работает.

Использование useMemo и useCallback в качестве универсального решения тоже не является правильным подходом. Обертывание всего в механизмы мемоизации лишь добавляет дополнительные сложности и проблемы, связанные с массивами зависимостей. Лучшее решение — сначала определить, действительно ли необходимо создавать значение прямо в процессе отрисовки.

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

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

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

3. Удаление единственного запроса, обеспечивавшего работу всего экрана

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

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

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

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

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

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

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

Большие компоненты склонны становиться хрупкими именно тогда, когда их модель данных определяется удобством работы на уровне страницы, а не естественным жизненным циклом отдельных элементов, находящихся внутри них.

4. Удаление универсальных компонентов, управляемых десятками флагов

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

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

<DataPanel
  searchable
  selectable
  showToolbar
  allowExport={canExport}
  inlineEdit={mode === "admin"}
  compact={isInsideModal}
  hidePagination={rows.length < 20}
  stickyHeader={!isMobile}
/>

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

Этот компонент постепенно превращается во второе приложение, скрытое внутри первого.

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

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

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

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

5. Удаление состояния модального окна с страницы, которая его открыла

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

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

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

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

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

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

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

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

6. Выделение проверок разрешений из рассеянного JSX

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

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

{user.role === "admin" && record.status !== "archived" && (
  <DeleteButton />
)}

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

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

Вместо того чтобы разбрасывать примитивные проверки правил по JSX, можно заранее вычислить явный набор возможностей:

const capabilities = getRecordCapabilities({
  user,
  record,
  organization,
});

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

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

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

Большие деревья JSX становятся хрупкими, когда в них одновременно формируются бизнес-правила «на лету». Отрисовка должна в основном заключаться в использовании уже принятых решений, а не в их повторном формировании с нуля в каждом условном ветке.

7. Устаревание функций загрузки всей страницы и флагов ошибок

Большинство крупных компонентов изначально имеют простую структуру: одно значение isLoading и одно значение error. Это нормально, если страница выполняет только одну задачу. Однако по мере развития функционала эти два флага начинают использоваться для описания нескольких независимых операций одновременно.

Страница может загружать начальные данные, обновлять таблицу на фоне, сохранять форму, удалять строки и экспортировать отчет — иногда все это происходит в той же сессии. Один общий логический флаг загрузки не позволяет определить, какая именно из операций в данный момент выполняется. Одна запрос завершается и ставит флаг в значение false, в то время как совершенно другой запрос все еще работает. Ошибка, возникшая в результате изменений данных, перезаписывает ошибку, предназначенную для описания первоначальной загрузки страницы. Вся интерфейсная среда «залипает» лишь из-за того, что происходит какое-то независимое фоновое обновление.

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

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

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

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

Это делает интерфейс более устойчивым, а модель компонентов — понятнее для анализа. Статус больше не рассматривается как глобальный лишь потому, что операция происходит внутри крупного компонента страницы.

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

Цель никогда не заключалась только в меньших файлах

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

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

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

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

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

Крупные компоненты React остаются управляемыми, как только они перестают пытаться самостоятельно вести себя как всё приложение.

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

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

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

  • Производительность фронтенда: от упущений при проверке кода до показателей продукта — Узнайте, почему одного прохождения проверки кода недостаточно, какие именно Core Web Vitals имеют значение, и как измерять и устранять проблемы с производительностью React в реальных условиях.
  • Устранение ситуаций гонки и проблем, которые нельзя решить с помощью дебаунсинга в интерфейсах поиска — Поймите, почему дебаунсинг сам по себе не может предотвратить замену свежего состояния интерфейса устаревшими ответами API, и ознакомьтесь с четырьмя практическими способами обеспечения правильного порядка выполнения запросов.