Strona główna / Artykuły / Dlaczego abstrakcje frontendu po cichu przekształcają się w dług techniczny

Dlaczego abstrakcje frontendu po cichu przekształcają się w dług techniczny

Dowiedz się, dlaczego przedwczesne abstrakcje frontendu wprowadzają ukrytą złożoność oraz jak ocenić, czy wspólne komponenty, hooki lub narzędzia rzeczywiście warto tworzyć.

2389 słów

W niemal każdym kodzie frontendowym pojawia się moment, w którym cicho przestaje on być aplikacją, a staje się frameworkiem stworzonym po to, by wspierać aplikację.

Wprowadza się bibliotekę komponentów.

Następnie pojawia się system projektowy.

Potem dodaje się warstwę zarządzania stanem.

Później pojawia się abstrakcja do pobierania danych.

Następnie specjalny hook otacza tę abstrakcję do pobierania danych.

Potem ktoś pisze uniwersalny komponent formularza, który przyjmuje obiekt konfiguracji opisujący, jak powinny zachowywać się formularze.

Niedługo potem zmiana czegoś tak prostego jak przycisk oznacza konieczność przeanalizowania sześciu plików, trzech warstw abstrakcji oraz konwencji, której twórcy nie są już znani.

Dziwne jest to, że żadna z tych pojedynczych decyzji nie wydawała się wtedy nierozsądna.

To właśnie jest istotnym problemem w sposobie, w jaki zespoły frontendowe radzą sobie z abstrakcją.

Większość abstrakcji nie jest z natury zła, a wiele z nich rzeczywiście pomaga. Problem polega na tym, że deweloperzy front-endu stali się niezwykle biegli w tworzeniu abstrakcji, zanim zdobędą wystarczające dowody na to, że są one rzeczywiście potrzebne.

Nie ograniczamy się już tylko do abstrahowania istniejącej złożoności.

Abstrahujemy nawet samą możliwość, że złożoność może się kiedyś pojawić.

Taki nawyk powoduje specyficzny rodzaj długu technicznego.

Abstrakcja zwykle zaczyna się od dobrych intencji

Wyobraźmy sobie trzy oddzielne komponenty, z których każdy jest odpowiedzialny za pobieranie danych użytkownika.

Pierwszy zawiera nieco powtarzającej się logiki ładowania.

Drugi powtarza niemal ten sam schemat.

Trzeci robi coś nieco innego.

Ktoś zauważa to powtórzenie i proponuje:

">Prawdopodobnie powinniśmy przenieść to do wspólnej abstrakcji."

To samo w sobie jest trafną obserwacją.

Zespół więc tworzy dostosowany hook:

const { data, loading, error } = useUserData(userId);

Ładne i uporządkowane.

Następnie inny komponent wymaga niewielkiej modyfikacji zachowania.

Zamiast bezpośrednio korzystać z API, zespół wprowadza inną opcję:

useUserData(userId, {
  includePermissions: true,
  cache: true,
  retry: 3,
});

Kilka miesięcy później hook nie tylko pobiera już rekordy użytkowników.

Teraz zajmuje się cacheowaniem, ponawianiami prób, sprawdzaniem uprawnień, transformacjami danych, normalizacją błędów, aktualizacjami optymistycznymi oraz różnymi specyficznymi dla funkcji szczegółami.

Pierwotna duplikacja zniknęła.

Jednak pojawiło się coś innego: rosnąca luka pomiędzy kodem a tym, co faktycznie robi.

Przeglądanie komponentu nie informuje już o tym, skąd pochodzą jego dane.

Najpierw trzeba zrozumieć abstrakcję, która się przed nami znajduje.

To jest kompromis, o którym nikt nie mówi.

Abstrakcja nie eliminuje złożoności.

Tylko ją przenosi.

Czasami to przeniesienie jest rzeczywiście wartościowe.

Innym razem po prostu zamienia się pięć linijek prostej logiki na trzysta linijek wewnętrznych mechanizmów.

Abstrakcja ma swoją cenę

