Strona główna / Artykuły

Dług frontendowy jako koszt zmian: diagnozowanie rosnącej bazy kodu w React

Naucz się rozpoznawać złożone decyzje, które sprawiają, że duże aplikacje w React, Redux, TypeScript i Next.js są drogie w modyfikacji, oraz jak stopniowo spłacać ten dług.

Wydawca: REACTAPP.TOP18 min czytania4156 słów

Źródła: Frontend Technical Debt: The Problems Nobody Notices Until the React App Becomes Huge

Złożone aplikacje React prawie nigdy nie stają się trudne w utrzymaniu z powodu jednego katastrofalnego komitowania. Ich jakość pogarsza się w wyniku długiej serii małych, pojedynczo rozsądnych decyzji: dodatkowego useEffect, fragmentu stanu Redux, który nie musiał być globalny, „tymczasowego” komponentu składającego się z 500 linii kodu, wspólnego widgetu, który ciągle otrzymuje nowe właściwości, pliku utils, który gromadzi wszystko, co nie ma oczywistego miejsca przechowywania, typu API wklejonego do trzech modułów oraz konwencji stylizacji, której nikt nie odważy się zmienić. Żadna z tych decyzji nie wydaje się błędna w momencie jej podjęcia. Problemy pojawiają się, gdy gromadzi się ich setki i zaczynają one ze sobą współdziałać.

To właśnie taka akumulacja oznacza dług techniczny frontendu, a podstawowa idea tego przewodnika jest prosta: największe problemy frontendu rzadko wynikają z jednego złego fragmentu kodu, lecz z drobnych decyzji, które się kumulują. Poniżej znajdziesz najczęstsze formy, jakie przybiera ten dług w kodach opartych na React, Redux, TypeScript i Next.js, powody, dla których każda z nich stanowi problem, oraz praktyczne sposoby na jego zmniejszenie bez konieczności wstrzymywania prac nad produktem w celu przepisania kodu.

Jak czysty repozytorium ulega degradacji

Nowy projekt zwykle zaczyna się od struktury, którą każdy może od razu odczytać:

src/
  components/
  pages/
  hooks/
  services/
  store/

Rozwijający, który nigdy nie widział tego repozytorium, może odgadnąć, gdzie powinien trafić nowy hook lub usługa. Następnie produkt się rozwija, zespół się powiększa, a lista funkcji również. Kilka lat później ta sama struktura często wygląda w ten sposób:

src/
  components/
    Button/
    Modal/
    Table/
    UserTable/
    UserTableNew/
    CommonTable/
    GenericTable/
    SharedTable/
    ...
  hooks/
    useUser.ts
    useUsers.ts
    useUserData.ts
    useFetchUser.ts
    useUserDetails.ts
    ...
  utils/
    helpers.ts
    common.ts
    misc.ts
    formatter.ts
    newHelpers.ts
    oldHelpers.ts
  store/
    user/
    users/
    userData/
    userProfile/
    ...

Zauważ wzorzec: kilka tabel o nakładających się funkcjach, pięć mechanizmów, które wszystkie pobierają „użytkownika” w nieco inny sposób, pliki pomocnicze odróżniane jedynie słowami „nowy” i „stary”, oraz cztery foldery przechowawcze dla tego, co można uznać za jeden domenę. Nikt tego nie zaprojektował – rozwijało się to krok po kroku, za każdym razem dzięki nowej prośbie o zmianę, i właśnie dlatego tego typu zadłużenie jest niebezpieczne: nigdy nie ma chwili, w której stałoby się oczywiste, że wymaga naprawy.

Skrót, który cicho staje się trwały

Najbardziej powszechnym źródłem takiego zadłużenia jest odkładane sprzątanie. Funkcja musi zostać wdrożona dziś; schludna implementacja zajęłaby dwa dni, natomiast szybka – trzy godziny. Wybór wersji trwającej trzy godziny nie oznacza automatycznie słabej inżynierii – czasami jest to dokładnie właściwa decyzja biznesowa.

Ryzyko pojawia się wtedy, gdy skrót przetrwa dłużej niż jego uzasadnienie. Kod tymczasowy ma silną tendencję do przekształcania się w infrastrukturę produkcyjną, a decyzje, które go tworzą, zazwyczaj wydają się nieszkodliwe w momencie podjęcia:

