Gdy ponawialne komponenty React idą nie tak: wybuch propów i rozwiązanie
Zobacz, jak przedwczesne ponowne użycie przekształca prosty komponent React w obciążony licznymi parametrami problem, oraz jak duplikacja, złożone komponenty i zasada trzech zapobiegają temu.
Komponenty współdzielone mają za zadanie oszczędzać czas, jednak w wielu bazach kodu frontendowych istnieje jeden komponent, którego nikt nie odważa się edytować: <Modal /> lub <Card /> z dziesiątkami atrybutów, przy których naprawa błędu na jednym ekranie psuje trzy inne. Taki wynik rzadko jest skutkiem nieuwagi; to naturalny rezultat zbyt wczesnego ponownego użycia interfejsu. Ten artykuł pokazuje, jak komponent staje się takim obciążeniem, wyjaśnia, dlaczego duplikacja interfejsu jest często tańszym rozwiązaniem, oraz przedstawia dwa praktyczne narzędzia do uniknięcia tego problemu: komponenty złożone oraz Zasada Trzech.
Jak nieszkodliwy skrót zamienia się w komponent z trzydziestoma dwoma atrybutami
Zazwyczaj cała sytuacja zaczyna się od rozsądnej prośby. Projektant przekazuje ekran płatności z oknem dialogowym, które niemal identyczne jest z oknem potwierdzenia na stronie ustawień. Różnice są niewielkie: przyciski znajdują się po lewej stronie, nad tytułem widnieje pomarańczowy znacznik, a cienka linia oddziela przyciski od treści.
<Modal />, i proponuje jego ponowne użycie. Kilka godzin później przychodzi prośba o integrację, która dodaje cztery właściwości: hasOrangeBadge, alignActionsLeft, showDividerLine oraz badgeText.
Powtarzaj to tuzin razy w ciągu roku. Ten sam moduł teraz wymaga trzydziestu dwóch właściwości, zawiera piętnaście zagnieżdżonych wyrażeń warunkowych oraz dwanaście flag logicznych, a do synchronizacji wewnętrznych animacji z różnymi kombinacjami właściwości wykorzystuje trzy hooki useEffect. Następnie ktoś zmienia wartość jednej z właściwości padding, aby naprawić błąd w systemie fakturowania, i nieumyślnie uszkadza moduł na czterech zupełnie niepowiązanych ekranach. Wysiłki zmierzające do utrzymania czystego i wielokrotnie używalnego kodu doprowadziły do powstania komponentu, którego nikt nie chce obsługiwać.
Dlaczego zasada DRY ma inne znaczenie w interfejsach użytkownika
Zasada „Nie powtarzaj się” jest sensowną radą z konkretnego powodu. Gdy logika biznesowa, taka jak obliczanie płatności czy sprawdzanie uprawnień, jest kopiowana, naprawa zastosowana do jednej kopii pozostawia pozostałe w stanie uszkodzonym, a te kopie stopniowo się od siebie oddalają.
Komponenty interfejsu użytkownika zmieniają się z różnych powodów. Zasady biznesowe ulegają zmianie, gdy zmienia się domena. Interfejsy ewoluują wraz z ścieżkami użytkownika, punktami przełomowymi responsywności, wymaganiami dostępności, decyzjami dotyczącymi produktu oraz eksperymentami projektowymi, a te czynniki oddziałują na każdy ekran niezależnie.
Dwa elementy, które wyglądają podobnie, nie muszą koniecznie reprezentować tego samego pojęcia. Połączenie dwóch wzorców interfejsu użytkownika w jeden wspólny komponent przed ustaleniem ich ostatecznego wyglądu powoduje powstanie powiązań pomiędzy funkcjami, których wcześniej nie istniało. Od tego momentu niewielka modyfikacja projektu dotycząca procesu onboardingu może wymagać ponownego testowania ustawień, systemów fakturacji i analizy danych, po prostu dlatego, że te elementy wyświetlają ten sam komponent. W takiej sytuacji ponowne wykorzystywanie komponentów działa przeciwko tobie.
Anatomia „eksplozji propów”
Pomaga obserwować, jak zmiany zachodzą krok po kroku w ramach poszczególnych sprintów. Punkt wyjścia to karta z jasnym, minimalistycznym kontraktem: tytułem, opisem oraz opcjonalnym obsługiwaczem kliknięć.
interface CardProps {
title: string;
description: string;
onClick?: () => void;
}
Implementacja jest równie prosta i łatwa do zrozumienia:
export function Card({ title, description, onClick }: CardProps) {
return (
<div className="card" onClick={onClick}>
<h3>{title}</h3>
<p>{description}</p>
</div>
);
}
W tym komponencie nie ma nic złego. Następnie, w sprintzie 4, dział marketingu chce karty blogowe z obrazkiem u góry, więc pojawiają się dwa opcjonalne atrybuty obrazka:
interface CardProps {
// ...
imageUrl?: string;
imageAlt?: string;
}
W sprintzie 7 zespół dashboardu prosi o przycisk działań w rogu, widoczny tylko na aktywnych elementach. Wymaga to flagi, ikony oraz obsługiwacza:
interface CardProps {
// ...
hasTopRightAction?: boolean;
topRightActionIcon?: React.ReactNode;
onTopRightAction?: () => void;
}
Do sprintu 12 zespół analityczny chce drugi wiersz pod tytułem, odznakę stanu w trzech kolorach oraz stopkę, która może się rozszerzać, co dodaje kolejne sześć atrybutów:
interface CardProps {
// ...
subtitle?: string;
badgeText?: string;
badgeVariant?: "success" | "warning" | "danger";
isExpandable?: boolean;
expandedContent?: React.ReactNode;
defaultExpanded?: boolean;
}
Do sprintu 20 funkcja renderowania stała się serią warunków. Element korzeniowy oblicza swoją klasę na podstawie flagi, a obraz jest renderowany tylko wtedy, gdy istnieje URL:
export function Card(props: CardProps) {
return (
<div
className={`card ${
props.isExpandable ? "card-expandable" : ""
}`}
>
{props.imageUrl && (
<img src={props.imageUrl} alt={props.imageAlt} />
)}
Opakowanie nagłówka otwiera się z tytułem:
<div className="card-header">
<div>
<h3>{props.title}</h3>
za którym następuje podtytuł, który pojawia się tylko wtedy, gdy jest dostarczony:
{props.subtitle && <h4>{props.subtitle}</h4>}
</div>
Działanie w górnym prawym rogu zależy od flagi logicznej, a nie od tego, czy istnieje obsługa, więc możliwe jest ustawienie flagi i zapomnienie o ikonie lub przekazanie ikony i zapomnienie o flagi:
{props.hasTopRightAction && (
<button onClick={props.onTopRightAction}>
{props.topRightActionIcon}
</button>
)}
</div>
Odznaka buduje nazwę klasy na podstawie swojej wersji i po cichu przechodzi na wersję default, której typ nawet nie wymienia:
{props.badgeText && (
<span
className={`badge badge-${
props.badgeVariant || "default"
}`}
>
{props.badgeText}
</span>
)}
Opis, jedyne element pozostały z oryginalnego projektu, znajduje się pośrodku:
<p>{props.description}</p>
A rozszerzalny stopka zamyka komponent. Zauważ, że isExpandable kontroluje zarówno klasę nadrzędną, jak i stopkę, podczas gdy defaultExpanded z interfejsu nie jest używany nigdzie w markupu:
{props.isExpandable && (
<div className="card-footer">
{props.expandedContent}
</div>
)}
</div>
);
}
Takie małe nieścisłości są typowe. Gdy komponent posiada tak wiele flag, nikt nie może jednocześnie zobaczyć wszystkich kombinacji, co prowadzi do pojawienia się nieprawidłowych lub częściowo zaimplementowanych stanów. Każdy użytkownik <Card /> musi zapoznać się z coraz dłuższym wykazem opcji i ustalić, które kombinacje są obsługiwane. Abstrakcja ta staje się teraz trudniejsza do zrozumienia niż prosty markup, który miała zastąpić.
Duplikacja jest często tańsza niż błędna abstrakcja
Słynne powiedzenie z dziedziny projektowania oprogramowania, upowszechnione przez Sandi Metz, mówi wprost: ">Podwajanie jest o wiele tańsze niż błędna abstrakcja." To rada najbardziej się sprawdza w pracy nad interfejsem użytkownika.
Gdy dwa komponenty jedynie się do siebie podobają, lepszym wyborem jest często trzymanie ich oddzielnie. Załóżmy, że BillingModal i OnboardingModal to niezależne komponenty:
- Zmiana w
BillingModalnie może wpłynąć naOnboardingModal. - Usunięcie funkcji onboardingu usuwa zarazem jego modal oraz całą specyficzną dla niej logikę.
- Każdy modal rozwija się zgodnie ze swoimi własnymi wymaganiami.
- Niewielka zmiana wizualna nie wymaga zrozumienia dziesiątek właściwości należących do innych funkcji.
Kopiowanie JSX kosztuje zaledwie kilka dodatkowych wierszy kodu. Błędna abstrakcja kosztuje o wiele więcej – czas na debugowanie, testy regresji oraz ciągłą konserwację. Nie każdy powtarzający się fragment markupu zasługuje na komponent współdzielony.
Komponenty złożone: kompozycja zamiast konfiguracji
Prawdziwe ponowne wykorzystanie nadal ma swoje zastosowanie, szczególnie w systemach projektowych. Kluczem jest to, jak komponent współdzielony zapewnia elastyczność. Zamiast pojedynczego komponentu sterowanego rosnącym списkiem wartości logicznych, komponent złożony oferuje zestaw małych, powiązanych elementów, które użytkownicy mogą sami skomponować.
Oto dialog fakturujący zbudowany w ten sposób. Komponent nadrzędny przechowuje stan otwarty lokalnie:
export function BillingSettings() {
const [isOpen, setIsOpen] = useState(false);
Korzeń <Modal> otrzymuje tylko to, co rzeczywiście posiada – stan otwarty oraz funkcję zwrotną przy zmianie, a warstwa nakładkowa stanowi odrębny element:
return (
<Modal open={isOpen} onOpenChange={setIsOpen}>
<Modal.Overlay />
Obszar zawartości zawiera nagłówek, a nagłówek zawiera tytuł:
<Modal.Content>
<Modal.Header>
<Modal.Title>Update Billing Plan</Modal.Title>
Odznaka jest po prostu kolejnym elementem dzieckim nagłówka, a jej wariant jest określany jako właściwość samej odznaki:
<Modal.Badge variant="warning">
Action Required
</Modal.Badge>
</Modal.Header>
Ciało zawiera dowolną treść, zaczynającą się od komunikatu:
<Modal.Body>
<p>
Please update your payment method to avoid account suspension.
</p>
a następnie kontynuowaną formularzem specyficznym dla danej funkcji, o którym sam moduł nie wie nic:
<CreditCardForm />
</Modal.Body>
Stopka kontroluje własne ustawienie wyrównania i zawiera zwykłe przyciski, z których pierwszy zamknął okno dialogowe:
<Modal.Footer align="right">
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
Główna akcja zamyka stopkę oraz całą strukturę drzewiastą:
<Button variant="primary">
Save Changes
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}
Różnica strukturalna ma znaczenie. Gdy modala wymaga odznaki, nie istnieje właściwość showBadge do dodania; renderuje się wtedy <Modal.Badge />. Gdy ekran potrzebuje spersonalizowanej treści, umieszcza się ją tam, gdzie powinna być, zamiast wymyślać kolejną flagę. Korzyści są oczywiste:
- Brak nadmiaru właściwości. Modala bez odznaki lub stopki po prostu nie renderuje
<Modal.Badge />ani<Modal.Footer />. - Ikona obok tytułu umieszcza się wewnątrz
<Modal.Header>; nie jest potrzebna żadna nowa właściwość. - Izolowane stylizowanie. Zmiana
<Modal.Badge />nie musi wpływać na główny kontener.
W rzeczywistości składowe komponenty zazwyczaj dzielą się stanem, takim jak open, za pośrednictwem kontekstu React, dzięki czemu <Modal.Footer> lub przycisk zamknięcia mogą do niego uzyskać dostęp bez konieczności przechodzenia przez kolejne warstwy właściwości. Kompozycja przekazuje kontrolę użytkownikowi, nie zamieniając komponentu w obiekt konfiguracyjny. Ten podejście nie jest bezkosztowe: system projektowy musi dokumentować, które części mogą być umieszczone gdzie, a użytkownicy muszą tworzyć nieco więcej kodu przy każdym użyciu. Aby zapoznać się z inną perspektywą na tę refaktoryzację, sprawdź nasz przewodnik na temat naprawy przeładowania właściwościami za pomocą kompozycji i slotów.
Zasada trzech elementów przy wyodrębnianiu wspólnych komponentów
Prosta heurystyka pomaga zdecydować, kiedy abstrakcja jest uzasadniona: poczekaj na trzecie rzeczywiste wystąpienie.
Pierwsze wystąpienie: zapisz je na miejscu
Umieść znaczniki bezpośrednio w widoku, który ich potrzebuje. Opanuj chęć abstrakcji i zachowaj style oraz strukturę obok danej funkcji.
Druge wystąpienie: skopiuj i dostosuj
Gdy inny ekran potrzebuje czegoś podobnego, stworzenie globalnego komponentu wydaje się bardzo kuszące. Zamiast tego skopiuj znaczniki i dostosuj je do nowego kontekstu. Masz teraz dwa konkretne przykłady, a z czasem możesz zaobserwować, gdzie faktycznie się różnią, zamiast spekulować na temat przyszłych wymagań.
Trzecie wystąpienie: wydziel z dowodami
Gdy trzeci, odrębny ekran wymaga tego samego wzorca wizualnego i zachowania, w końcu masz wystarczająco dużo dowodów, by zobaczyć, co jest naprawdę wspólne, a co się różni. Tak długie czekanie ujawnia prawdziwe niezmienniki – te elementy, które pozostają takie same za każdym razem – oraz rzeczywiste wariacje, które muszą zachować elastyczność. Te wariacje są dobrymi kandydatami na miejsca kompozycyjne zamiast właściwości boolowskich.
Celem nie jest unikanie komponentów wielokrotnego użycia. Chodzi o unikanie tworzenia abstrakcji opartych na założeniach.
Główne wnioski
- Uważaj na rozmnażanie się właściwości. Gdy komponent ciągle zyskuje właściwości konfiguracyjne do niepowiązanych przypadków użycia, zastanów się nad tą abstrakcją przed dodaniem kolejnej.
- Niech kompozycja zastąpi konfigurację. Dzieci, miejsca kompozycyjne i złożone komponenty zapewniają elastyczność bez konieczności tworzenia nowych właściwości boolowskich za każdym razem, gdy zmienia się projekt.
Dobra architektura frontendu nie mierzy się najmniejszą liczbą linii kodu, lecz tym, jak bezpiecznie można zmieniać kod bez powstawania efektów domina w całym aplikacji. Czasami najlepszym komponentem nie jest ten, który jest używany wszędzie, ale ten, który pozostaje nietknięty.
Literatura pokrewna
- Skracanie React Prop-Drilling i bogatych komponentów do rozsądnego rozmiaru — Poznaj siedem konkretnych wzorców refaktoryzacji, które umożliwiają rozbicie rozbudowanych komponentów React poprzez izolację stanu, pobierania danych, uprawnień oraz logiki ładowania, zamiast jedynie dzielenia plików.
- Naprawianie przeładowania propów w React za pomocą kompozycji i slotów — Dowiedz się, dlaczego propy w React o dużej liczbie ustawień powodują problemy z utrzymaniem kodu, oraz jak inwersja kontroli, kompozycja i sloty pozwalają tworzyć naprawdę wielokrotnie używalne komponenty.