Rozwijający są od wczesnego etapu uczeni, by traktować duplikację jako obciążenie.

To rozsądne, często rzeczywiście tak jest.

Jednak duplikacja to daleko nie jedyne rodzaje złożoności.

Inne formy obejmują:

  • pośrednictwo
  • konfigurację
  • behawior domyślny
  • API ogólne
  • Zależności ukryte
  • konwencje
  • dziedziczenie
  • komponenty otulające
  • debugging, który istnieje tylko dzięki samej abstrakcji
  • obciążenie poznawcze

Część duplikacji jest rzeczywiście tańsza niż skomplikowana abstrakcja.

Porównaj dwa podejścia.

Jedno powtarza mały fragment logiki trzy razy.

Drugie tworzy uniwersalną funkcję z tuzinem parametrów, co uzasadniano tym, że trzy obecne przypadki użycia pokrywają się w przybliżeniu w 60 procentach.

Druga opcja wygląda bardziej dopracowana, bardziej „skonstruowana”.

Może też być znacznie trudniejsza do utrzymania.

To właśnie tutaj praca nad frontendem regularnie napotyka na trudności.

Zespoły optymalizują kod pod kątem zasady DRY, a nie pod kątem kodu, który jest łatwy do zrozumienia.

Te cele nie są wzajemnie zamienialne.

Ekosystem frontendu sprzyja temu

Rozwój frontendu ma osobliwy związek z abstrakcją, głównie dlatego, że cały ekosystem jest z założenia zbudowany warstwowo.

Jedna nowoczesna aplikacja może z łatwością wykorzystać framework do renderowania w celu budowy komponentów, jakąś bibliotekę routingu, menedżer stanu po stronie klienta, oddzielne narzędzie do obsługi stanu po stronie serwera, a także bibliotekę form wraz z własnym pakietem walidacji. Na dodatek istnieje system projektowy, zbiór gotowych komponentów interfejsu użytkownika, abstrakcja stylizacji nakładana na zwykły CSS, narzędzie do budowania aplikacji koordynujące wszystko oraz framework testowy nadzorujący całą konfigurację.

Każdy z tych elementów sam w sobie rozwiązuje konkretny problem.

Sytuacja komplikuje się, gdy aplikacja zaczyna dodawać własne, niestandardowe warstwy na wierzch tego wszystkiego.

Zamiast korzystać bezpośrednio z frameworka, programista ucieka się do wewnętrznego opakowania wokół niego.

To opakowanie z kolei polega na kolejnym opakowaniu.

Niedługo model programowania używany na co dzień przez zespół ledwo przypomina już podstawową platformę.

Taki wzorzec występuje szczególnie często w większych organizacjach.

Zespół może ostatecznie skończyć z taką strukturą:

<AppPage>
  <DataBoundary>
    <PermissionGate>
      <FormContainer>
        <EntityEditor />
      </FormContainer>
    </PermissionGate>
  </DataBoundary>
</AppPage>

Każda część ma określony cel.

Każda warstwa ma udokumentowany powód swojego istnienia.

Jednak gdy coś się zepsuje, programista musi w myślach odbudować całą strukturę, zanim dotrze do samego kodu funkcjonalnego.

Ta odbudowa nie jest bezkosztowa – ani pod względem poznawczym, ani czasowym.

Uniwersalne komponenty są często najgorszym problemem

Jedną z najprostszych drogów do złożoności frontendu jest projektowanie komponentu, który ma przewidywać każde przyszłe wymaganie.

Zaczyna się to niewinnie:

<Button />

Następnie nieco się rozwija:

<Button variant="primary" />

Potem nadal się rozszerza:

<Button
  variant="primary"
  size="large"
  loading
  icon={...}
  permission="admin"
  analyticsEvent="save"
  confirm
/>

Ostatecznie pozostaje nam coś, co technicznie już nie jest przyciskiem.

To miniaturowe frameworki do renderowania dowolnych działań.

Zespół czuje się bardziej produktywny, ponieważ nowe przyciski można teraz tworzyć poprzez konfigurację zamiast od nowa je implementować.