"Po prostu umieśćmy to w istniejącym komponencie." ">Zrefaktoryzujemy to po wydaniu wersji." ">Na razie zachowajmy to w Redux." ">Móżemy to później uogólnić." ">Nic innego nigdy tego nie będzie potrzebować."

Po pół roku kontekst znika. Nikt nie pamięta, dlaczego kod został napisany w taki sposób; osoba, która go napisała, mogła przenieść się do innego zespołu, wymagania, którym służył, uległy zmianie, a teraz trzy inne funkcjonalności polegają na jego zachowaniu. Odwrócenie skrótu, który wymagał trzech godzin pracy, może zająć potem trzy tygodnie.

Gdy jeden komponent odpowiada za całą funkcjonalność

function Checkout() {
  // fetch products
  // validate coupon
  // calculate totals
  // manage address
  // handle payment
  // track analytics
  // show modals
  // handle errors
  // render UI
}

Każde nowe wymaganie trafia w to samo miejsce, ponieważ tam już znajduje się odpowiedni kod. W końcu komponent ten zajmuje się:

  • komunikacją z API
  • regułami biznesowymi i weryfikacją
  • zarządzaniem stanem
  • analizą danych i sprawdzaniem uprawnień
  • nawigacją i obsługą błędów
  • stylizacją i renderowaniem

React tak naprawdę nie jest winny w tym przypadku. Prawdziwym problemem jest to, że zbyt wiele obowiązków przeniknęło poza granice komponentu. Komponent to odpowiednie miejsce do renderowania oraz przechowywania stanu interfejsu należącego do danego elementu UI; nie powinien stać się pojemnikiem na wszystkie aspekty, z którymi ma do czynienia dana funkcjonalność. Zasady cenowe, walidacja kuponów oraz analiza danych mogą znajdować się w zwykłych modułach lub niestandardowych hookach, które można testować bez konieczności renderowania czegokolwiek.

Komponenty wielokrotnego użycia, które kosztują więcej, niż oszczędzają

W początkowej fazie kariery wielokrotność użycia wydaje się oczywistą zaletą. Z upływem doświadczenia staje się jasne, że każda abstrakcja ma swój koszt. Weźmy na przykład tabelę, która została uczyniona „konfigurowalną”:

<DataTable
  data={data}
  columns={columns}
  loading={loading}
  selectable
  searchable
  sortable
  pagination
  serverPagination
  expandable
  virtualized
  stickyHeader
  renderEmptyState={...}
  renderLoadingState={...}
  onRowClick={...}
/>

W sensie technicznym może być ponownie wykorzystywany. To, czy jest łatwy w utrzymaniu, to inna kwestia, a szczera odpowiedź brzmi „to zależy”. Każdy flaga logiczna podwaja liczbę kombinacji, które komponent musi obsługiwać, a każdy nowy użytkownik wymagający niewielkiej modyfikacji dodaje kolejną właściwość zamiast nowego komponentu.

Bardziej przydatnym testem niż „czy można to ponownie wykorzystać?” jest to, czy abstrakcja ułatwi tworzenie kolejnych funkcjonalności. Komponent zawierający trzydzieści właściwości często wcale nie stanowi architektury nadającej się do ponownego użycia; to kilka odrębnych komponentów zamkniętych za jedną interfejsem API. Kompozycja, w której użytkownicy łączą mniejsze elementy, jest często lepszym rozwiązaniem, jak opisano w rozwiązaniu problemu przeładowania właściwościami w React za pomocą kompozycji i slotów.

Gdzie folder utils ukrywa twój domenę

Prawie każdy spory frontend posiada katalog utils, a na początku nie stanowi to problemu. Z czasem sytuacja wygląda mniej więcej tak:

utils/
  formatDate.ts
  formatCurrency.ts
  userHelpers.ts
  permissions.ts
  productHelpers.ts
  validation.ts
  apiHelpers.ts
  stringUtils.ts
  common.ts
  helpers.ts

Gdy folder osiągnie ten stan, nikt nie wie, gdzie powinna trafić nowa logika, więc kolejny helper trafia do pliku common.ts, a potem jeszcze jeden. Kod jest uporządkowany według typu pliku, ale obszar biznesowy pozostaje niewidoczny. Sprawdzanie uprawnień, zasady produktów oraz elementy API znajdują się obok siebie z formatowaniem ciągów znaków.

