Strona główna / Artykuły

Projekt z warstwami w React: warstwy, zasady importu i kiedy przestać

Dowiedz się, w jaki sposób warstwy projektu Feature-Sliced Design, zasada jednokierunkowego importu, proste API publiczne oraz @x cross-imports pomagają uporządkować kod w aplikacjach React, oraz kiedy warto zastosować tylko ich część.

Wydawca: REACTAPP.TOP7 min czytania1715 słów

Źródła: Feature-Sliced Design in React: How to Structure a Scalable Codebase (and When It’s Overkill)

Prawie każda baza kodu React, która przetrwa drugi rok istnienia, ma folder utils/, którego nikt nie chce otwierać. W środku znajduje się formatownik dat, narzędzie do ponawiania prób, pół maszyny stanowej oraz plik helpers.ts, który importuje elementy z trzech folderów funkcjonalnych, podczas gdy dwa z tych folderów importują go z powrotem, więc nikt nie może stwierdzić, co od czego zależy. Design Filtrowany według Funkcji (FSD) to metodologia skierowana właśnie na ten problem. Po przeczytaniu tego tekstu poznasz jego sześć warstw, jedną zasadę importowania, która wykonuje większość pracy, sposób projektowania granic fragmentów umożliwiający bezpieczną refaktoryzację, a przede wszystkim sposób decydowania, czy pełne zastosowanie FSD jest warte wysiłku dla twojej aplikacji, czy też lżejszy podzbiór jest lepszym wyborem.

Dlaczego foldery oparte na typach utrudniają skalowanie

Klasyczna struktura grupuje pliki według ich rodzaju (komponenty, hooki, narzędzia), a nie według przeznaczenia. To w porządku dla małej aplikacji, ale gdy pojawiają się rzeczywiste domeny biznesowe, wygląda to tak:

src/
 components/ # 200 files, no domain grouping
 hooks/ # useAuth next to useCartTotals
 utils/ # the grab-bag
 services/ # api calls that import from utils, which imports back

W takiej strukturze psują się dwie rzeczy. Po pierwsze, pytanie „gdzie powinien znajdować się ten plik?” nie ma jasnej odpowiedzi, więc wszystko, co jest niejednoznaczne, trafia do katalogu utils/. Po drugie, nic nie ogranicza kierunku zależności, co umożliwia powstawanie cykli. Wyobraźmy sobie, że usługa obsługująca zamówienia ładuje plik utils/user.ts, który z kolei polega na pliku services/auth.ts, a usługa autoryzacji ponownie importuje tę samą usługę obsługującą zamówienia. Takie pętle działają bezszelestnie. Zazwyczaj ujawniają się znacznie później w postaci wartości niespodziewanie równej undefined podczas pierwszego ładowania modułu, ponieważ jeden z plików w pętli został przetworzony przed zakończeniem pracy pliku, od którego zależy, a do tego czasu struktura zależności staje się zbyt skomplikowana, by ją odczytać.

FSD rozwiązuje oba te problemy, nadając całemu projektem określony kształt zamiast polegać na nawykach poszczególnych programistów. W chwili pisania wersją specyfikacji jest v2.1; sprawdź oficjalne dokumentacje w celu poznania późniejszych zmian.

Sześć warstw i to, co znajduje się w każdej z nich