Jednak konfiguracja to nadal kod, niezależnie od tego, jak wygląda.

W pewnym sensie jest to nawet gorsze, ponieważ konfiguracja zasłania rzeczywisty przepływ sterowania.

Przeczytaj dwadzieścia linijek prostego kodu, a będziesz mógł dokładnie zobaczyć, co się dzieje.

Przeczytaj dwadzieścia linijek konfiguracji, a być może będziesz musiał zagłębić się w implementację komponentu, prześledzić parser konfiguracji, ustalić wartości domyślne i odkryć, które opcje w tle ze sobą współdziałają.

Jawny kod został w praktyce zastąpiony słownictwem.

Taka leksyka może być naprawdę potężna.

Może równie łatwo przerodzić się w dialekt, którego nikt nie chce utrzymywać.

Pułapka „odporności na przyszłość”

Odporność na przyszłość to zazwyczaj najsilniejsze uzasadnienie, jakie ktoś podaje dla dodania abstrakcji.

„Może będziemy tego potrzebować później.”

„Prawdopodobnie pojawi się więcej wariantów w przyszłości.”

„To może zostać ponownie wykorzystane gdzie indziej w aplikacji.”

„Zbudujmy to od początku w sposób uniwersalny.”

Czasami ten instynkt jest słuszny. Znacznie częściej po prostu nie ma się jeszcze wystarczająco dużo informacji, by to wiedzieć.

Problem polega na tym, że każda abstrakcja zawiera w sobie założenia dotyczące problemu. Zbyt wczesna abstrakcja oznacza ustalenie wyborów architektonicznych, zanim naprawdę zrozumiemy, co budujemy. A gdy inne części bazy kodu zaczną polegać na tej abstrakcji, cofnięcie się staje się szybko kosztowne.

Dlatego właśnie wczesna abstrakcja jest bardziej ryzykowna, niż się wydaje. Kod duplikowany zazwyczaj łatwo można przeredagować, gdy tylko jesteśmy gotowi. Złej abstrakcji natomiast zwykle przytrafia się to, że rozprzestrzenia się dalej.

Wyobraź sobie trzy komponenty, które wyglądają podobnie. Można na razie zignorować tę duplikację. Jeśli z czasem pojawi się rzeczywiście wspólny wzorzec, wtedy go wyodrębnimy. Ale jeśli od razu przejdziemy do uogólnionej abstrakcji, każdy przyszły przypadek użycia będzie musiał dostosować się do założeń początkowych.

W tym momencie abstrakcja przestaje być udogodnieniem i staje się ograniczeniem. To, co miało eliminować powtórzenia, ostatecznie sprawia, że zmiany są trudniejsze niż gdyby doszło do duplikacji.

Dobre abstrakcje zazwyczaj powstają w wyniku trudności

Najsilniejsze abstrakcje zwykle nie są planowane z góry. Są odkrywane.

Zespół implementuje to samo zachowanie więcej niż raz. W końcu zauważa, które części są naprawdę identyczne, a które tylko pozornie się podobają. Rozumie, co tak naprawdę się różni. Dopiero wtedy wyodrębnia stabilną, wspólną istotę.

To tworzy znacznie mocniejsze podstawy. Sekwencję tę można opisać w ten sposób:

Duplikacja prowadzi do powtórzeń, powtórzenia prowadzą do zrozumienia, a zrozumienie prowadzi do abstrakcji.

Zespoły frontendu jednak często podążają inną ścieżką:

Potencjał prowadzi bezpośrednio do abstrakcji, następnie do konfiguracji, a potem do zamieszania.

Pierwsze podejście wymaga więcej czasu na początkowym etapie. Jednak w całym okresie trwania projektu jest zazwyczaj szybsze ogólnie, ponieważ powstała abstrakcja odzwierciedla wiedzę, którą zespół faktycznie zdobył dzięki doświadczeniu.

Nie wszystkie duplikaty należy usuwać

To nieprzyjemna prawda dla programistów, ponieważ duplikaty są z reguły postrzegane jako błąd. Jednak czasami ich użycie jest właściwą decyzją.