Lepiej postawić pytanie nie „do którego pliku utility powinien trafić?”, ale „która funkcja biznesowa odpowiada za tę logikę?”. Układ zorientowany na funkcje sprawia, że odpowiedź staje się jasna:

features/
  checkout/
    components/
    hooks/
    services/
    types/
    utils/

products/
    components/
    hooks/
    services/
    types/
payments/
    components/
    hooks/
    services/
    types/

To nie jest nakaz stosowania folderów funkcjonalnych wszędzie. Podstawowa zasada jest ważniejsza niż nazwy katalogów: kod powinien być grupowany według właściciela i odpowiedzialności, a nie tylko ze względu na rodzaj pliku.

Redux jako magazyn wszystkiego

Redux to rzeczywiście przydatne narzędzie, ale umieszczanie wszystkiego w globalnym stanie to jeden z najszybszych sposobów na powstawanie długu frontendowego. Magazyn zwykle zaczyna się od fragmentów, które wyraźnie nadają się do bycia globalnymi:

auth
user
permissions
cart
products
checkout
notifications

Następnie pochłania elementy, które dotyczą faktycznie jednego ekranu lub jednej interakcji:

modal
filters
selectedTab
searchText
formState
temporaryUIState

W końcu magazyn ten funkcjonuje jak baza danych dostępna w całym aplikacji dla wszystkiego, co dwa komponenty mogłyby chcieć odczytać. Technicznie niemal każda wartość może trafić do Redux, więc pytanie „czy to możliwe?” jest błędne. Prawidłowe pytanie brzmi, czy stan rzeczywiście musi być globalny. Podział stanu na trzy kategorie pomaga w tym.

Stan interfejsu, który powinien pozostać lokalny

Takie wartości zazwyczaj powinny znajdować się obok komponentu, który je używa, w useState lub useReducer:

isModalOpen
activeTab
inputValue
hoveredItem
expandedRow

Przeniesienie ich do globalnego magazynu sprawia, że każdy otwarty modal lub wskazany rząd staje się problemem dla całej aplikacji i dodaje nadmierną złożoność do prostych interakcji.

Stan serwera, który wymaga strategii cacheowania

Dane należące do backendu mają inne wymagania:

products
orders
users
invoices

Tutaj trudnymi problemami są cacheowanie, usuwanie duplikatów, ponowne pobieranie danych oraz unieważnianie stanu. Wymagają one celowego podejścia do pobierania danych (specjalistycznej biblioteki do zarządzania stanem serwera lub równoważnego wzorca), zamiast ręcznie pisanych funkcji redukcyjnych, które kopiują odpowiedzi do magazynu. Kwestie te są szerzej omawiane w przemyśleniach na temat stanu serwera w dużych aplikacjach z użyciem React Query i Redux.

Stan rzeczywiście obejmujący całą aplikację

Część problemów rzeczywiście dotyczy całego produktu:

authentication
permissions
application-wide preferences
cross-feature workflows

Są to rozsądne kandydaci na globalny magazyn danych. Lekcją nie jest „unikanie Redux”; chodzi o to, że sama dostępność nie jest powodem, by uczynić stan globalnym.

Dług w postaci TypeScript: typy, które tracą znaczenie

TypeScript jest jednym z najcenniejszych narzędzi do budowy dużych interfejsów użytkownika, jednak nie projektuje za Ciebie bazy kodu. Możliwe jest stosowanie tego wzorca w całym aplikacji i mimo to nazywanie projektu typowanym:

const data: any = response.data;

Każde użycie any wyłącza sprawdzanie dokładnie w momencie, gdy dane trafiają do aplikacji, więc kompilator nie może ostrzec Cię w przypadku zmiany struktury API. Innym częstym problemem jest deklarowanie tego samego typu domeny niezależnie w kilku plikach:

type User = {
  id: string;
  name: string;
}

Gdy takie sytuacje występują w kilku zespołach, powstaje seria niemal identycznych rozwiązań:

User
UserType
UserModel
UserResponse
UserDTO
UserData

