Головна / Статті

Дуплікація проти з’єднання: як вирішити, коли слід існувати спільний код

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

Видавець: REACTAPP.TOP12 хв читання2806 слів

Джерела: I Don’t Want My Code to Be Reusable

Більшість розробників навчені сприймати повторюваний код як дефект: знаходять два схожі компоненти, виділяють спільний елемент та продовжують роботу. Ця реакція часто є правильною, але вона приховує певні витрати, які проявляються лише через кілька місяців, коли спільний елемент мусить обслуговувати користувачів, чиї вимоги змінилися. Якщо розглядати це рішення як компроміс між двома різними витратами — дублюванням та зв’язком між компонентами — це допомагає сформулювати кілька запитань для визначення того, яку з них слід прийняти у React, React Native та коді бекенду.

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

Як охайний спільний компонент перетворюється на мову конфігурації

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

<UserAvatar user={user} />

Інший варіант призначений для тренерів:

<TrainerAvatar trainer={trainer} />

У перший день вони майже нерозрізнені. Кожен з них відображає:

  • зображення профілю
  • замінник у разі відсутності зображення
  • однакові розміри
  • стан завантаження

Очевидний висновок полягає у тому, що один компонент має виконувати обидві функції, тому з’являється універсальний аватар:

<Avatar
  imageUrl={...}
  fallback={...}
  size="medium"
/>

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

<Avatar
  imageUrl={...}
  fallback={...}
  size="medium"
  showInitials={...}
  showVerification={...}
  showAvailability={...}
  privacyMode={...}
/>

Постійно з’являються нові вимоги, і кожна з них стає окремою проблемою. У підсумку „загальний“ аватар має інформацію про користувачів, тренерів, правила конфіденційності, процедури верифікації, доступність та різноманітну бізнес-логіку, яка не має нічого спільного з малюванням кола з зображенням всередині. Технічно його все ще можна використовувати знову, але він більше не є простим. Складність ніколи не була усунена; вона просто була зібрана в один файл, від якого тепер залежить кожен користувач. Якщо ця ситуація з накопиченням елементів здається вам знайомою, стаття „Коли повторно використовувані компоненти React призводять до проблем: ефект вибуху параметрів та способи його усунення“ детальніше розглядає процес рефакторингу таких компонентів.

Розподіл коду означає розподіл майбутнього

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

Це змінює питання, яке варто поставити. На запитання «Чи можуть ці два елементи ділитися кодом?» майже завжди можна відповісти «так». Краще запитання звучить так: Чи повинні ці два елементи ділити майбутнє?

Два блоки коду сьогодні можуть бути текстово ідентичними та все одно належати до нерелевантних частин домену. Їхнє реалізування збігається; проте їхні причини змін можуть бути різними. Саме ця відмінність краще, ніж проста кількість дубльованих рядків, дозволяє прогнозувати витрати на технічне обслуговування. Вона тісно пов’язана з принципом єдиної відповідальності, який зазвичай формулюється як «модуль має мати одну причину для змін», де причина змін — це група людей, чиї запити її спричиняють.

Коли дублювання маркапу забезпечує незалежність

Розгляньмо картку користувача:

<UserCard />

та картку для оплати:

<PaymentCard />

Наразі обидві відображають однакову структуру:

<div className="card">
  <h3>{title}</h3>
  <p>{description}</p>
</div>

Природним наступним кроком є створення спільної картки:

<Card />

Тепер уявімо, що картка для користувача часто переробляється, тоді як картка для оплати обмежується зовсім іншими вимогами до продукту та відповідності стандартам. Відповідність маркапу — це випадковість; бізнес-значення не є спільним. Тримання цих двох елементів окремо коштує кількох дубльованих рядків JSX. У взамін кожна концепція отримує простір для розвитку без необхідності переговорів. Таке дублювання забезпечує конкретну перевагу: можливість змінити один елемент без координації з власниками іншого. Якщо дивитися з цієї точки зору, два окремі компоненти — це не недбала архітектура. Вони можуть бути свідомо встановленими межами.

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

Запитуйте, чому дві речі однакові, а не як їх об’єднати

Під час роботи з повтореннями ми схильні спочатку ставити питання про те, як це абстрагувати. Спочатку інстинктивно виникає запитання «Як мені це узагальнити?». Більш корисною послідовністю є:

  • Чому ці дві речі зараз однакові?
  • Чи залишаться вони такими та з тією ж причиною?

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

Ідентичні реалізації можуть моделювати різні концепції

Візьмемо два інструменти форматування: один для користувачів:

formatUserName(user)

і один для тренерів:

formatTrainerName(trainer)

Припустимо, що обидва наразі містять однаковий вміст:

return `${firstName} ${lastName}`;

Тож їх об’єднують у один інструмент:

formatFullName(person)

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

  • переважних імен
  • налаштувань приватності
  • правил локалізації

Імена тренерів можуть потребувати:

  • професійних титулів
  • сертифікатів
  • специфічних правил формування імен для відображення

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

Абстракція — це не просто спільний код; вона кодує твердження про те, як структурована доменна сфера.

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

Ціна передчасної абстракції проявляється пізніше

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

100 lines

до:

60 lines

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

if (variant === "A") ...
else if (variant === "B") ...
else if (variant === "C") ...

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

