Duplikacja kontra połączenie: jak zdecydować, kiedy powinien istnieć wspólny kod
Dowiedz się, dlaczego łączenie podobnych komponentów React lub usług NestJS może kosztować więcej niż ich kopiowanie, oraz wykorzystaj trzy pytania, aby zdecydować, gdy abstrakcja zasługuje na swoje miejsce.
Większość programistów jest szkolona tak, by traktować powtarzający się kod jako wadę: znajdują dwa podobne komponenty, wyodrębniają wspólny element i przechodzą dalej. Ten odruch jest często słuszny, ale ukrywa koszt, który ujawnia się dopiero po kilku miesiącach, gdy wspólny element musi służyć użytkownikom, których wymagania się zmieniły. Przedstawienie decyzji jako wyboru pomiędzy dwoma różnymi kosztami – duplikacją a powiązaniem – daje nam małą serię pytań, które pomagają zdecydować, który z nich należy ponieść w kodzie React, React Native oraz backendowym.
Główne twierdzenie jest proste, ale łatwe do błędnego zrozumienia: powtarzanie się nie jest cnotą, ale w niektórych sytuacjach duplikacja jest tańsza niż powiązanie. Poniżej znajduje się przewodnik po rozpoznawaniu takich sytuacji.
Jak uporządkowany wspólny komponent zamienia się w język konfiguracji
Zacznijmy od dwóch komponentów, które wyświetlają zdjęcie osoby. Jeden z nich jest przeznaczony dla zwykłych użytkowników:
<UserAvatar user={user} />
Drugi typ jest przeznaczony dla trenerów:
<TrainerAvatar trainer={trainer} />
Od pierwszego dnia są niemal nierozróżnialne. Każdy z nich wyświetla:
- obraz profilowy
- zastępstwo w przypadku braku obrazu
- identyczne wymiary
- stan ładowania
Oczywistym wnioskiem jest to, że jeden komponent powinien pełnić obie funkcje, dlatego pojawia się uniwersalny awatar:
<Avatar
imageUrl={...}
fallback={...}
size="medium"
/>
Wydaje się to oczywistym zwycięstwem: mniej kodu, jedno miejsce do naprawiania błędów. Następnie produkt ewoluuje. Użytkownicy potrzebują zastępcy, który szanuje ustawienia prywatności. Trenerzy potrzebują odznaki weryfikacji. Użytkownicy mogą korzystać z inicjałów. Trenerzy pokazują, czy są obecnie dostępni. Każda prośba trafia do wspólnego komponentu jako kolejna właściwość:
<Avatar
imageUrl={...}
fallback={...}
size="medium"
showInitials={...}
showVerification={...}
showAvailability={...}
privacyMode={...}
/>
Ciągle pojawiają się nowe wymagania, a każde z nich staje się dodatkowym elementem do obsługi. W końcu „ogólny” awatar musi uwzględniać informacje o użytkownikach, trenerach, zasadach prywatności, procesach weryfikacji, dostępności oraz różnorodną logikę biznesową, która w ogóle nie ma związku z rysowaniem kółka z obrazkiem w środku. Choć technicznie nadal można go wykorzystywać, nie jest już prosty w użyciu. Złożoność nigdy nie została usunięta – po prostu została zgromadzona w jednym pliku, od którego teraz zależy każdy element aplikacji. Jeśli ten wzorzec eskalacji liczby właściwości wydaje się znajomy, artykuł na temat kiedy wielokrotnie używane komponenty React przynoszą negatywne skutki omawia refaktoryzację takich komponentów w większym stopniu szczegółowo.
Dzielenie się kodem oznacza dzielenie się przyszłością
Kwestią, o której rzadko się mówi, jest to, że wyodrębnianie wspólnego kodu to nie tylko decyzja dotycząca implementacji. Łączy ono przyszłość użytkowników tych komponentów. Gdy zarówno komponent A, jak i B polegają na jednej abstrakcji, każda zmiana wprowadzona w A może teraz uszkodzić lub zmienić strukturę B. Ponowne użycie kodu tworzy relację, a relacje oznaczają powiązania.
To zmienia pytanie, które warto zadać. Na „Czy te dwa elementy mogą dzielić się kodem?” można niemal zawsze odpowiedzieć twierdząco. Lepszym pytaniem jest: Czy te dwa elementy powinny dzielić przyszłość?
Dwa bloki kodu mogą dziś być identyczne pod względem tekstu, a mimo to należeć do niespowiązanych części domeny. Ich implementacja się pokrywa; ich powody zmiany mogą być różne. Ta różnica znacznie lepiej przewiduje koszty konserwacji niż liczba powtórzonych linii. Jest ściśle związana z zasadą pojedynczej odpowiedzialności, zwykle sformułowaną jako „moduł powinien mieć jeden powód do zmiany”, gdzie powodem zmiany jest grupa osób, których żądania ją napędzają.
Gdy powtórzone elementy markup zapewniają niezależność
Rozważmy kartę użytkownika:
<UserCard />
i kartę płatniczą:
<PaymentCard />
W tej chwili obie wyświetlają tę samą strukturę:
<div className="card">
<h3>{title}</h3>
<p>{description}</p>
</div>
Naturalnym następnym krokiem jest stworzenie wspólnej karty:
<Card />
Załóżmy teraz, że karta przeznaczona dla użytkownika jest często przeprojektowywana, podczas gdy karta płatnicza jest ograniczona zupełnie innymi wymaganiami produktowymi i regulacyjnymi. Zgodność struktury markup jest przypadkowa; znaczenie biznesowe nie jest wspólne. Trzymanie tych dwóch elementów oddzielnie powoduje mnóstwo duplikowanych linii JSX. W zamian każda z tych koncepcji ma miejsce, w którym może się rozwijać bez konieczności negocjacji. Ta duplikacja daje coś konkretnego: możliwość zmiany jednej z nich bez koordynacji z osobami odpowiedzialnymi za drugą. Patrząc w ten sposób, dwa oddzielne komponenty nie stanowią nieudolnej architektury – mogą być celowym rozgraniczeniem.
Istnieje ważna niuans w przypadku Reacta. Czysto wizualna struktura, stylizowany kontener bez żadnego znaczenia domenowego, może być często bezpiecznie udostępniany, ponieważ powodem jego zmiany jest system projektowy, a nie funkcja produktu. Pułapką jest sytuacja, gdy udostępniona część zaczyna przyjmować decyzje domenowe należące do konkretnego użytkownika.
Zadawaj pytanie, dlaczego dwie rzeczy są takie same, a nie jak je połączyć
Pod wpływem doświadczenia pierwsze pytanie, które zadajemy przy widoku powtórzeń, ma tendencję do zmiany. Na początku instynkt podpowiada: „Jak to uogólnić?”. Bardziej przydatna sekwencja jest następująca:
- Dlaczego te dwie rzeczy są teraz takie same?
- Czy pozostaną takie same i z tego samego powodu?
Druga kwestia jest trudniejsza, ponieważ zmusza nas do przestania porównywać składnię i zaczęcia analizowania intencji.
Tożsame implementacje mogą reprezentować różne koncepcje
Weźmy dwa narzędzia do formatowania, jedno dla użytkowników:
formatUserName(user)
i jedno dla trenerów:
formatTrainerName(trainer)
Załóżmy, że oba obecnie zawierają ten sam treść:
return `${firstName} ${lastName}`;
Wtedy łączą się w jedno narzędzie:
formatFullName(person)
Wydaje się to nieszkodliwe, dopóki zasady nie różnią się. Nazwy użytkowników mogą wymagać uwzględnienia:
- preferowanych nazw
- ustawień prywatności
- zasad lokalizacji
Nazwy trenerów mogą wymagać:
- tytułów zawodowych
- certyfikatów
- sposobów wyświetlania nazw
Dwie początkowe funkcje mogłyby rozwijać się niezależnie bez konfliktów. Zamiast tego uniwersalne narzędzie zakłada, że użytkownicy i trenerzy to ten sam typ „osoby” w celach nadawania nazw, co już nie jest prawdą. To mniej oczywiste ryzyko abstrakcji:
Abstrakcja nie tylko dzieli kod; koduje również informację o tym, jak jest zorganizowany dany obszar.
Gdy ta informacja jest błędna, abstrakcja aktywnie wprowadza w błąd kolejnego programistę, który słusznie zakłada, że cokolwiek o nazwie formatFullName odnosi się do każdej osoby w systemie.
Koszty przedwczesnej abstrakcji pojawiają się później
Przedwczesna abstrakcja jest atrakcyjna, ponieważ każda widoczna metryka poprawia się w dniu jej wprowadzenia. Rozmiar pliku zmniejsza się z czegoś w rodzaju:
100 lines
na:
60 lines
Znikają duplikaty, prośba o połączenie wygląda lepiej, a abstrakcja wydaje się elegancka. Koszty są odkładane. Po roku ktoś musi zmienić zachowanie jednego użytkownika i odkrywa, że wspólny komponent jest używany jeszcze przez siedmiu innych. Zamiast ryzykować uszkodzenie pozostałych, tworzy nową gałąź:
if (variant === "A") ...
else if (variant === "B") ...
else if (variant === "C") ...
Następnie pojawia się kolejna właściwość, kolejny flag, ścieżka kompatybilności dla starszego ekranu. Abstrakcja przetrwa, ale jej koncepcyjna prostota nie. W ten sposób bazy kodu wypełniają się komponentami, których interfejs wygląda w ten sposób:
<UniversalThing
mode="..."
variant="..."
type="..."
compact
showHeader
showFooter
enableSomething
disableSomethingElse
/>
W tym momencie komponent przestaje być abstrakcją i staje się małym językiem konfiguracji dla kilku niepowiązanych przypadków użycia. Każda kombinacja tych właściwości to stan, nad którym ktoś musi się zastanowić, a większość kombinacji nigdy nie była testowana.
Ponowne użycie może utrudnić zmiany, a nie ułatwić je
Ironią jest to, że kod współdzielony tworzy się po to, by zmiany były tańsze, jednak nadmierne dzielenie się często sprawia, że stają się one droższe. Powodem jest zasięg wpływu: zbiór elementów, na które może wpłynąć jedna zmiana.
Zanim pojawiła się abstrakcja, każda funkcjonalność miała swój własny komponent:
Feature A → Component A
Feature B → Component B
Po tym każda funkcjonalność przechodzi przez jeden wspólny element:
Feature A ─┐
Feature B ─┼→ Shared Component
Feature C ─┘
Drugi diagram zawiera mniej powtórzonych fragmentów kodu, ale silniejszą sieć zależności. Dlatego użyteczne porównanie to nie „która wersja ma mniej duplikatów?”, lecz „która wersja sprawia, że koszt przyszłych zmian jest bardziej przewidywalny?” Gdy zmiana w funkcjonalności B musi zostać przetestowana pod kątem regresji w odniesieniu do funkcjonalności A i C, wspólny komponent sprawia, że zmiana staje się mniej przewidywalna, mimo że skraca kod.
Dlaczego React zachęca do przedwczesnego dzielenia się
React sprawia, że wyodrębnianie elementów jest niemal bezproblemowe. Pojawia się przycisk:
<Button />
i staje się on ponownie użyteczny. Następnie karta:
<Card />
Potem okno modalne:
Modal />
Następnie pole formularza:
FormField />
Potem własny hook:
useSomething()
Niedługo potem powstaje wewnętrzna biblioteka komponentów używana we całym aplikacji. Duża jej część jest rzeczywiście cenna. Istnieją elementy, które wyraźnie powinny być udostępniane:
- przycisk, który spójnie odzwierciedla system projektowy aplikacji
- proste narzędzia dostępności niskiego poziomu, takie jak zarządzanie fokusem lub dostępne etykiety
- koncepcje domeny, które są rzeczywiście stabilne
Samo ponowne użycie nie stanowi problemu. Problemem jest ponowne wykorzystywanie elementów tylko dlatego, że wyglądają podobnie. Proste elementy interfejsu działają dobrze, gdy nie zawierają reguł biznesowych – kwestia ta jest również omawiana w artykule projektowanie komponentów w oparciu o odpowiedzialności, a nie o możliwość ponownego użycia.
React Native pomnaża liczby stanów
Aplikacje mobilne dodają kolejny wymiar. Dwa ekrany mogą wyglądać podobnie, choć mają zupełnie różne wymagania dotyczące cyklu życia aplikacji. Komponent, który dobrze funkcjonuje na jednym ekranie, może później musieć radzić sobie z:
- przenoszeniem aplikacji w tło
- behawiorem klawiatury
- przerwami w połączeniu sieciowym
- różnicami pomiędzy platformami iOS i Android
- uprawnieniami
- głębokimi linkami
- różną wielkością urządzeń
- stanem nawigacji
- trybem offline
Jeśli wszystko zostanie zaprojektowane w sposób uniwersalny od samego początku, wspólny komponent będzie musiał radzić sobie z każdym środowiskiem, w którym może zostać uruchomiony. Staje się „elastyczny”, a elastyczność ma swoją cenę: każda nowa opcja pomnaża liczbę możliwych stanów. Każdy z tych stanów to coś, co ktoś w końcu musi zrozumieć, przetestować i utrzymywać. Dwie opcje dają cztery kombinacje; pięć – trzydzieści dwa.
Usługi backendu wpadają w tę samą pułapkę
To nie jest problem dotyczący wyłącznie frontendu. Wyobraźmy sobie dwie usługi NestJS:
UserService
TrainerService
Początkowo mogą one oferować te same operacje:
create()
findById()
update()
delete()
Klasa bazowa typu generic wydaje się atrakcyjna:
BaseService<T>
Czasami to działa i pozwala zaoszczędzić kod. Ale gdy zasady biznesowe dotyczące użytkowników i trenerów się różnią, klasa bazowa zaczyna gromadzić wyjątki. Najpierw sprawdzenie typu:
if (entityType === "user") ...
potem hook, który można przejąć przed aktualizacjami:
protected beforeUpdate(...)
potem kolejny po utworzeniu:
protected afterCreate(...)
I stopniowo powstaje cała seria punktów rozszerzeń, których jedynym celem jest sprawienie, by jeden generyczny serwis zachowywał się inaczej w zależności od domeny. To zamiana powtarzającego się kodu na złożoność warunkową, która zazwyczaj jest znacznie trudniejsza do zrozumienia, ponieważ analiza zachowania jednej entity wymaga teraz przeczytania klasy bazowej, jej hooków oraz wszystkich metod przejętych razem. Jeśli brzmi to znajomo, dyskusja na temat strukturyzowania domen w NestJS za pomocą zasad DDD oferuje uzupełniający punkt widzenia na to, gdzie powinny znajdować się granice.
To nie jest argument za kopiowaniem i wklejaniem
Stwierdzenie, że duplikacja jest dobra, byłoby równie uproszczone. Duplikacja wiąże się z rzeczywistymi kosztami:
- Jeśli dziesięć miejsc wdroży to samo reguły biznesowe niezależnie od siebie, naprawa jednego błędu może wymagać dziesięciu zmian, a pominięcie którejś powoduje niespójne zachowanie.
- Jeśli dwadzieścia komponentów implementuje to samo zachowanie związane z dostępnością, utrzymanie ich spójności staje się bardzo trudne.
- Jeśli kilka aplikacji polega na tym samym stabilnym kontrakcie, dzielenie się nim ma ogromną wartość.
Rzeczywisty problem jest bardziej precyzyjny: duplikacja i powiązanie to dwa różne rodzaje kosztów. Dobry projektowanie polega na wyborze takiego kosztu, który pasuje do danego problemu, zamiast ciągłego minimalizowania tylko jednego z nich.
Trzy pytania przed wyodrębnieniem abstrakcji
Gdy dwa fragmenty kodu wyglądają podobnie, zatrzymaj się i rozważ te kwestie przed ich połączeniem.
Czy zmieniają się z tego samego powodu?
To jest najważniejszy punkt. Jeśli wymagania produktu mają tendencję do zmiany jednocześnie dla elementów A i B, udostępnianie tych elementów jest prawdopodobnie rozsądne. Jeśli A zmienia się z powodu jednego czynnika biznesowego, a B z powodu innego, dzisiejsza identyczna implementacja to słaby dowód na to, że należą one do siebie.
Czy oznaczają to samo?
Kod może być identyczny, podczas gdy jego semantyka się różni, a to właśnie semantyka ewoluuje. Cena oraz saldo konta mogą być zwykłymi liczbami, ale to nie usprawiedliwia łączenia ich we wspólny alias wszędzie:
type Amount = number;
Reprezentacja jest taka sama, ale znaczenie nie. Cena może obejmować zasady walutowe i podatkowe, natomiast saldo – limity przelewów. Zachowanie ich jako odrębnych pojęć, nawet jeśli oba są dziś liczbami, sprawia, że ta różnica pozostaje niewielka.
Czy to eliminuje duplikaty, czy tylko usuwa linie kodu?
To są różne wyniki. Dobra abstrakcja eliminuje powtarzające się koncepcje. Zła natomiast jedynie skraca pliki. Wyjęcie 30 linijek z dwóch komponentów do pomocniczego pliku o długości 40 linijek z ośmioma właściwościami niekoniecznie cokolwiek poprawia; złożoność po prostu się przeniosła i trzeba teraz utrzymywać dodatkową interfejs.
Celowane powtarzanie jest uzasadnionym wyborem
Umiarkowane jest trzymanie dwóch fragmentów kodu oddzielnie, gdy:
- są małe
- są proste
- prawdopodobnie będą rozwijać się niezależnie
- nie chronią krytycznej inwarianckiej właściwości
- nie należą do wspólnej koncepcji domeny
Powodem, by zostawić je w spokoju, nie jest brak umiejętności pracy z abstrakcjami; to zrozumienie, ile kosztowałaby taka abstrakcja. Umiejętność spojrzenia na powtórzenia i podjęcia decyzji „jeszcze nie” to oznaka dojrzałości, a nie lenistwa. Nie każde powtórzenie stanowi dług techniczny – czasami to po prostu powtórzenie.
Kiedy duplikacja staje się sygnałem ostrzegawczym
Druga strona kwestii jest równie ważna. Niektóre duplikacje są wyraźnym sygnałem do konsolidacji:
- ta sama złożona reguła biznesowa skopiowana w kilku miejscach
- trzy aplikacje, z których każda implementuje ten sam proces autoryzacji
- wiele zespołów, które muszą przestrzegać jednego kontraktu API
- zmiana reguły wymagająca zapamiętania dziesięciu różnych miejsc jej zastosowania
W takich przypadkach właściwe pytanie brzmi: jakie wiedze są kopiowane? To ma znacznie większe znaczenie niż to, ile wierszy się powtarza, ponieważ to, czego należy unikać, to kopiowanie wiedzy, a nie tekstu.
DRY zawsze dotyczyło wiedzy
Zasada „Don’t Repeat Yourself” jest często interpretowana jako „nigdy nie pisz tego samego kodu dwa razy”. Oryginalne sformułowanie w książce The Pragmatic Programmer odnosi się do wiedzy: każda informacja powinna mieć w systemie jedno, autorytatywne przedstawienie. To są różne zasady.
Dwa komponenty mogą zawierać podobny JSX bez konieczności duplikowania żadnej wiedzy biznesowej. Z drugiej strony, dwie funkcje, które w ogóle nie przypominają się nawzajem, mogą każda reprezentować tę samą zasadę biznesową – na przykład próg zniżki hard-kodowany zarówno w komponencie koszyka zakupów, jak i w walidatorze backendowym. To drugi przypadek jest niebezpieczny, ponieważ prowadzi do cichego rozbieżności. Dlatego zamiast pytać, czy coś można uczynić wielokrotnie użytecznym, należy zadać pytanie gdzie powinna znajdować się ta wiedza.
Pozwól abstrakcji zdobyć swoje miejsce
Nie jest konieczne projektowanie abstrakcji od razu, gdy pojawia się duplikacja. Pozwolenie na chwilowe istnienie powtórzeń i obserwowanie, jak ewoluują te kopie, to skuteczna strategia. Jeśli drugi i trzeci przypadek użycia będą rozwijać się w tym samym kierunku, właściwa forma stanie się oczywista. Wtedy wspólne zachowanie jest odkrywane, a nie wymyślane.
Różnica ta jest znacząca. Abstrakcja wyciągnięta z trzech rzeczywiście podobnych przypadków użycia jest zazwyczaj znacznie bardziej solidna niż ta zaprojektowana na podstawie jednego przypadku użycia w celu uwzględnienia dwóch hipotetycznych przyszłych przypadków. To właśnie stanowi podstawę znanej heurystyki „reguły trzech”. Innymi słowy:
Abstrakcja powinna opierać się na tym, co obecnie rozumiesz jako kod, a nie na tym, jakim może się on stać według twoich wyobrażeń.
Celem jest projekt, który można zmieniać, a nie kod wielokrotnie używalny
Wielokrotne wykorzystywanie kodu samo w sobie nie jest przydatnym miernikiem jakości architektury. Lepszymi wskaźnikami są:
- jak łatwy do zrozumienia jest kod
- jak izolowana jest typowa zmiana
- jak przewidywalny jest zasięg wpływu zmiany
- czy koncepcje biznesowe pozostają wyraźnie oddzielone
- czy granice znajdują się w sensownych miejscach
Czasami te kryteria prowadzą do eleganckiej, wspólnej abstrakcji. Czasami skutkują dwoma niemal identycznymi komponentami obok siebie, a to może być lepszy projekt, ponieważ te dwa elementy nigdy nie były tym samym. Po prostu wyglądają podobnie dziś.
Główne wnioski
- Dzielenie się kodem tworzy zależność między użytkownikami; traktuj każde wyodrębnienie jako decyzję o powiązaniu ich przyszłości.
- Oceniaj kandydatów na abstrakcje pod kątem powodów zmiany i ich znaczenia, a nie pod kątem podobieństwa tekstowego.
- Flagi Prop, gałęzie wariantów oraz hooki nadające się do przejęcia to oznaki, że wspólny element służy niepowiązanym domenom.
- Duplikuj swobodnie mały, prosty kod rozwijający się niezależnie; konsoliduj duplikowane informacje, takie jak reguły biznesowe, umowy i procesy bezpieczeństwa.
- Należy preferować abstrakcje wyłonione z różnych rzeczywistych przypadków użycia nad tymi zaprojektowanymi dla hipotetycznych sytuacji.
- Zanim połączysz dwa podobne elementy, zastanów się, czy tworzysz możliwość ponownego użycia, czy też relację, oraz czy ta relacja powinna przetrwać dłużej niż pliki, które przechowujesz.
Literatura pokrewna
- Kiedy AI pisze twój React App, ale pomija zasady czystego kodu — Poznaj siedem nawyków pisania czystego kodu – DRY, zasada jednej odpowiedzialności, klauzule ochronne i inne – które często są łamane w kodzie React generowanym przez AI oraz sposoby ich naprawy.
- Kiedy używane ponownie componenty React mają negatywne skutki: eksplozja propów i rozwiązanie — Dowiedz się, jak przedwczesne ponowne użycie zamienia prosty komponent React w obciążenie spowodowane dużą ilością propów, oraz jak duplikacja, złożone componenty i zasada trzech zapobiegają temu.