Rozwijający muszą wtedy poświęcić więcej wysiłku na decydowanie, jaki typ importować, niż czerpiąc korzyści z systemu typów. Dążenie do zasady „wszystko ma typ” pomija sedno sprawy. Celem jest to, aby ważne elementy systemu, takie jak odpowiedzi API, entity domeny oraz interfejsy komponentów, były jasno zdefiniowane i istniały tylko raz. Dobry TypeScript utrudnia reprezentację nieważnych stanów, natomiast zły TypeScript powoduje jedynie powstanie większej liczby plików. Przydatnym rozróżnieniem jest używanie typu transportowego dla tego, co zwraca API, oraz typu domenowego dla tego, z czym pracuje interfejs użytkownika – oba typy są konwertowane w jednym miejscu, zamiast istnieć sześć słabo powiązanych wariantów.

Kiedy useEffect staje się domyślnym rozwiązaniem awaryjnym

Efekty są kolejnym częstym źródłem powolnie narastających problemów. Zaczyna się od pojedynczego efektu, który reaguje na jedną wartość:

useEffect(() => {
  // do something
}, [something]);

Następnie drugi efekt utrzymuje synchronizację dwóch wartości:

useEffect(() => {
  // synchronize something
}, [a, b]);

Następnie istnieje trzeci element, którego głównym celem jest poprawa zachowania wcześniejszego:

useEffect(() => {
  // fix previous effect
}, [data]);

Komponent przekształca się w łańcuch efektów, które uruchamiają kolejne efekty, co utrudnia zrozumienie funkcjonowania i prowadzi do dodatkowych renderowań oraz błędów w kolejności wykonywania operacji. Na tym etapie umiejętność pracy z Reactem zależy mniej od znajomości API, a bardziej od jasnego rozumienia tego, w jaki sposób dane przepływają przez komponent.

Typowym przykładem jest przechowywanie danych pochodnych w stanie i synchronizowanie ich z efektem:

const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

To renderuje się raz z przestarzałym pustym łańcuchem znaków, następnie uruchamia efekt, a potem renderuje się ponownie. Wartość tę można po prostu obliczyć podczas renderowania:

const fullName = `${firstName} ${lastName}`;

Wydaje się to błahą różnicą, ale w całym aplikacji takie małe decyzje się kumulują. Funkcje typu Effects służą do synchronizacji z systemami poza Reactem, takimi jak subskrypcje, timerzy czy DOM, a nie do wyprowadzania jednego stanu z drugiego. Szersze omówienie znajduje się w artykule stop syncing state with useEffect.

Granica serwera/klienta w Next.js

Współczesny Next.js dodaje nowy wymiar do decyzji architektonicznych. Oprócz określenia, który komponent ma coś wyświetlić, trzeba też zdecydować, gdzie będzie wykonywany kod: na serwerze, na klientzie lub w obu miejscach. Ta granica sama w sobie jest decyzją architektoniczną.

Częstym błędem jest przekształcanie dużej części struktury w komponenty klienta tylko dlatego, że jeden mały element wymaga interaktywności. Dyrektywa ta umieszcza się na początku pliku strony:

"use client";

A cała strona jest teraz wysyłana do przeglądarki jako JavaScript po stronie klienta:

export default function ProductPage() {
  // entire page becomes client-side
}

Wszystko to dlatego, że jeden przycisk wymaga fragmentu lokalnego stanu:

const [isOpen, setIsOpen] = useState(false);

To wygodne, ale taka wygoda w jednym miejscu ma swoją cenę gdzie indziej: więcej JavaScriptu jest wysyłane do przeglądarki, a pobieranie danych, które mogło pozostać na serwerze, musi teraz odbywać się po stronie klienta. W kontekście App Router wszystko, co jest importowane z pliku "use client", staje się częścią paczki klienta, więc umieszczenie tej instrukcji decyduje o znacznie większej liczbie elementów niż tylko jeden komponent, który chciałeś uczynić interaktywnym.

Pomaga myślenie warstwowo, przenosząc interaktywność jak najniżej w strukturze aplikacji:

Server
   ↓
Data fetching
   ↓
Feature boundary
   ↓
Interactive client component
   ↓
Small local state

zamiast stosować prosty, płaski podejście:

Everything → Client Component

W praktyce oznacza to zazwyczaj wyodrębnienie interaktywnego przycisku do osobnego, małego komponentu klienckiego, a stronicę, jej ładowanie danych oraz większość kodu HTML pozostawienie na serwerze.