<UniversalThing
  mode="..."
  variant="..."
  type="..."
  compact
  showHeader
  showFooter
  enableSomething
  disableSomethingElse
/>

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

Повторне використання може ускладнити зміни, а не полегшити їх

Іронія полягає у тому, що спільний код створюється для того, щоб зробити зміни дешевшими, проте надмірне його використання часто робить їх дорожчими. Причина – радіус поширення впливу: набір речей, які може вплинути одна зміна.

До появи абстракції кожна функція мала свій власний компонент:

Feature A → Component A
Feature B → Component B

Після цього кожна функціональність проходить через один спільний елемент:

Feature A ─┐
Feature B ─┼→ Shared Component
Feature C ─┘

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

Чому React спонукає до передчасного обміну

React робить вилучення коду майже без труднощів. З’являється кнопка:

<Button />

і вона стає повторно використовуваною. Потім — карточка:

<Card />

Потім — модальне вікно:

Modal />

Потім — поле форми:

FormField />

Потім — власний хук:

useSomething()

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

  • кнопка, яка послідовно відображає систему дизайну програми
  • примітиви доступності низького рівня, такі як керування фокусом чи доступні позначки
  • концепції домену, які справді є стабільними

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

React Native множить стани

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

  • переходу додатку у фоновий режим
  • поведінки клавіатури
  • втрати мережевого з’єднання
  • відмінностей між платформами iOS та Android
  • дозволів
  • глибоких посилань
  • різних розмірів пристроїв
  • стану навігації
  • режиму офлайн

Якщо все спочатку робити універсальним, то спільний компонент в кінцевому підсумку буде знати про кожне середовище, в якому може працювати. Він стає „гнучким“, але гнучкість має свою ціну: кожен новий варіант подвоює кількість можливих станів. Кожен з цих станів — це те, що людина зрештою мусить зрозуміти, протестувати та підтримувати. Два варіанти створюють чотири комбінації; п’ять — тридцять дві.

Сервіси бекенду потрапляють у ту саму пастку

Це проблема, яка стосується не лише фронтенду. Уявіть два сервіси NestJS:

UserService
TrainerService

Спочатку вони можуть надавати однакові операції:

create()
findById()
update()
delete()

Генеричний базовий клас здається привабливим варіантом:

BaseService<T>

Іноді це спрацьовує та дозволяє заощадити код. Але як тільки бізнес-правила для користувачів та тренерів починають відрізнятися, базовий клас починає генерувати винятки. Спочатку перевірка типу:

if (entityType === "user") ...

потім функція, яку можна перевизначити перед оновленням:

protected beforeUpdate(...)

потім ще одна після створення:

protected afterCreate(...)

І поступово з’являється ціла низка точок розширення, єдиною метою яких є змусити одну універсальну службу поводитися по-різному в кожній доменній області. Це замінює дубльований код на умовну складність, яку зазвичай набагато важче зрозуміти, адже для розуміння поведінки однієї сутності тепер потрібно читати базовий клас, його хуки та всі перевизначення разом. Якщо це звучить знайомо, обговорення структурування доменів NestJS за правилами DDD пропонує додатковий погляд на те, де мають знаходитися межі.

Це не аргумент на користь копіювання та вставки

Висновок про те, що дублювання — це добре, був би таким само спрощеним. У дублювання є реальні витрати:

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

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

Три запитання перед створенням абстракції

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

Чи змінюються вони з однієї причини?

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

Чи означають вони одне й те саме?

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

type Amount = number;

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

Чи це усуває дублювання чи просто видаляє рядки?

Це різні наслідки. Хороша абстракція усуває дублювання концепцій. Погана ж лише скорочує розмір файлів. Витягнення 30 рядків з двох компонентів у допоміжний файл об’ємом 40 рядків із вісьмома параметрами не обов’язково щось покращує; складність просто перемістилася та додала необхідність підтримки інтерфейсу.

Навмисне дублювання — це легітимний вибір

Цілком доцільно залишати два фрагменти коду окремими, коли вони:

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

Причина залишати їх у спокої полягає не в браку навичок роботи з абстракціями, а у розумінні того, яку ціну може коштувати абстракція. Здатність бачити повторення та вирішувати «поки що ні» — це ознака зрілості, а не лінощів. Не кожне повторення є технічним боргом. Іноді це просто повторення.

Коли дублювання стає ознакою попередження

Інша сторона також має велике значення. Деяке дублювання є чітким сигналом до об’єднання:

  • одне й те саме складне бізнес-правило, скопійоване в кількох місцях
  • три додатки, кожен з яких реалізує однаковий процес автентифікації
  • кілька команд, які повинні дотримуватися одного контракту API
  • зміна правила, яка вимагає запам’ятовування десяти окремих місць зберігання

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

DRY завжди стосувався знань

Фразу „Не повторюйте себе“ часто тлумачать як „ніколи не писати один і той самий код двічі“. Оригінальне формулювання з книги The Pragmatic Programmer стосується саме знань: кожен елемент знань має мати єдине, авторитетне представлення в системі. Це різні правила.

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

Дозвольте абстракції заробити собі місце

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

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

Абстракція має ґрунтуватися на тому, яким ви зараз розумієте код, а не на тому, яким ви уявляєте його майбутній стан.

Метою є змінний дизайн, а не код, який можна повторно використовувати

Повторне використання саме по собі не є корисним показником якості архітектури. Кращими показниками є:

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

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

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