Проектирование компонентов фронтенда с учетом ответственности, а не повторного использования
Узнайте, почему организация компонентов фронтенда с четким распределением ответственности — а не максимальным повторным использованием кода — облегчает изоляцию и поддержку изменений функций.
Фронтенд становится проще в обслуживании, когда границы компонентов определяются на основе четких обязанностей, а не на основе того, сколько кода теоретически можно совместно использовать.
Проблема с нашим фронтендом никогда не заключалась в том, что отдельные компоненты стали слишком большими. Большинство из них действительно были компактными.
У нас была хорошо организованная библиотека из повторно используемых кнопок, карточек, модалов, элементов таблицы, контролей формы, хуков, инструментов API, общих схем, а также четкое разделение папок. При просмотре репозитория всё казалось хорошо упорядоченным. Повторное использование было распространено, очевидных дубликатов было мало, а уровень абстракции придавал кодовой базе вид зрелости.
Настоящая проблема возникала каждый раз, когда требовалось изменить какую-либо функциональность.
Даже незначительное требование может заставить нас одновременно искать решение среди нескольких не связанных между собой компонентов: компонента уровня экрана, универсальной формы, логики, заключенной в хук, кода сетевых операций, диалогового окна, предназначенного для множества сценариев использования, и схемы валидации входных данных. Компонент, изначально созданный для совместного использования из-за схожести двух экранов, постепенно накапливал различные флаги, параметры обратных вызовов, условные правила валидации, альтернативные макеты и специальные случаи работы, о которых он вообще не должен был знать.
Код был переиспользуемым, однако ответственность за его работу была распределена по всей системе.
То, что в конечном итоге упростило фронтенд, — это не новая схема названий папок, другая библиотека управления состоянием или строгий лимит количества строк в компоненте. Это было изменение нашего представления о том, что на самом деле должно обозначать граница компонента.
Вместо того чтобы задавать только вопрос:
Можно ли использовать этот компонент в других местах?
Мы начали задаваться вопросом:
За что именно должен отвечать этот компонент?
И второй вопрос быстро стал таким же важным:
Когда требуется изменить его функции, сколько нерелевантного кода оказывается вовлечено вместе с ним?
Этот вопрос привёл к тому, что наша архитектура преобразовалась в простую иерархию, при которой средний слой несёт наибольшую нагрузку.
Повторно используемые примитивы отвечали исключительно за визуальное представление. Компоненты функций содержали информацию о бизнес-процессах. Страницы и другие границы композиции определяли, как все элементы соединяются вместе.
Работа над фронтендом стала проще, не потому что мы устранили всю дублированность, а потому что изменения на уровне функций стали более локализованными, и меньшему количеству частей кодовой базы требовалось понимать друг друга.
1. Повторное использование — это не то же самое, что простота
«Избегайте дублирования компонентов» — это совет, который в целом кажется правильным.
И часто им действительно является.
Когда два экрана используют одну и ту же кнопку, поле ввода или модальное окно, объединение их реализации снижает несоответствия и необходимость дополнительного технического обслуживания.
Проблемы возникают, когда визуальное сходство принимают за общую ответственность.
Представьте две отдельные функции, каждая из которых требует диалогового окна подтверждения.
Первая реализация может выглядеть примерно так:
<ConfirmationDialog
title="Delete project?"
onConfirm={deleteProject}
/>
Затем другой рабочий процесс требует того же диалогового окна, только без кнопки отмены.
В третьем случае требуется собственное сообщение об опасности.
В ещё одном случае необходимо выполнить асинхронную проверку перед тем, как пользователь сможет подтвердить действие.
Вскоре компонент превращается в нечто подобное этому:
<ConfirmationDialog
title="Delete project?"
variant="danger"
showCancel
disableConfirm={isDeleting}
customWarning={warning}
onBeforeConfirm={validateDeletion}
onConfirm={deleteProject}
onSpecialAction={archiveInstead}
useLegacyLayout={false}
/>
С технической точки зрения компонент по-прежнему используется повторно.
Но с архитектурной точки зрения он превратился в своего рода язык конфигурации.
Каждый новый рабочий процесс требует от общего компонента поддержки ещё одной вариации. Он становится более гибким, но эта гибкость достигается за счёт увеличения количества скрытых поведений, расположенных за постоянно растущим набором параметров.
Именно здесь стоимость абстракции отличается от стоимости дублирования.
Дублирование проявляется сразу же: два практически идентичных компонента находятся рядом друг с другом в репозитории, что легко видно любому, кто читает код.
Плохо подобранная абстракция на первый взгляд кажется дешевой. Ее реальная стоимость проявляется позже, когда функции, которые она обслуживает, начинают развиваться в разных направлениях.
Дублирование стоит вам того, что вы можете сразу увидеть. Неправильный выбор абстракции стоит вам того, что вы замечаете лишь тогда, когда «похожие» функции перестают меняться синхронно.
Это осознание изменило наш подход к оценке возможностей повторного использования.
Повторяющийся JSX больше не вызывал автоматических подозрений. Более важным стало вопрос о том, действительно ли два фрагмента кода разделяют одну и ту же ответственность или просто совпадают по форме в данный момент.
2. Мы начали проектировать компоненты с учетом ответственности
Самым значительным архитектурным изменением стало то, что дерево компонентов теперь отражает реальную ответственность, а не стремится к возможностям повторного использования.
Возьмём в качестве примера процесс настройки.
Каждый слой в этом процессе существует по своей уникальной причине.
UserSettingsPage объединяет данную функциональность с остальной частью приложения. Он может знать информацию о маршрутизации, лейауте страницы или о том, какая учётная запись в настоящее время редактируется.
UserSettingsForm содержит знания о процессе работы. Он понимает, какие данные относятся к настройкам пользователя, как поля связаны между собой, что означает отправка данных и как следует отображать ошибки пользователю.
Button, Input и Checkbox даже не подозревают о существовании настроек пользователя. Они остаются многоразово используемыми именно потому, что их функции действительно универсальны.
Именно этот промежуточный слой функциональности обычно первым теряется в командах.
В фронтенде, где слишком активно применяется принцип повторного использования, разработчики часто переходят сразу от страницы к универсальным компонентам, игнорируя любой слой, связанный с конкретными функциями.
Когда это происходит, бизнес-логика теряет четкое место реализации.
В итоге она просачивается в настройки.
Универсальная форма начинает понимать, что для одного рабочего процесса требуется поле с именем пользователя, а для другого — нет. Универсальная таблица начинает знать, какие действия допустимы для определенного типа объекта домена. Общий модальный окно приобретает специальное поведение просто потому, что содержимое одного рабочего процесса отображается внутри него.
Слой повторного использования постепенно впитывает знания о бизнес-логике, которые изначально не предназначался для хранения.
Проектирование с учетом принципа ответственности снимает этот давление.
Компонентам, связанным с конкретными функциями, предоставляется возможность понимать ту функцию, которую они обслуживают.
Примитивы, предназначенные для многократного использования, намеренно не осведомлены о чем-либо, связанном с конкретными функциями.
Такое разделение облегчает понимание всей архитектуры, поскольку каждый слой должен хранить лишь ограниченный объем контекста.
3. Компоненты специального назначения заслужили своё место
Одной из самых сложных привычек, от которых нужно было избавиться, была предпосылка о том, что компонент, используемый только в одном месте, является недостатком дизайна.
Возьмём такой пример:
<OrderCancellationDialog />
Уже само название говорит о том, что этот компонент создан для одного конкретного рабочего процесса.
Сравните это с чем-то вроде:
<ConfirmationDialog />
Это второе название создаёт впечатление большей возможности многократного использования.
Однако отмена заказа в конечном итоге может потребовать гораздо большего, чем простое подтверждение.
Пользователю может потребоваться выбрать причину отмены. На экране могут отображаться детали возврата средств. Некоторые заказы могут не подлежать отмене. Проверки разрешений могут отличаться. Текст предупреждения может меняться в зависимости от статуса выполнения заказа. Само действие отмены может иметь собственную асинхронную обработку ошибок.
Если объединить всё это в универсальный компонент ConfirmationDialog, то этот общий компонент постепенно станет специалистом в том, что на самом деле означает отмена заказа.
Более оптимальная структура обычно выглядит так:
OrderCancellationDialog берет на себя управление всем процессом.
Ему разрешено знать о разрешениях, причинах отмены, асинхронном статусе, тексте возврата средств и состояниях ошибок, поскольку всё это естественным образом связано между собой.
Тем временем более низкоуровневые элементы остаются многоразово используемыми именно потому, что они совсем не содержат информации о заказах.
Это разделение изменило количество принимаемых решений относительно компонентов.
Больше не было необходимости обосновывать существование компонента путем подсчёта количества мест, где он импортируется.
Повторное использование — не обязательное требование для каждого компонента. Некоторые компоненты существуют исключительно для того, чтобы предоставить подходящее место для реализации определённой части бизнес-логики.
Компонент с единственным способом использования всё равно может оказаться полезным, если он помогает легче находить, понимать и впоследствии изменять рабочий процесс.
Такая локальная ясность часто перевешивает издержки, связанные с принудительным включением второго, несвязанного рабочего процесса в общую абстракцию лишь из-за схожести внешних API.
4. Бизнес-логика не вмешивалась в компоненты отображения
Та же проблема с ответственностями снова возникла в вопросах обработки данных и инфраструктуры.
Компонент, начинающийся как чисто визуальный элемент, может постепенно брать на себя такие функции, как загрузка данных, обработка параметров URL, выполнение изменений, отправка уведомлений, управление навигацией, работа с кэшем и отслеживание состояния рабочего процесса.
Представьте себе ProjectTable, который в конечном итоге будет обрабатывать всё это:
ProjectTable
├── fetch projects
├── read query parameters
├── filter projects
├── manage loading state
├── render rows
├── delete projects
├── show notifications
└── navigate after actions
Название по-прежнему намекает на «таблицу».
Но теперь компонент понимает значительную часть функционала.
Это затрудняет его повторное использование в других местах, поскольку при повторном использовании таблицы автоматически переносятся предположения относительно загрузки данных, изменений, навигации, кэширования и побочных эффектов.
Структура, организованная по принципу ответственностей, может выглядеть так:
Код на уровне функций определяет, как происходит «загрузка», как обрабатываются ошибки, что на самом деле происходит при удалении данных, какие фильтры применяются к конкретному рабочему процессу и следует ли после этого перенаправлять пользователя.
ProjectTable сам по себе занимается только отображением данных проекта и фиксацией действий пользователя.
Это не означает, что компоненты представления должны быть лишены всей логики.
Рассматривание этого как строгого правила лишь приводит к тому же проблеме в другой форме. Таблица может законно хранить информацию о локальном состоянии взаимодействия, например, какие строки раскрыты или какие столбцы видны, поскольку это состояние действительно принадлежит таблице.
Лучшая руководящая идея заключается в следующем:
Не позволяйте знаниям на уровне инфраструктуры распространяться дальше по иерархии компонентов, чем это действительно необходимо для функции.
Цель не в том, чтобы полностью устранить логику из компонентов.
Цель — размещать логику рядом с той функцией, которая придает ей смысл.
5. Сборка элементов вместо настройки опций
Одно из самых заметных изменений произошло тогда, когда мы перестали отвечать на каждое новое требование добавлением еще одного свойства.
У универсальных компонентов есть тенденция к росту за счет настроек.
Таблица может начинаться просто:
<DataTable rows={projects} columns={columns} />
Затем требования начинают накапливаться:
<DataTable
rows={projects}
columns={columns}
selectable
sortable
paginated
editableRows
showBulkActions
enableExport
customToolbar={toolbar}
rowActions={rowActions}
emptyState={emptyState}
onSelectionChange={handleSelection}
/>
Ни одно из этих свойств само по себе не является неразумным.
Проблемы возникают, когда компоненту приходится отслеживать, какие комбинации свойств вообще допустимы.
Могут ли строки быть одновременно редактируемыми и выбираемыми?
Должны ли отображаться групповые действия, если экспорт отключен?
Заменяет ли пользовательская панель инструментов стандартную или располагается рядом с ней?
Обрабатывается ли пагинация на стороне клиента или на сервере?
Каждый добавленный логический параметр и функция обратного вызова увеличивает количество состояний, с которыми должен считаться универсальный компонент.
Композиция переносит часть этой ответственности обратно на вызывающий код:
<Table>
<TableToolbar>
<ProjectFilters />
<ExportButton />
</TableToolbar>
<ProjectRows projects={projects} />
<Pagination />
</Table>
Теперь родительский компонент решает, какие элементы действительно необходимы для конкретного рабочего процесса.
Основные примитивы таблицы не должны учитывать каждую специфическую вариацию продукта, которая может появиться в будущем.
Конфигурация требует, чтобы компонент предусматривал все возможные комбинации. Композиция позволяет вызывающему коду создавать только те комбинации, которые ему действительно нужны.
Композиция не предотвращает автоматически создание бессмысленных структур. Звонящий код всё равно может сгенерировать некорректную комбинацию.
Изменяется не само содержимое: теперь общий компонент больше не должен самостоятельно кодировать каждую специфическую для бизнеса комбинацию параметров.
Некоторые настройки всё ещё можно без проблем сохранять. Например, повторно используемый Button должен поддерживать единообразные опции, такие как размер, состояние отключения или визуальное выделение.
Настоящий вопрос заключается в том, представляет ли данный параметр ещё одну вариацию той же основной функции или же он незаметно заставляет один компонент выполнять несколько независимых задач одновременно.
6. Чёткое распределение ответственности упрощает принятие решений по состоянию
Большинство проблем с состоянием фронтенда на первый взгляд кажутся проблемами инструментария.
Обсуждение обычно превращается в спор о механизмах:
Должно ли это храниться в состоянии компонента, Context, Zustand, Redux, URL или в серверной кэше?
Однако на практике многие из этих проблем решаются сами по себе, как только становится ясно, кто на самом деле отвечает за данное поведение.
Состояние склонно накапливаться, когда неясно, у кого оно находится:
Модальное окно изначально имеет своё собственное состояние внутри него.
Затем ему также нужно, чтобы его активировал какой-то другой компонент, поэтому состояние перемещается к нему.
Позже ему нужен доступ и инструментарий, находящийся далеко в структуре приложения, поэтому состояние попадает в контекст этого инструментария.
Вскоре один глобальный хранилище начинает содержать флаги видимости модальных окон, незавершённые черновики форм, настройки фильтров таблиц, кэшированные ответы API, активные вкладки и различную информацию из функций, не связанных друг с другом.
В такой момент люди обычно винят библиотеку управления состоянием в возникшем беспорядке.
Но на самом деле проблема обычно не в инструменте, а в том, у кого находится ответственность за состояние.
Как только границы компонентов будут отражать реальные обязанности, для хранения состояния найдется логичное место.
Все, что пользователь в настоящее время вводит в поле, может оставаться внутри этого поля.
Многоэтапный процесс отмены должен находиться внутри функции отмены.
Все, что загружается с сервера, должно храниться там, где ваше приложение обрабатывает вопросы серверного состояния.
Фильтр, который должен сохраняться при навигации или быть доступным по ссылке, скорее всего, должен находиться в URL.
Состояние, которое действительно используется во многих не связанных между собой частях приложения, может потребовать более широкого и централизованного управления.
Ничто из этого не делает принятие решений по состоянию ненужным, но оно предоставляет структуру для их принятия.
Как только границы компонентов отражают реальную ответственность за данные, определение места хранения состояния становится гораздо проще.
Такая модель мышления принесла команде больше пользы, чем любая попытка выбрать одну «правильную» библиотеку состояний.
7. Иногда дублирование оказывается лучшим решением
Самым сложным в принятии этого подхода было осознание того, что определённое количество дублирования — это нормально, даже хорошо.
Представьте два формуляра, похожих друг на друга почти полностью, которые используют примерно 80 процентов одинакового маркировочного кода и структуры.
Инстинктивно хочется сразу объединить их в один общий компонент.
Но эти оставшиеся 20 процентов могут содержать совершенно разную бизнес-логику.
Возможно, один формуляр выполняет проверки по-другому.
Возможно, отправка данных через один из них запускает другую последовательность действий, чем через другой.
Один из формуляров может просто сохранять черновик.
Другой же может запустить процесс, от которого невозможно вернуться назад.
Со временем один формуляр, скорее всего, станет более сложным, в то время как другой должен оставаться максимально простым.
Попытки заставить оба решения работать в рамках одной общей реализации часто приводят к созданию структуры, напоминающей лабиринт из условий и специальных случаев, встроенных в один «повторно используемый» компонент.
В таком случае дублирование JSX заменяется дублированием когнитивной нагрузки: любой, кто работает с этими процессами, вынужден разбираться, как общий компонент защищает другой, прежде чем сможет что-либо изменить.
В подобных ситуациях часто разумнее просто сохранить два отдельных, названных компонента:
FeatureAForm
FeatureBForm
даже если часть базового кода повторяется.
Это не является аргументом в пользу дублирования как такового.
Как только станет очевидной и устойчивой реальная необходимость совместного использования кода, его можно вынести в отдельный компонент. В конечном итоге оба формата могут использовать одни и те же элементы ввода, инструменты проверки, обертки для макетирования или другие компоненты, независимые от конкретной области применения.
Настоящая разница заключается во времени, а не в принципе.
Вместо того чтобы при первом сходстве двух элементов обращаться к абстракциям, имело больше смысла дождаться, пока общая граница действительно проверится временем.
Из этого возникло следующее правило:
Лучше дублировать простой код, чем использовать логику, основанную на предположениях, которые все еще находятся в процессе развития.
Простой дублированный код легко различим и находится в одном месте.
Абстракция, созданная слишком рано, может скрытно объединить несколько предположений, связанных с конкретными функциями, под одним обманчиво простым названием, скрывая ту самую сложность, которую вы пытались устранить.
8. В чем на самом деле суть: ограничение изменений локально
В конце концов стало ясно, что вся эта подходка на самом деле не касалась размера или возможности повторного использования компонента.
Речь шла о том, насколько локализованными могут быть изменения.
Вопрос, который стоит задавать каждый раз, звучал так:
Когда мы меняем одну функцию, сколько не связанного с ней кода нам сначала нужно понять?
Дизайн с большим количеством общих, универсальных компонентов технически может иметь меньше дублирующихся строк, чем дизайн с большим количеством компонентов, специфичных для конкретной функции.
Но это всё равно может заставить разработчика запоминать гораздо большую часть всего приложения, чтобы совершить хотя бы одну безопасную правку.
Такие затраты редко отражаются в каких-либо показателях повторного использования.
У вас может быть прекрасно подходящий для повторного использования компонент, который тем не менее создаёт ужасные ограничения при внесении изменений.
Напротив, компонент, созданный исключительно для одной функции и используемый только в одном месте, тем не менее может улучшить поддерживаемость кодовой базы, поскольку он делает этот конкретный рабочий процесс автономным и понятным для анализа.
В итоге это стало наиболее точным формулированием всей идеи:
Определяйте границы компонентов исходя из локальных принципов работы, а не с целью максимизации повторного использования.
Этот единственный принцип связывает всё остальное воедино.
Повторно используемые примитивы находят своё место потому, что шаблоны представления действительно повторяются в разных частях кодовой базы.
Компоненты, предназначенные для конкретных функций, остаются специфичными, поскольку бизнес-процессы меняются по причинам, которые редко совпадают друг с другом.
Композиция позволяет общим компонентам не изучать каждую возможную вариацию бизнес-логики.
Состояние остаётся там, где фактически контролируется соответствующее поведение.
Дублирование становится приемлемым именно тогда, когда совместное использование кода приводит к объединению предположений, которые и так постепенно расходятся друг от друга.
Фронтенд становится проще не потому, что каждый компонент уменьшился в размерах или стал более переиспользуемым, а потому, что каждый из них требует меньше усилий от того, кто его читает.
Правильные границы компонента — это такие, которые можно объяснить при изменении чего-либо
Соблазнительно оценивать кодовую базу фронтенда по поверхностным признакам: активное переиспользование компонентов, минимальное дублирование, аккуратно организованные папки, небольшие размеры файлов. Эти факторы не бесполезны, но они удивительно мало говорят о том, что на самом деле происходит в момент изменения требований.
Оказалось, что это гораздо более полезный критерий для оценки.
Куда на самом деле относится этот фрагмент поведения?
Какой компонент отвечает за понимание этого бизнес-правила?
Где должен находиться этот конкретный элемент кода?
Если меняются требования, какие файлы логически следует объединить?
Какие части интерфейса действительно могут быть повторно использованы, а какие предназначены только для одной функции?
Шаблон, который в итоге упростил этот фронтенд, не был какой-то изощрённой новой техникой абстракции.
Дело сводилось к тому, чтобы у каждого слоя кодовой базы было меньше элементов, за которыми нужно следить.
Повторно используемые примитивы отвечали за визуальное представление.
Компоненты функций отвечали за рабочие процессы.
Границы композиции определяли, как функции взаимодействуют друг с другом.
А компоненты, связанные с бизнес-логикой, получили право оставаться именно такими: специфичными.
В результате кодовая база не обязательно становилась более простой по количеству компонентов или имела минимальное количество дублирования.
В итоге получилась база кода, в которой разработчик мог работать над отдельной функциональностью, не обязанный сначала запоминать всю архитектуру фронтенда.
Это оказалось гораздо более практичным определением простоты, чем подсчет компонентов или дублирующихся строк.
Исходя из вашего личного опыта, что больше способствует поддерживаемости фронтенда: больше переиспользуемых компонентов или более четкие границы между существующими компонентами?
Связанные статьи
- Сокращение размера React-компонентов с помощью устранения проблемы передачи свойств и «боговских» компонентов — Узнайте о семи конкретных шаблонах рефакторинга для разбиения объемных React-компонентов путем изоляции состояния, загрузки данных, прав доступа и логики загрузки, а не просто разделения файлов.