Dług stylistyczny to nadal dług

Zespoły często traktują dług techniczny jako problem związanym wyłącznie z JavaScriptem i TypeScriptem. Stylizacja w równie pewny sposób powoduje narastanie długu, szczególnie w produktach działających od dawna i łączących kilka różnych podejść jednocześnie: CSS-in-JS i styled-components, moduły CSS, klasy utility, stare globalne pliki stylów oraz więcej niż jeden system projektowy. Prędzej czy później biblioteka komponentów zawiera coś w tym rodzaju:

Button
ButtonV2
PrimaryButton
NewButton
StyledButton
CommonButton
LegacyButton

i nikt nie może stwierdzić, który przycisk jest właściwy. Technologia stylizacji rzadko stanowi podstawową przyczynę problemu. Prawdziwym brakiem jest brak odpowiedzialności za system projektowy oraz niejasne granice komponentów. Dojrzały frontend posiada odpowiedzi na pytania takie jak:

  • Które komponenty należą do publicznego, współdzielonego zestawu?
  • Które istnieją tylko dla jednej funkcjonalności?
  • Który zespół jest odpowiedzialny za każdy z nich?
  • Jaki jest zatwierdzony sposób dodawania wariantów?
  • Jak wprowadzane są zmiany zakłócające funkcjonowanie?
  • Czym musi kierować się wybór elementów do włączenia do systemu projektowego?
  • Czym musi być zajmowane się lokalnie i czego nigdy nie należy przenieść do współdzielonego zestawu?

Kiedy duplikacja jest lepsza od abstrakcji

Tę lekcję najtrudniej przyjąć doświadczonym programistom. Załóżmy, że istnieją dwa komponenty, które obecnie wyglądają na około 90% podobnie:

ComponentA
ComponentB

Instynkt podpowiada, by połączyć je w jeden:

<UniversalComponent />

Jednak duplikacja bywa czasami tańszym rozwiązaniem, ponieważ jeszcze nie wiadomo, czy obie części będą dalej rozwijać się w tym samym kierunku. Jeśli ich drogi się rozchodzą, połączony komponent wypełnia się warunkami, a każda zmiana w jednym z elementów niesie ryzyko uszkodzenia drugiego. Niewielka ilość duplikacji jest korzystna; przedwczesna abstrakcja może doprowadzić do powstania silnych powiązań trwających lata.

Pракtyczne rozwiązanie domyślne: napisz kod dwa razy, przeanalizuj wzorzec i abstrahuj tylko wtedy, gdy trzeci przypadek użycia potwierdzi jego strukturę. To nie jest prawo uniwersalne, ale sensowna zasada wyjściowa.

Zależności, które już nie są konieczne