Załóżmy, że dwa komponenty używają niemal identycznej logiki walidacji. Jeśli ta logika składa się z zaledwie pięciu linijek, a każdy komponent odpowiada na inne zasady biznesowe, pozostawienie kodu w formie duplikatu może być mądrzejszym wyborem.

Dlaczego? Ponieważ trzymanie ich oddzielnie zachowuje lokalne zrozumienie. Ktoś pracujący później nad jednym komponentem może dostosować jego zachowanie, nie martwiąc się o przypadkowe uszkodzenie drugiego.

Kod duplikowany implicytnie mówi: te dwie rzeczy teraz przypadkowo się do siebie podobają.

Abstrakcja mówi coś znacznie mocniejszego: te dwie rzeczy powinny być identyczne i powinny zmieniać się razem w przyszłości.

To jest silniejsze twierdzenie. Powinieneś uciekać się do abstrakcji tylko wtedy, gdy naprawdę wierzysz, że to twierdzenie jest prawdziwe.

Abstrakcje powinny mieć mały API

Pożyteczną praktyczną sprawdzą jest następująca: ile dokładnie musisz się nauczyć, zanim będziesz mógł poprawnie używać tej abstrakcji?

Jeśli ta lista stale się powiększa, prawdopodobnie obserwujesz, jak abstrakcja przekształca się w framework.

Dobra abstrakcja ukrywa złożoność. Zła natomiast ukrywa decyzje. Brzmi to podobnie, ale wcale nie są to to samo.

Dobrze zaprojektowana API może wyglądać tak prosto:

const user = useUser(id);

Gorsza zaczyna dodawać coraz więcej flag i opcji, co skutkuje czymś w rodzaju tego:

const user = useUser(id, {
  cache: true,
  normalize: true,
  permissions: true,
  optimistic: false,
  retry: 3,
  suspense: false,
  transform: customTransform,
  mode: "editor",
});

W pewnym momencie abstrakcja przestaje cokolwiek upraszczać. Po prostu przenosi oryginalny problem do obiektu konfiguracji. Taka zmiana powinna być traktowana jako sygnał ostrzegawczy.

Zespoły frontendu potrzebują budżetu na abstrakcje

Zespoły regularnie mówią o budżetach wydajności, rozmiaru plików, błędów oraz infrastruktury. Warto dodać do tej listy również budżet na abstrakcje.

Celem nie jest koniecznie mniej abstrakcji w sensie bezwzględnym. Chodzi o utrzymanie ich liczby na poziomie, który zespół może łatwo zapamiętać.

Zanim dodasz coś nowego, pomocne jest postawienie kilku pytań.

Ile razy już pojawił się ten dokładny wzorzec? Jeśli odpowiedź brzmi „raz”, nie abstrahuj go jeszcze. Jeśli było to dwa razy, traktuj to jako powód do podejrzeń, a nie do działania. Pięć wystąpień zaczyna przypominać rzeczywisty dowód.

Następnie: czy te elementy naprawdę zmieniają się z tych samych powodów? Podobieństwo nie jest wystarczającym uzasadnieniem. Dwa komponenty mogą wyglądać niemal identycznie, rozwijając się jednak zupełnie różnymi ścieżkami z różnych powodów biznesowych — w takim przypadku łączenie ich w jedną abstrakcję może być błędem.

Na koniec: czy ta abstrakcja rzeczywiście upraszcza codzienne przypadki? Nie jakieś hipotetyczne przyszłe sytuacje — te, z którymi ludzie będą się konsekwentnie spotykać. Jeśli korzystanie z abstrakcji oznacza konieczność ponownego czytania jej dokumentacji za każdym razem, prawdopodobnie zamieniłeś jeden problem na gorszy.

Najlepszy kod frontendu jest często nudny

Rozwój oprogramowania ma w sobie ciekawą hierarchię statusów. Sprytna abstrakcja wydaje się imponująca bardziej niż trzy zwykłe komponenty. System ogólny wydaje się bardziej architektonicznie poważny niż prosta funkcja. Framework wielokrotnie używalny wydaje się bardziej profesjonalny niż kawałek kodu powtórnego.

