Strona główna / Artykuły

Rozwiązywanie problemu przeładowania właściwości w React za pomocą kompozycji i slotów

Dowiedz się, dlaczego React props o dużej liczbie ustawień powodują dług konserwacyjny, oraz jak inwersja kontroli, kompozycja i sloty umożliwiają tworzenie naprawdę wielokrotnie używalnych komponentów.

Wydawca: REACTAPP.TOP11 min czytania2538 słów

Źródła: Your React Components Aren’t Reusable. They’re Just Complicated

Zapoznaj się z tym, jak właściwości sterowane konfiguracją po cichu powodują narastający dług konserwacyjny, oraz jak odwrócenie kontroli w połączeniu z kompozycją prowadzi do prawdziwie modułowych komponentów interfejsu użytkownika.

Zobaczmy prośbę o pull request od kolegi z zespołu, który chce wprowadzić to, co z dumą nazywa "Uniwersalną Tabelą Danych."

Celem jest załatwienie wszystkich potrzeb związanych z wyświetlaniem tabel w całym kodzie za jednym razem.

Jest ona dostarczana z trzydziestoma czterema oddzielnymi właściwościami:

<UniversalDataTable
  data={users}
  columns={columns}
  enablePagination={true}
  paginationType="cursor"
  enableSorting={true}
  showFilterRow={true}
  hasExportToCsvButton={true}
  exportButtonText="Download CSV"
  headerBackground="light-gray"
  denseRows={false}
  enableRowSelection={true}
  selectionMode="multiple"
  renderCustomRowActions={(row) => <DropdownMenu row={row} />}
  onRowClick={(row) => navigate(`/users/${row.id}`)}
/>

W jej wnętrzu znajduje się około 1 200 linii logiki.

Dla wyświetlania listy użytkowników działa doskonale.

Kilka tygodni później dział marketingu prosi o tabelę porównawczą cen, która ma trafić na stronę publiczną.

Wymagania to: trzy kolumny, ikony z znaczkami obok każdego wiersza funkcji oraz podświetlony kontur „Popularny” wokół środkowej kolumny.

Ktoś sięga po <UniversalDataTable />, oczekując, że po prostu zadziała.

Wtedy właśnie wszystko się psuje.

Nie ma możliwości dostosowania pojedynczych krawędzi komórek.

Rozciąganie nagłówków jest z góry ustalone w konkretnym układzie flex.

Aby uzyskać czysty wynik, trzeba podać hasExportToCsvButton={false} wraz z kilkoma innymi ustawieniami przejęciami.

Dlatego zespół dodaje jeszcze cztery właściwości:

isPricingTable
popularColumnIndex
customHeaderRenderer
disableGlobalStyles

Plik rozrasta się do 1 450 wierszy.

W tym momencie w ogóle nie nadaje się do ponownego użycia.

To, co zaczęło się jako abstrakcja, przerodziło się w plątaninę specjalnych przypadków udających jeden komponent.

1. Pułapka właściwości konfiguracyjnych

Gdy zespół chce, aby komponent był „ponownie używalny”, instynktownym rozwiązaniem jest zazwyczaj konfiguracja za pomocą właściwości.

Każdy nowy przypadek użycia powoduje dodanie kolejnego flaga, wartości z typu string enum, funkcji callback lub modyfikacji stylu do interfejsu props.

Typowy przykład:

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
  hasLeftIcon?: boolean;
  leftIcon?: React.ReactNode;
  hasRightIcon?: boolean;
  rightIcon?: React.ReactNode;
  isFullWidth?: boolean;
  tooltipText?: string;
  tooltipPosition?: 'top' | 'bottom';
  rounded?: boolean;
}

Na pierwszy rzut oka wydaje się to dostosowalne.

Problem polega na tym, że elastyczność oparta na konfiguracji wiąże się z kosztem, którego nie dostrzegasz od razu.

1. Eksplozja kombinatoryczna

Już dziesięć właściwości typu boolean daje w teorii 1 024 możliwe kombinacje.

Nie musisz testować ich wszystkich, aby zrozumieć, jak szybko sytuacja wymyka się spod kontroli.

Pewne kombinacje są ze sobą sprzeczne.

Inne pozostaną na zawsze nieużywane.

Część z nich będzie renderowana w różny sposób w zależności od szerokości okna przeglądarki, otaczającej treści lub głębokości w której znajduje się komponent.

Z upływem czasu otrzymujemy zbiór przypadków krawędziowych zamiast spójnego, przewidywalnego API.

2. Przeszukiwanie właściwości