FSD organizuje kod w warstwy o ustalonej kolejności, od góry do dołu: App, Pages, Widgets, Features, Entities, Shared. Istnieje również starsza warstwa Processes, ale jest przestarzała i można ją zignorować. Ta kolejność nie ma charakteru estetycznego; określa, kto może polegać na kim.

  • App: routowanie, punkty wejścia, globalne style i dostawcy.
  • Pages: pełne strony lub duże sekcje interfejsu użytkownika powiązane z konkretną trasą.
  • Widgets: duże, samodzielne bloki interfejsu użytkownika, które same w sobie realizują całe przypadki użycia.
  • Funkcje: implementacje możliwości produktu, które są wykorzystywane w kilku miejscach.
  • Entytety: koncepcje biznesowe, którymi zajmuje się projekt, takie jak użytkownik lub produkt.
  • Dane wspólne: kod wielokrotnie używalny, który nie wymaga wiedzy biznesowej, w tym system projektowy i pomocniki ogólne.
  • Cztery środkowe warstwy (Pages do Entities) są podzielone na segmenty, po jednym na każdą dziedzinę biznesową, na przykład entities/user lub features/auth. Warstwy App i Shared nie mają segmentów. Każdy segment jest następnie podzielony na części według roli technicznej: ui dla komponentów, api dla zapytań i typów odpowiedzi backendu, model dla schematów i magazynów danych, lib dla pomocników używanych wyłącznie w tym segmencie oraz config.

    Zasada importu uniemożliwiająca cykle

    Zasada leżąca u podstaw całego systemu jest prosta: moduł może importować tylko z warstw znajdujących się ściśle poniżej jego własnej. Strona może korzystać z funkcji, ale funkcja nigdy nie może korzystać ze strony. Ponadto elementy na tej samej warstwie w ogóle nie mogą importować się wzajemnie. W obrębie jednego elementu pliki mogą swobodnie odwoływać się do siebie; ograniczenie to obowiązuje również między elementami i warstwami.

    Zastosujmy tę zasadę do wcześniejszego cyklu. Po ponownym podziale logika porządkowa znajduje się w entities/order, logika użytkownika w entities/user, a proces autoryzacji w features/auth. Dozwolone zależności stają się następujące:

    features/auth → can import entities, shared
    entities/order → can import shared only
    entities/user → can import shared only
    

    Jeśli teraz entities/order spróbuje zaimportować elementy z features/auth, jest to naruszenie struktury architektury, a nie kwestia stylu. Cykl nie może powstać, ponieważ każdy krawędź w grafie zależności prowadzi w dół. To również zmienia sposób rozumowania o kodzie: zamiast śledzić importy plik po pliku, myśli się w kategoriach sześciu warstw, co jest modelem, który człowiek może faktycznie zapamiętać.

    Publiczne API zachowują prywatność elementów wewnętrznych

    Każda warstwa udostępnia publiczne API, zazwyczaj plik index.ts, który ponownie eksportuje elementy, które mogą być używane przez osoby z zewnątrz. Dokumentacja FSD jasno określa cel: udostępniać tylko to, co jest konieczne, a szczegóły implementacji zachowywać w tajemnicy.

    Bardzo powszechna wersja tego pliku podważa ten cel:

    // features/auth/index.ts (the anti-pattern)
    export * from "./ui";
    export * from "./model";
    

    Znaki zastępcze ponownie eksportują wszystko, włączając model/tokenStore.ts, który miał pozostać prywatny. Prędzej czy później jakaś strona zaimportuje bezpośrednio ten token store, a od tego momentu nie można go przearanżować bez uszkodzenia aplikacji, o której się nie wiedziało. Znaki zastępcze mogą również utrudnić narzędziom kompilacyjnym proces usuwania niepotrzebnego kodu, przez co nieuzyskiwany kod może trafić do użytkowników.

    Alternatywa wymienia każdy eksport wyraźnie:

    // features/auth/index.ts (the boundary)
    export { LoginForm } from "./ui/LoginForm";
    export { useSession } from "./model/useSession";
    // tokenStore stays internal. Nobody outside auth can touch it.
    

    Ta struktura ma teraz dokładnie dwa publiczne nazwy. Wszystko inne w jej obrębie można przemienić, podzielić lub przepisać bez wpływu na żadnego wywołującego ją element. Ta kombinacja – czytelny graf zależności plus swoboda modyfikacji wewnętrznych elementów – stanowi główną zaletę obiecywaną przez FSD.

    Istnieje jedna zastrzeżenie w dokumentacji FSD: pliki index typu barrel mogą powodować cykliczne importy, problemy z optymalizacją w katalogach shared/ui i shared/lib, a także spowalniać narzędzia do pakowania w dużych projektach. Praktycznym rozwiązaniem jest utrzymywanie publicznych API w sposób jasny i minimalistyczny, a także szczególna ostrożność w dziale Shared, gdzie wiele małych, niezależnych modułów lepiej jest importować bezpośrednio niż kierować je przez jeden duży plik barrel.

    Importy międzymodułowe, nadmierna fragmentacja i pozwalanie narzędziom linter na interwencję

    Oznaczenie @x dla elementów, które odwołują się do siebie

    Część zmiennych rzeczywiście jest od siebie uzależniona; na przykład zamówienie wymaga typu User. Ponieważ importy w tym samym warstwie są zabronione, wersja v2.1 ustandaryzowała notację @x właśnie dla takich sytuacji. Warstwa user publikuje dedykowany punkt wejścia przeznaczony dla warstwy order, a order importuje z niej dane:

    // entities/user/@x/order.ts
    export type { User } from "../model/types";
    
    // in entities/order/model/types.ts
    import type { User } from "entities/user/@x/order";
    

    Nazwa pliku jasno określa tę zależność i ułatwia jej wyszukiwanie: entities/user/@x/order.ts to ta część warstwy user, którą order może widzieć. Dokumentacja traktuje to jako wyjątek o ograniczonym zastosowaniu. Unikaj nadmiernych importów między warstwami i używaj @x wyłącznie w warstwie Entities. Jeśli okaże się, że potrzebujesz go w całym kodzie, oznacza to, że Twoje warstwy są zdefiniowane w niewłaściwych miejscach.

    Steiger sygnalizuje zarówno naruszenia zasad, jak i nadmierną skomplikowanie architektury

    Zasady są przydatne tylko wtedy, gdy są egzekwowane. Steiger, narzędzie do sprawdzania architektury w FSD (jego zasady dostępne są jako @feature-sliced/steiger-plugin), działa bez konfiguracji i w momencie pisania tego tekstu nadal znajdował się w wersji beta. Oprócz sprawdzania zasad importu, zapobiega nadmiernemu projektowaniu: reguła insignificant-slice aktywuje się, gdy funkcja lub element składa się wyłącznie z jednej strony, natomiast excessive-slicing wskazuje na sytuację, gdy projekt został podzielony na zbyt wiele małych części. Gdy Steiger zgłosi nieważną część projektu, przyjmij to jako wskazówkę – stworzyłeś folder, w którym wystarczyłaby zwykła funkcja lokalna.

    Gdy pełna wersja FSD jest przesadą

    Nazwy folderów to najmniej ważna część FSD. Kopiowanie wszystkich sześciu warstw do małej aplikacji powoduje swego rodzaju bałagan: więcej plików niż logiki, a każda funkcja rozproszona jest w czterech katalogach, przez co prosta zmiana zamienia się w zadanie poszukiwawcze.

    Specyfikacja to rozumie, dlatego wersja v2.1 zaleca podejście „najpierw strony” przy wdrażaniu. Zacznij od App, Pages, Widgets i Shared i rozważ zatrzymanie się na tym etapie. Dla wielu aplikacji to już stanowi kompletną i czytelną strukturę.

    Przenoś kod do sekcji Features i Entities tylko wtedy, gdy istnieje konkretny powód. Praktyczną zasadą jest czekanie, aż ta sama logika będzie potrzebna na trzech lub więcej stronach lub widgetach, zanim nadasz jej własną sekcję. Do tego czasu właściwym wyborem jest proces zakupów umieszczony w całości w katalogu pages/checkout, a nie jakakolwiek skrótka. Jedno użycie ponowne może być zbiegiem okoliczności; trzy przypadki wskazują na rzeczywisty wzorzec, a właśnie reprezentowanie takich wzorców jest celem istnienia warstwy Features.

    Jeśli rozważasz również FSD w porównaniu z prostszymi strukturami folderów lub rozwiązaniami opartymi na domenach, nasze porównanie wzorców struktury folderów w React przedstawia te alternatywy obok siebie.

    Krótki lista kontrolna wdrożenia

    • Wdrożenie pełnej wersji FSD jest konieczne, gdy zespół składa się z więcej niż kilku inżynierów, liczba obszarów biznesowych stale rośnie, a katalog utils/ stał się już miejscem zbierania niepotrzebnego kodu.
    • W przypadku małego produktu z jednym wyraźnie określonym właścicielem należy zachować zasadę importu w dół oraz uprościć publiczne API, unikając dodatkowych warstw.
    • Każdy plik index.ts powinien być napisany jako wyraźna lista eksportów; unikać należy zapisu export *.
    • Symbol @x należy zachować wyłącznie do odniesień między elementami, a częste jego użycie powinno być traktowane jako problem graniczny.
    • Należy używać narzędzi takich jak Steiger, aby zasady oraz ostrzeżenia dotyczące niepotrzebnych fragmentów kodu były stosowane automatycznie.

    Podsumowanie

    Sześć folderów nigdy nie było celem. To, co FSD naprawdę oferuje, to graf zależności, który przepływa tylko w jednym kierunku, oraz warstwy, których struktura jest ukryta za małymi kontraktami – wszystko to razem sprawia, że baza kodu pozostaje zrozumiała nawet po roku. Większość tych korzyści można uzyskać już dzięki regułom importu i publicznym API, dodając kolejne warstwy tylko wtedy, gdy faktycznie jest to konieczne ze względu na potrzeby ponownego użycia.