Головна / Статті / Проєктування компонентів фронтенду на основі принципу відповідальності, а не повторного використання

Проєктування компонентів фронтенду на основі принципу відповідальності, а не повторного використання

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

3171 слів

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

Проблема з нашим фронтендом ніколи не полягала у тому, що окремі компоненти стали занадто великими. Більшість з них були справді компактними.

У нас була міцна бібліотека з повторно використовуваними кнопками, картками, модальними вікнами, елементами таблиць, контролем форм, хуками, допоміжними засобами 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. Іноді дублювання було кращим рішенням

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

Інстинктивно хочеться негайно об’єднати їх у один спільний компонент.

Але ці решті 20 відсотків можуть містити зовсім іншу бізнес-логіку.

Можливо, одна форма перевіряє дані по-іншому.

Можливо, надсилання даних через одну форму викликає іншу послідовність дій, ніж через іншу.

Одна форма може лише зберігати чернетку.

Інша може запустити щось незворотне.

Одна форма, ймовірно, з часом стане більш складною, тоді як інша має залишатися простою.

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

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

У подібних ситуаціях часто краще просто тримати два окремі, названі елементи:

FeatureAForm
FeatureBForm

навіть якщо частина базового коду повторюється.

Це не є аргументом на користь дублювання як такої чесноти в загалі.

Як тільки справді спільна відповідальність стане очевидною та стабільною, її можна вийняти окремо. Обидві форми зрештою можуть мати спільні елементи введення, інструменти перевірки, обгортки для макетування чи інші елементи, незалежні від конкретної сфери застосування.

Справжня різниця полягає у часі, а не у принципі.

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

З цього виникла орієнтовна правило:

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

Простий копійований код легко помітний та залишається в одному місці.

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

8. У чому насправді суть: збереження змін у локальному масштабі

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

Йшлося про те, наскільки локалізованими можуть бути зміни.

Питання, яке варто ставити щоразу, було таким:

Коли ми змінюємо одну функцію, скільки непов’язаного коду нам потрібно спочатку зрозуміти?

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

Але це все одно може змусити розробника запам’ятовувати значно більшу частину всього додатку, щоб здійснити лише одну безпечну зміну.

Такі витрати рідко враховуються у будь-яких показниках повторного використання.

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

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

Це виявилося найточнішим формулюванням усієї ідеї:

Проектуйте межі компонентів навколо локальних логічних структур, а не навколо максимізації їх повторного використання.

Цей єдиний принцип поєднує все інше разом.

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

Компоненти, специфічні для певної функції, залишаються такими самими, оскільки бізнес-процеси змінюються з причин, які рідко збігаються між собою.

Композиція запобігає тому, щоб спільні компоненти мусили опановувати кожну можливу варіацію бізнес-логіки.

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

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

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

Правильні межі компонента — це ті, які можна пояснити, коли щось змінюється

Є спокуса оцінювати кодову базу фронтенду за поверхневими ознаками: активне повторне використання компонентів, мінімальне дублювання, акуратно організовані папки, невеликі розміри файлів. Ці аспекти не є беззначущими, але вони дивовижно мало говорять про те, що насправді відбувається у момент зміни вимог.

Виявилося, що це набагато корисніший критерій для оцінки.

Де насправді має знаходитися ця частина поведінки?

Який компонент відповідає за розуміння цього бізнес-правила?

Де має знаходитися цей конкретний елемент системи?

Якщо змінюється вимога, які файли логічно слід об’єднати?

Які частини інтерфейсу справді можна використовувати знову, а які призначені лише для однієї функції?

Шаблон, який у кінцевому підсумку спростив цей фронтенд, не був якоюсь винахідливою новою технікою абстрагування.

Суть полягала у тому, щоб кожен шар кодової бази мав менше елементів для контролю.

Примітиви, які можна використовувати знову, були відповідальні за візуалізацію.

Компоненти функцій відповідали за робочі процеси.

Межі композиції визначали, як функції поєднуються між собою.

А компоненти, специфічні для бізнес-логіки, мали право залишатися саме такими: специфічними.

У результаті кодова база не обов’язково мала менше компонентів чи теоретично мінімальну кількість дублікацій.

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

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

За вашим досвідом, чи більше сприяли підтримці фронтенду більш використовувані компоненти чи чіткіші межі між існуючими компонентами?

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

  • Зменшення розміру React Prop-Drilling та God Components — Дізнайтеся про сім конкретних шаблонів рефакторингу для розбирання великих React-компонентів шляхом ізоляції стану, отримання даних, прав доступу та логіки завантаження, а не просто розділення файлів.
  • Практичне порівняння шаблонів структури папок у React — Пояснює структури проектів у React, засновані на функціях, шарах та домені, та надає поради щодо вибору оптимального варіанту у міру зростання вашого додатку.
  • Проектування з урахуванням кешу «Назад/Вперед»: умови використання, відновлення та стан — Дізнайтеся, як кеш браузера «Назад/Вперед» відновлює цілі сторінки, що приховує цей процес та як керувати відновленим станом за допомогою функцій pageshow та pagehide.