Wielkie interfejsy użytkownika szybko gromadzą zależności. Kodbase sprzed pięciu lat może łatwo zawierać biblioteki do:

  • zarządzania stanem, formularzami i walidacją
  • pracy z datami, wykresami i animacjami
  • kompletów interfejsu i stylizacji
  • uzyskiwania danych
  • testowania
  • narzędzia wielozadaniowe
  • Część z nich jest już prawie nie używana, a inne zostały zastąpione funkcjami przeglądarki lub możliwościami wbudowanymi w framework. Usunięcie którychkolwiek z nich wydaje się ryzykowne, więc pozostają one, a manifest stopniowo przekształca się w dokument architektoniczny, którego nikt nie chce czytać:

    package.json
    

    Higiena zależności nie dotyczy tylko stosowania poprawek bezpieczeństwa. Oznacza to regularne sprawdzanie, czy każda zależność nadal uzasadnia swoje istnienie, biorąc pod uwagę koszty konserwacji, aktualizacji i pakietowania, które generuje.

    Prawdziwa cena: wolniejsze i bardziej ryzykowne zmiany

    Dług nie stanowi problemu tylko dlatego, że inżynierom niepodoba się nieuporządkowany kod. Stanowi problem, ponieważ zmienia aspekty ekonomiczne wdrażania oprogramowania. W zdrowym aplikacji żądanie przechodzi przez krótki proces:

    Feature request
          ↓
    Small change
          ↓
    Tests
          ↓
    Deploy
    

    W aplikacji silnie obciążonej długiem to samo żądanie staje się przedmiotem dochodzenia:

    Feature request
          ↓
    Find where this behavior lives
          ↓
    Understand 5-year-old code
          ↓
    Check Redux dependencies
          ↓
    Check shared component
          ↓
    Check side effects
          ↓
    Check styling overrides
          ↓
    Change code
          ↓
    Something else breaks
          ↓
    Fix regression
          ↓
    Add another workaround
    

    Każdy krok w tej drugiej łańcuchowej procedurze to czas poświęcony na zrozumienie problemu zamiast na jego rozwiązanie, a ostatnie dwa kroki są najbardziej charakterystyczne: zmiana psuje coś zupełnie niepowiązanego, a naprawa wymaga kolejnego rozwiązania tymczasowego. To właśnie oznacza, że baza kodu „nabiera odsetek”, i to jest istota tak zwanego długu technicznego.

    Dlaczego stwierdzenie „nadal działa” jest mylące

    Aplikacja może działać bez zarzutu mimo posiadania ogromnego długu technicznego. Strony ładują się, użytkownicy logują się, zamówienia są przetwarzane, panel sterowania jest widoczny, a status w systemie CI pokazuje zielony kolor, więc wszyscy zakładają, że kod jest w dobrym stanie. Następnie pojawia się nowa funkcja, a pozornie prosta zmiana zajmuje dwa tygodnie. W tym momencie dług techniczny staje się widoczny.

    "Czy aplikacja działa?" to zatem słaby test sprawności. Znacznie bardziej przydatne pytanie, szczególnie w miarę rozwoju systemów, brzmi: ile kosztuje bezpieczna modyfikacja tej aplikacji?

    Zmniejszanie długu bez przepisywania kodu

    Kuszącą odpowiedzią na bałagan w bazie kodu jest całkowite odbudowanie aplikacji. Typowy wynik po sześciu miesiącach jest przewidywalny:

    • stara aplikacja nadal działa w środowisku produkcyjnym
    • nowa aplikacja jest ukończona tylko częściowo
    • dwa systemy wymagają teraz konserwacji
    • Zespół jest wyczerpany
    • Rozwój produktu został wstrzymany

    Krok po kroku poprawa architektury prawie zawsze przynosi lepsze rezultaty. Zaczyna się od znalezienia rzeczywistego źródła problemów.

    Zlokalizowanie najdroższych obszarów

    Opieraj się pokusie refaktoryzowania wszystkiego. Zamiast tego zidentyfikuj te części produktu, w których programiści wielokrotnie napotykają trudności, na przykład:

    Checkout
    User Management
    Search
    Dashboard
    Reporting
    

    Dla każdego kandydata poszukaj tych sygnałów:

    • Błędy, które ciągle pojawiają się w tej samej obszarze
    • Pliki, których edycja budzi obawy u użytkowników
    • Komponenty, które pojawiają się w niemal każdym pull requestu
    • Części produktu, których nauka zajmuje nowym pracownikom najdłużej
    • Funkcje, których zmiany wpływają na niepowiązane moduły

    Obszary, które pojawiają się raz za razem, to twoje „gorące punkty” zadłużenia technologicznego.

    Mierz aktualne koszty zmian

    Nie jest potrzebne zaawansowane oprogramowanie. Wystarczy śledzenie kilku wskaźników:

    Average time to implement feature
    Average PR size
    Number of files touched
    Regression frequency
    Build time
    Test execution time
    Deployment frequency
    

    Powiązanie długu z takimi wynikami inżynieryjnymi znacznie ułatwia ustalanie priorytetów, ponieważ można pokazać, że określona dziedzina spowalnia realizację, zamiast argumentować, że jej kod jest nieatrakcyjny.

    Zdefiniuj zasady zależności przed wyborem wzorca

    Zanim rozpocznie się dyskusja na temat Clean Architecture lub Feature-Sliced Design, należy najpierw odpowiedzieć na prostsze pytanie: co może zależeć od czego? Prosty układ warstwowy mógłby wyglądać w ten sposób:

    app
     ↓
    features
     ↓
    shared
    

    W ramach tego rozwiązania funkcjonalność może polegać na wspólnych elementach budulcowych:

    features/product
          ↓
    shared/ui
    shared/lib
    

    ale wspólny kod nigdy nie powinien oddziaływać na funkcjonalność:

    shared/ui
          X
    features/product
    

    Specyficzna architektura może zmieniać się z czasem; to kierunek zależności utrzymuje ją spójną. Gdy wspólna interfejs użytkownika zaczyna importować elementy z konkretnej funkcjonalności produktu, przestaje być wspólna, a każdy użytkownik w tle polega na tej funkcjonalności. Zasady te można również łatwo automatycznie egzekwować za pomocą reguł lintingu dotyczących ścieżek importu, co przekształca intencję architektoniczną w coś, co system CI sprawdza przy każdej prośbie o pull request.

    Rozkład oparty na funkcjonalnościach dla React i Next.js

    Żadna jedyna struktura nie jest odpowiednia dla każdego projektu, ale organizacja według funkcjonalności zapewnia naturalne granice:

    src/
      app/
        routes/
        providers/
        layouts/
    
    
    features/
        checkout/
          components/
          hooks/
          services/
          state/
          types/
        products/
          components/
          hooks/
          services/
          state/
          types/
        users/
          components/
          hooks/
          services/
          state/
          types/
      shared/
        ui/
        hooks/
        lib/
        types/
    

    Kopowanie tych nazw folderów nie jest sednem sprawy. Ważne jest to, że właścicielstwo staje się jawne: app łączy ze sobą ścieżki, dostawców i układy, każda funkcjonalność posiada swoje komponenty, haki, usługi, stan i typy, a shared zawiera tylko to, co jest naprawdę uniwersalne. Aby porównać alternatywy, zobacz praktyczne porównanie wzorców struktury folderów w React.

    Nawyki, których należy unikać w dużym frontendzie

    Podczas dołączania lub utrzymywania dużego kodu w React, uważaj na następujące rzeczy:

    • Globalizacja stanu jako standard. Większość wartości nigdy nie musi opuszczać swojego komponentu.
    • Zbyt wczesna uogólnianie. Najpierw zrozum różnice, a dopiero potem abstrahuj.
  • Dodawanie efektu jako reflex. Przemyśl przepływ danych przed synchronizacją czegokolwiek.
  • Otwieranie innego pliku utils.ts. Znajdź domenę, która powinna zawierać logikę.
  • Oznaczanie całych stron jako kodu klienckiego. W App Routerze jest to szczególnie kosztowne.
  • Instalowanie pakietu do czegoś, co platforma lub framework już robią. Każdy taki pakiet staje się długoterminowym obowiązkiem konserwacyjnym.
  • Refaktoryzacja przed pomiarami. Uporządkowanie nie jest wynikiem biznesowym; niższy koszt zmian jest nim.
  • Najważniejszą zmianą, która pojawia się wraz z doświadczeniem, jest zmiana celu, a nie nowa technika. Na początku naturalnym pytaniem jest to, jak lepiej napisać dany fragment kodu. Później pytanie zmienia się na to, jak ułatwić pracę tym, którzy będą go później edytować. Ta zmiana perspektywy przekształca sposób, w jaki podchodzimy do komponentów i abstrakcji, zarządzania stanem za pomocą Redux, TypeScript, Next.js, stylizacji, testów, ogólnej architektury oraz zależności. Zamiast doskonalić pojedyncze pliki, zaczynamy optymalizować zdolność systemu do rozwoju.

    Dług to narzędzie, a nie wróg

    Nie można wyeliminować długu technicznego, a próba tego jest błędem. Każdy produkt wymaga kompromisów, a wiele z nich powoduje dług już na etapie projektowania: szybka dostawa produktu, obsługa kilku wymagań biznesowych jednocześnie, utrzymanie kompatybilności z starszymi przeglądarkami, zmiana kierunku rozwoju produktu lub integracja przejętego oprogramowania. Nawet doskonała decyzja architektoniczna może stać się długiem, gdy zmieni się otaczający system.

    Dlatego „zerowy dług” to niewłaściwy cel. Realistycznym celem jest taki dług, który zespół rozumie, ma pod kontrolą i może spłacać, gdy zaczyna wpływać na proces dostarczania produktu.

    Czym właściwie utrudnia się pracę nad dużym aplikacją

    Warto precyzyjnie określić przyczyny podstawowe, ponieważ rzadko są to te elementy, za które ludzie obwiniają innych:

    • Problemem nie są same komponenty; problemem są relacje między nimi, których nikt nie potrafi zrozumieć.
  • Istnienie Redux nie jest problemem; problemem jest to, że nikt nie wie, co powinno się w nim znajdować.
  • Liczne typy TypeScript również nie są problemem; problemem są typy, które już nie reprezentują istotnych granic.
  • Funkcja Server i Client Components w Next.js nie jest problemem; problemem jest zespół, który nie wie, gdzie leżą te granice.
  • Stary kod nie jest problemem; problemem są stare decyzje, których nie można już bezpiecznie zmienić.
  • Mówiąc krótko, dług techniczny to nie kod o brzydkim wyglądzie. To kod, który sprawia, że zmiany są niepotrzebnie kosztowne, a najlepsza architektura frontendu to nie ta, która wygląda doskonale od pierwszego dnia, ale ta, która nadal pozwala zespołowi szybko działać pięć lat później.

    Listwa kontrolna przed dodaniem nowej funkcjonalności

    Zanim dodasz kolejną funkcjonalność do bazy kodu React, przeanalizuj następujące pytania:

    • Czy istnieje rzeczywisty powód, dla którego ten stan musi być globalny?
    • Czy ten danych należy do warstwy backend, czy to czysto stan interfejsu użytkownika?
    • Czy komponent przejmuje drugą lub trzecią odpowiedzialność?
    • Czy nowa abstrakcja rozwiązuje jakieś potrzeby, czy po prostu sprawia lepszy wygląd?
    • Która funkcjonalność jest odpowiedzialna za tę zasadę biznesową?
    • Czy ten kod mógłby zamiast tego działać na serwerze w Next.js?
    • Czy tutaj rzeczywiście potrzebny jest Effect?
    • Czy to doprowadzi do konieczności utrzymywania kolejnego pakietu?
    • Czy komponent jest rzeczywiście używany w różnych funkcjonalnościach?
    • Czy osoba początkująca mogłaby zrozumieć ten kod bez pytania osoby, która go napisała?

    Takie pytania zazwyczaj są cenniejsze niż kolejna lista najlepszych praktyk w React.

    Tematy wartych dalszego zbadania

    Kilka tematów z tego przewodnika wymaga osobnego, bardziej szczegółowego omówienia:

    • Kiedy Redux jest rzeczywiście właściwym wyborem w porównaniu z lokalnym stanem, Context, Redux Toolkit, RTK Query, dedykowanym obsługiwaniem stanu serwera, stanem URL oraz stanem formularzy.
    • Refaktoryzacja komponentu składającego się z 1000 linii bez zakłócania działania aplikacji w formie krok po kroku, jako studium przypadku.
    • Czy komponenty wielokrotnego użycia pogarszają aplikację, omawiając problem „eksplozji propów”, kompozycję oraz systemy projektowe.
    • Komponenty serwerowe versus klienckie w Next.js, skupiające się na decyzjach dotyczących granic zadań, a nie na tym, co robi "use client".
    • TypeScript na dużą skalę, w tym użycie typu any, generyków, DTO-ów, typów domeny i API, duplikacji oraz inferencji.
    • Co powinno zastąpić folder utils jako miejsce przechowywania logiki.
  • Wiedza o tym, kiedy aplikacja wymaga refaktoryzacji architektury, najlepiej przy użyciu modelu oceny opartego na takich kryteriach:
  • Change cost
    Bug frequency
    Dependency coupling
    Component size
    State complexity
    Build time
    Testability
    Developer onboarding
    

    Główne wnioski

    • Dług frontendu to suma małych, rozsądnych decyzji; obserwuj trend, a nie pojedyncze zmiany.
    • Oceniaj stan bazy kodu pod kątem kosztów i ryzyka zmian, a nie tego, czy aplikacja obecnie funkcjonuje.
    • Domyślnie przechowuj stan lokalnie, traktuj dane serwera jako problem związany z buforowaniem i globalizuj tylko to, co naprawdę ma charakter wspólny dla całej aplikacji.
    • Wolą granice i kierunek zależności nad nazwami folderów czy określonymi metodologiami.
    • Zmniejszaj dług stopniowo w wybranych obszarach o największym obciążeniu, zamiast stawiać na całkowitą przepisówkę.

    Pozycje pokrewne