Jednak systemy produkcyjne nie nagradzają kodu za wygląd skomplikowany. Nagradzają kod, który deweloperzy mogą bezpiecznie modyfikować.

Najcenniejszy kod frontendu to zazwyczaj ten nudny. Otwierasz komponent i od razu widzisz, skąd pochodzą jego dane. Widzisz dokładnie, co się aktywuje, gdy użytkownik na coś kliknie. Możesz edytować kod bezpośrednio. Możesz prześledzić, jak przepływa stan. Możesz bez trudu zlokalizować wywołanie API.

Nie powinieneś musieć przyswajać wewnętrznej filozofii projektowej zespołu tylko po to, by naprawić błąd. To nie jest oznaką prymitywnego inżynieringu — to oznaka dobrego inżynieringu.

Abstrakcja powinna zmniejszać potrzebę myślenia, a nie ją zwiększać

Abstrakcja nie istnieje po to, by kod wyglądał na wielokrotnie używalny. Jej celem jest ułatwienie rozumienia systemu. To standard, który zespoły frontendowe powinny stosować wobec każdej abstrakcji.

Jeśli abstrakcja umożliwia dziesięciu komponentom współdzielenie złożonego zachowania bez konieczności, by każdy programista rozumiał, jak to zachowanie jest implementowane, to spełnia swój cel. Jeśli natomiast każdy programista musi nauczyć się skomplikowanej API tylko po to, by dostosować jeden prosty komponent, abstrakcja prawdopodobnie działa przeciwko tobie.

To rozróżnienie jest ważne, ponieważ praca nad interfejsem użytkownika jest już z natury trudna. Przeglądarki są skomplikowane, interfejsy użytkownika również. Utrzymanie synchronizacji stanu jest skomplikowane, podobnie jak dostępność i wydajność. Nie ma potrzeby dodawania kolejnej warstwy złożoności tylko po to, by architektura wyglądała bardziej zaawansowana na papierze.

Następny etap rozwoju interfejsu użytkownika prawdopodobnie nie wyniknie z odkrycia kolejnej warstwy abstrakcji. Będzie wynikał z lepszej umiejętności rozpoznawania sytuacji, gdy nie należy jej tworzyć.

Inżynierowie, którzy wyróżniają się, to niekoniecznie ci, którzy potrafią zaprojektować najbardziej rozbudowany system komponentów wielokrotnie używalnych. To ci, którzy potrafią spojrzeć na problem i prawidłowo ocenić, czy w ogóle potrzebny jest taki system.

Czasami odpowiednią abstrakcją jest pojedyncza funkcja. Czasami to komponent. Czasami to po prostu dobrze nazwany moduł. A czasami to pięć linijek powtarzającego się kodu, który każdy w zespole może od razu przeczytać i zrozumieć.

Rozróżnianie tych sytuacji to właśnie umiejętność, którą warto rozwijać.

Literatura pokrewna

  • Wzory projektowe w React: Od klasycznego programowania obiektowego do nowoczesnych Hooków — Wyjaśnia, w jaki sposób klasyczne wzory oprogramowania, takie jak Singleton, Factory i Observer, znajdują zastosowanie w React, a także wzory specyficzne dla tego frameworka, takie jak HOC-y, Hooki i składniki złożone.
  • Zrozumienie zasad SOLID za pomocą praktycznych przykładów kodu — Ten przewodnik szczegółowo omawia wszystkie pięć zasad SOLID przy użyciu konkretnych przykładów kodu, pokazując, jak są one stosowane w rzeczywistych projektach i aplikacjach React.
  • Sześć technik TypeScript, które przekształcają type’y w skuteczną ochronę przed błędami — Dowiedz się, w jaki sposób satisfied, zagnieżdżone unie, never checks, unknown, typy pochodne oraz branded IDs pozwalają TypeScript łapać prawdziwe błędy już na etapie kompilacji, a nie w środowisku produkcyjnym.