Mówimy o sytuacji, gdy ustawienie w podpowiedzi musi dotrzeć do zagnieżdżonego elementu ukrytego na kilku poziomach wewnątrz drzewa komponentów.

Każdy pośredni komponent w tym procesie musi przekazywać dalej właściwość, której w rzeczywistości nie używa.

Konfiguracja przechodzi przez kolejne warstwy wyłącznie dlatego, że tego wymaga struktura, a nie dlatego, że ma tam sens.

To właśnie jest mechanizm stojący za zjawiskiem „prop drilling”.

3. Sztywne założenia dotyczące układu

To właśnie w tym miejscu komponenty oparte na konfiguracji zazwyczaj ostatecznie przestają funkcjonować prawidłowo.

Załóżmy, że nowy projekt wymaga umieszczenia ikony pomiędzy dwoma wierszami tekstu przycisków.

Dodanie czegoś takiego jak:

iconPosition="between"

może na razie naprawić sytuację.

Ale co w momencie, gdy projekt znów ulegnie zmianie?

Niedługo potem będziesz musiał utrzymywać całą serię wariantów:

iconPosition="left"
iconPosition="right"
iconPosition="top"
iconPosition="between"
iconPosition="absolute"

Lista wciąż się rozszerza, ponieważ wewnętrzny JSX komponentu nadal bezpośrednio określa jego układ.

Prawdziwym problemem nie jest liczba dostępnych właściwości.

Jest nim to, że komponent przejmuje zbyt wiele obowiązków dotyczących kwestii poza jego zakresem działania.

2. Inwersja kontroli: lepszy model myślowy

Prawdziwa możliwość ponownego użycia nie jest osiągana poprzez próbę uprzedniego przewidzenia wszystkich przyszłych wymagań.

Pochodzi ona z zastosowania Inwersji kontroli (IoC).

Koncepcja jest prosta:

Zamiast pozwalać komponentowi określać wszystko, co jest renderowane w jego obrębie, niech osoba używająca komponentu kontroluje te części, które wymagają zmiany.

Dlatego właśnie kompozycja jest tak ważnym wzorcem w React.

Zamiast tworzyć przycisk, który ma wbudowaną obsługę podpowiedzi, ikon, spinerów, odznak oraz wszelkich możliwych układów, stwórz przycisk, którego głównym zadaniem jest po prostu bycie przyciskiem.

Naprzимер:

export function Button({
  children,
  variant = 'primary',
  className = '',
  ...props
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
}

Kontrola nad treścią znajduje się teraz w rękach użytkownika:

<Tooltip content="Save your profile">
  <Button>
    <SaveIcon />
    <span>Save Changes</span>
  </Button>
</Tooltip>

Zauważ, co już nie jest konieczne.

Przycisk nie ma żadnej wiedzy o istnieniu podpowiedzi.

Nie ma właściwości hasLeftIcon.

Nie ma właściwości leftIcon.

Nie ma właściwości iconSpacing.

Nie ma właściwości hasSpinner.

Potrzebujesz spinera ładowania? Po prostu umieść go bezpośrednio wewnątrz przycisku.

<Button>
  <Spinner />
  Saving...
</Button>

Komponent zmniejsza się w rozmiarze, zyskując przy tym elastyczność.

To właśnie jest kluczowym wnioskiem w tym przypadku.

Komponent wielokrotnego użycia nie musi wewnętrznie obsługiwać każdego możliwego scenariusza. Potrzebuje dobrze zdefiniowanych granic, na których mogą być budowane inne komponenty.

3. Sloty ułatwiają rozszerzanie złożonych komponentów

Budowanie struktury za pomocą children sprawdza się dobrze przy prostych elementach. Ale co w przypadku bardziej złożonych komponentów, takich jak karty, okna dialogowe, panele sterowania czy profile?

Tutaj właśnie przydają się sloty i złożone komponenty.

Wyobraź sobie konwencjonalną kartę profilu zbudowaną na bazie zbioru właściwości:

<UserProfileCard
  avatarUrl="/avatar.jpg"
  name="Sarah Connor"
  role="Tech Lead"
  status="Active"
  bio="Building reliable frontend systems."
  primaryActionLabel="Send Message"
  onPrimaryAction={sendMessage}
  secondaryActionLabel="View Profile"
  onSecondaryAction={viewProfile}
  badgeColor="green"
/>

Na pierwszy rzut oka wydaje się to wygodne.

Następnie wymagania zaczynają się zmieniać.

Zespół marketingu chce, aby handle społecznościowe danej osoby były pokazywane tuż obok jej imienia. Zespół administracyjny kont korporacyjnych potrzebuje banera z napisem „Wstrzymane” rozciągniętego na całej szerokości karty. Inny zespół produktowy chce trzy przyciski akcji zamiast dwóch. Projektant postanawia, że awatar powinien znajdować się po prawej stronie, a nie po lewej.

Każda z tych prośb zamienia się w kolejny element do opracowania.

Lepszym podejściem jest przedstawienie układu jako złożonych elementów, które można modyfikować:

<Card>
  <Card.Header>
    <Avatar
      src={user.avatarUrl}
      alt={user.name}
    />
    <div>
      <Card.Title>{user.name}</Card.Title>
      <Card.Subtitle>{user.role}</Card.Subtitle>
    </div>    <Badge variant={user.statusColor}>
      {user.status}
    </Badge>
  </Card.Header>  <Card.Body>
    <p>{user.bio}</p>
  </Card.Body>  <Card.Actions>
    <Button
      variant="secondary"
      onClick={viewProfile}
    >
      View Profile
    </Button>    <Button
      variant="primary"
      onClick={sendMessage}
    >
      Send Message
    </Button>
  </Card.Actions>
</Card>

Teraz można faktycznie zobaczyć strukturę karty w kodzie JSX. A ponieważ ją widzimy, możemy ją zmienić.

Chcesz jeszcze jeden przycisk akcji? Po prostu go dodaj. Potrzebujesz innego znaku identyfikacyjnego? Zmień go. Musisz dodać kolejny baner? Wstaw go tam, gdzie powinien być. Chcesz zupełnie inny układ nagłówka? Po prostu napisz nowy nagłówek.

Nic z tego nie wymaga modyfikowania rozdętego komponentu współdzielonego, aby obsłużyć pojedynczą nową wersję.

4. Kompozycja przewyższa konfigurację

Te dwa podejścia stawiają fundamentalnie różne pytania.

Konfiguracja pyta:

"Jakie zestaw opcji powinien udostępniać ten komponent?"

Kompozycja pyta:

"Jakie małe elementy budulcowe mogą być łączone przez użytkowników w to, czego faktycznie potrzebują?"

Konfiguracja ciągle przenosi większą odpowiedzialność za podejmowanie decyzji na sam komponent. Kompozycja zwraca te decyzje tym, którzy korzystają z komponentu. To zazwyczaj lepsze miejsce na przeniesienie tej odpowiedzialności.

Weźmy to porównanie jako przykład:

<Button
  hasLeftIcon
  leftIcon={<SaveIcon />}
  hasTooltip
  tooltipText="Save your profile"
  showSpinner={isSaving}
  spinnerPosition="left"
/>

w przeciwieństwie do:

<Tooltip content="Save your profile">
  <Button>
    {isSaving && <Spinner />}
    <SaveIcon />
    Save Profile
  </Button>
</Tooltip>

Drugie fragment kodu rzeczywiście zawiera więcej JSX, to prawda. Ale ma też znacznie mniej powiązań pomiędzy elementami, które nie muszą o sobie wiedzieć. Taka zamiana zwykle jest opłacalna – trochę dodatkowego markupu w miejscu wywołania zazwyczaj jest tańsze w dłuższej perspektywie niż dodawanie kolejnej stałej funkcjonalności do wspólnego komponentu.

5. Ucz się od bibliotek bez interfejsu graficznego

Kilka nowoczesnych bibliotek frontendowych doskonale ilustruje tę filozofię.

Projekty takie jak Radix UI, TanStack Table i shadcn/ui odegrały ważną rolę w upowszechnianiu tego podejścia opartego na kompozycji.

TanStack Table jest jednym z najjaśniejszych przykładów. Nie narzuca on sztywnego projektu tabeli – zamiast tego dostarcza logikę i mechanizmy zarządzania stanem dla takich zadań jak:

  • Sorтировanie
  • Filtrowanie
  • Paginacja
  • Wybór wierszy
  • Zarządzanie kolumnami
  • Stan tabeli
  • Przekształcenie tego stanu w rzeczywisty kod markup zależy wyłącznie od ciebie. Na przykład:

    <table>
      <thead>
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <th key={header.id}>
                {flexRender(
                  header.column.columnDef.header,
                  header.getContext()
                )}
              </th>
            ))}
          </tr>
        ))}
      </thead>
    
      <tbody>
        {/* Your own rendering logic */}
      </tbody>
    </table>
    

    Dokładna struktura API nie jest tu tak naprawdę istotna. Ważna jest podstawowa architektura: silnik tabeli odpowiada za zachowanie, a ty za prezentację. Ponieważ te dwa aspekty są oddzielone, możesz swobodnie przeprojektować wygląd bez konieczności rozszerzania podstawowej logiki tabeli do jednego ogromnego komponentu wizualnego.

    6. Oddzielenie zachowania od wyglądu

    To naturalnie prowadzi do kolejnej zasady, którą warto przestrzegać:

    Trzymaj to, co robi komponent, oddzielnie od sposobu jego renderowania.

    Jeden komponent może faktycznie musieć radzić sobie z znaczną ilością wewnętrznej złożoności, obejmującej m.in.:

    • Nawigację klawiaturą
    • Zarządzanie fokusem
    • Wybór
    • Sorтировka
    • Filtrowanie
    • Stan
    • Dostępność
    • Pozycjonowanie

    Skuteczne zarządzanie tym wszystkim rzadko jest proste. Mimo to żaden z tych elementów nie wymusza, by ten sam komponent określał również dokładny wygląd końcowego rezultatu.

    To jest podstawowa idea większości systemów interfejsu bez głowy. To zachowanie jest wykorzystywane w wielu wizualnych implementacjach, podczas gdy warstwa prezentacji pozostaje całkowicie otwarta. Taki podział zazwyczaj zapewnia znacznie lepszą możliwość ponownego użycia niż próba stworzenia jednego ogromnego komponentu wypełnionego dziesiątkami elementów konfiguracji wizualnej.

    7. Kiedy props naprawdę mają sens

    To wszystko nie oznacza, że props to zły wzorzec.

    Są to jedne z najbardziej podstawowych narzędzi dostarczanych przez React.

    Problem nie polega na tym, że props istnieją. Chodzi o używanie ich do reprezentowania każdej możliwej zmiany strukturalnej, jakiej może potrzebować komponent.

    Przykłady takie jak te są w pełni przyjęte:

    <Button variant="primary" />
    <Button size="sm" />
    <Input disabled />
    <Card className="featured" />
    

    Czerwony znak pojawia się, gdy komponent zaczyna gromadzić listę w ten sposób:

    <Component
      showHeader
      showFooter
      showBadge
      showIcon
      showActions
      iconPosition="left"
      badgePosition="right"
      footerAlignment="center"
      actionLayout="horizontal"
      compact
      dense
      bordered
      rounded
      disableAnimation
      customHeaderRenderer={...}
      customFooterRenderer={...}
    />
    

    Gdy zauważysz taki wzorzec, warto na chwilę się zatrzymać i zadać sobie pytanie:

    ">Czy naprawdę powinny one pozostać jako propsy, czy niektóre z nich lepiej sprawdziłyby się jako komponenty składane?"

    Samo postawienie tego pytania pomaga uniknąć wielu niepotrzebnych komplikacji w przyszłości.

    8. Jakie są koszty nadmiernej abstrakcji

    Prawdziwe szkody spowodowane nadmiernie skonfigurowanymi komponentami nie są estetyczne.

    Chodzi o to, że ich modyfikacja staje się kosztowna.

    Komponent szeroko używany ma duży zasięg wpływu. Jeśli dwadzieścia miejsc w twojej bazie kodu od niego zależy, to każda nowa właściwość dodana może wpłynąć na wszystkie te elementy.

    Inżynierowie zaczynają wahać się, czy go edytować.

    Pisanie dokumentacji do niego staje się trudniejsze.

    Testowanie go staje się trudniejsze.

    Wykrywanie błędów w nim staje się trudniejsze.

    W końcu komponent staje się tak doskonale „ponownie wykorzystywalny”, że nikt tak naprawdę nie chce go używać ponownie.

    To ironia wbudowana w ten wzorzec.

    Nadmierna abstrakcja może podważyć właśnie tę ponowną wykorzystywalność, którą miał zapewnić.

    Niewielka, ściśle określona prymitywa często okazuje się w praktyce bardziej ponownie wykorzystywalna niż rozbudowany komponent stworzony z myślą o przewidzeniu każdej możliwej wariacji.

    Praktyczne ramy do budowania komponentów

    Zanim sięgniesz po kolejną właściwość konfiguracyjną, sprawdź te punkty.

    1. Czy mam do czynienia z zachowaniem, czy z prezentacją?

    Jeśli chodzi o zachowanie, odpowiednim narzędziem jest prawdopodobnie właściwość lub niestandardowy hook. Jeśli sprawa dotyczy wyłącznie struktury wizualnej, zazwyczaj lepszym rozwiązaniem jest kompozycja.

    2. Czy ten komponent rzeczywiście potrzebuje tych informacji?

    Jeśli od jednego komponentu oczekuje się, że będzie rozumiał podpowiedzi, hooki analityczne, odznaki, ikony, wskaźniki ładowania oraz każdą możliwą kompozycję swoich elementów potomnych, to z niemal pewnością przejął on za dużo obowiązków.

    3. Czy zamiast tego children mógłby to obsłużyć?

    Jeśli tak, lepiej zastosować kompozycję niż wprowadzać kolejną właściwość odpowiadającą za treść.

    4. Czy nazwane sloty mogłyby uprościć API?

    Dla bardziej złożonych komponentów definiowanie wyraźnych obszarów, takich jak:

    <Card.Header />
    <Card.Body />
    <Card.Footer />
    

    może znacznie ułatwić zrozumienie struktury.

    5. Czy logikę można oddzielić od markupu?

    Jeśli tak, warto rozważyć podejście bez głowy lub skupione na logice.

    6. Czy ten element rozwiązuje rzeczywisty problem, czy tylko wyimaginowany?

    To pytanie ma większe znaczenie, niż się wydaje.

    Unikaj dodawania czegoś takiego jak:

    futureFeature={true}
    

    tylko dlatego, że ktoś może tego w przyszłości zapragnąć.

    Projektuj zgodnie z wymaganiami, dla których masz rzeczywiste dowody, a nie tymi, co tylko przypuszczasz.

    Podsumowanie

    Celem tworzenia ponownie używalnych komponentów React nie jest stworzenie czegoś, co może robić absolutnie wszystko.

    Celem jest stworzenie elementów, które mogą być składane na wiele różnych sposobów.

    Różnica ta wydaje się subtelna, ale pod względem architektonicznym zmienia wszystko.

    Komponent posiadający trzydzieści właściwości może na pierwszy rzut oka wydawać się elastyczny, ale każda z tych właściwości wprowadza dodatkową odpowiedzialność, kolejny stan do obsługi oraz kolejną kombinację, którą ktoś w końcu będzie musiał wspierać.

    Natomiast komponent o wąskiej API i jasnych granicach składania może wyglądać prosto.

    Jednak w praktyce zazwyczaj lepiej radzi sobie z upływem czasu.

    Zatem następnym razem, gdy będziesz miał pokusę dodać kolejną właściwość do współdzielonego komponentu, zatrzymaj się na chwilę i zastanów:

    ">Czy czynię to bardziej wielokrotnie użytecznym, czy tylko bardziej konfigurowalnym?"

    Ty dwie rzeczy nie są ze sobą zamienialne.

    Komponent nie staje się wielokrotnie użyteczny poprzez gromadzenie trzydziestu opcji.

    Staje się wielokrotnie użyteczny, wykonując dobrze jedną funkcję, definiując sensowne granice i w pozostałych kwestiach nie przeszkadzając innym.

    Architektura solidnych komponentów nie polega na przewidywaniu każdej przyszłej potrzeby.

    Chodzi o to, aby wszystkie przyszłe potrzeby można było łatwo połączyć w spójne rozwiązania.

    Literatura pokrewna

  • React Query i Redux: Przemyślenie stanu serwera w dużych aplikacjach — Dowiedz się, dlaczego aplikacja do czatowania w środowisku produkcyjnym używała TanStack Query zamiast Redux do zarządzania danymi serwera oraz gdzie Redux nadal ma swoje zastosowanie w nowoczesnej architekturze React.
  • Jak brauzer rysuje i gdzie mieści się React — Dowiedz się, jak krytyczna ścieżka renderowania, proces synchronizacji, Fiber oraz planer współpracują ze sobą, aby przekształcić aktualizacje React w piksele na ekranie.
  • Skracanie React Prop-Drilling i bogatych komponentów do rozsądnego rozmiaru — Poznaj siedem konkretnych wzorców refaktoryzacji, które pozwalają rozbić przeciążone komponenty React poprzez izolację stanu, pobierania danych, uprawnień oraz logiki ładowania, zamiast jedynie dzielenia plików.
  • Kiedy użyteczne komponenty React działają przeciwnie: eksplozja prop i rozwiązanie — Zobacz, jak przedwczesne użycie powtarzalnych komponentów React zamienia prosty komponent w obciążony licznymi parametrami problem, oraz jak duplikacja, złożone komponenty i zasada trzech zapobiegają temu.
  • Framework-Agnostyczny Frontend DI z InversifyJS i Composition Root — Jak wybrać kontener DI dla TypeScript, zapakować każdą domenę jako ContainerModule, połączyć wszystko w jednym Composition Root oraz stworzyć most łączący go z React, Vue i Angular.