Rozbieranie zbyt dużych komponentów React bez regresji w środowisku produkcyjnym
Krok po kroku: jak bezpiecznie dzielić duże komponenty React – określanie zadań, wyodrębnianie czystej logiki, zarządzanie stanem oraz wdrażanie w małych krokach.
Źródła: React Components Are Getting Too Big: How to Refactor Without Breaking Production
Każda rozwinięta baza kodu React zawiera coś takiego: komponent, który funkcjonuje, przynosi przychody i budzi strach u wszystkich, którzy muszą się nim zajmować. Zaczynał się jako prosty ekran, potem dostał funkcje filtrowania, paginacji, wywołań API, uprawnień, walidacji, okien dialogowych, eksportów, analiz oraz coraz większą liczbę efektów. Ten przewodnik pokazuje, jak zmniejszyć rozmiar takiego komponentu poprzez obniżenie jego złożoności, a nie tylko rozmiaru pliku, oraz jak robić to krok po kroku, nie narażając przy tym zachowania w środowisku produkcyjnym.
Typowy przykład w cichy sposób pochłonął wszystkie aspekty danej funkcjonalności:
function ProductManagement() {
// API calls
// Redux selectors
// Local state
// Filters
// Pagination
// Sorting
// Selection
// Permissions
// Validation
// Business rules
// Export
// Modal handling
// Analytics
// Hundreds of lines of JSX
}
Gdy liczba linii przekracza tysiąc, wydaje się, że oczywistym problemem jest długość kodu. Tak nie jest. Długi komponent sam w sobie nie stanowi problemu; problemem jest komponent, który wymaga zmian z wielu niespowiązanych powodów. Praca opisana poniżej nie polega na podziale jednego pliku na dziesięć, lecz na zmniejszeniu kosztów i ryzyka kolejnych modyfikacji.
Liczba linii to niewłaściwa miara
Zasady takie jak „refaktoryzuj wszystko, co ma ponad 300 linii”, są łatwe do wdrożenia, ale słabo pomagają w wykrywaniu rzeczywistego ryzyka. Wyobraźmy sobie 500 linii głównie deklaratywnego kodu z niewielką ilością logiki. Teraz wyobraźmy sobie 200 linii zawierających wywołania API, przejścia stanów, sprawdzanie uprawnień, walidację, efekty, reguły biznesowe oraz nawarstwione warunki renderowania. Krótszy plik jest niemal na pewno trudniejszy do bezpiecznej modyfikacji.
Ważna jest obciążenie poznawcze. Przydatne pytanie: ile kontekstu potrzebuje kolega z zespołu, aby móc z pewnością modyfikować ten komponent?
Symptomy, które wskazują, że złożoność stała się przeszkodą
Zwracaj uwagę na te sygnały:
- wielokrotne, niespowiązane wywołania
useEffect - długa lista niezależnych zmiennych stanu
- żądania API wysyłane bezpośrednio z komponentów interfejsu
- reguły biznesowe ukryte w obsługach kliknięć
- bardzo głęboko zagnieżdżone warunkowe renderowanie
- logika uprawnień mieszana z prezentacją
- taka sama transformacja danych powtarzana w kilku miejscach
- różne elementy odpowiedzialne za odczyt i zapis tego samego stanu
- długie łańcuchy propów przekazywanych przez komponenty pośrednie
- duplikowane żądania sieciowe
- testy, które muszą załadować całą stronę, aby sprawdzić jedną małą regułę
Najbardziej charakterystycznym objawem jest problem społeczny, a nie techniczny: ludzie unikają tego pliku. Gdy powiedzenie „Nie dotykaj tego, zawsze coś się psuje” staje się częścią kultury zespołu, komponent przestaje być kwestią stylu i staje się ryzykiem inżynieryjnym.
Zmapuj odpowiedzialności przed edycją kodu
Pierwszym krokiem nie jest wyodrębnianie JSX w edytorze, lecz zapisanie tego, co faktycznie robi komponent. W przypadku ekranu zarządzania produktem lista może wyglądać tak:
ProductManagement
│
├── Fetch products
├── Transform API response
├── Filter products
├── Sort products
├── Manage selection
├── Validate permissions
├── Export data
├── Open/close dialogs
├── Handle errors
└── Render UI
Ta lista już pokazuje, że komponent pełni kilka ról jednocześnie. Podzielone według rodzaju, odpowiedzialności dzielą się na dane, zachowanie i prezentację:
ProductManagement
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
Data Behavior Presentation
│ │ │
▼ ▼ ▼
API Filters/Rules Table
State Selection Dialog
Validation Actions
Właśnie od tego podziału zaczyna się dobra refaktoryzacja. Najpierw szukasz granic, a dopiero potem plików.
Cel: warstwy z jasno określoną odpowiedzialnością
Rozbicie jednego dużego pliku na pięć mniejszych zmienia jego strukturę na dysku, ale nie projekt:
BigComponent.tsx
↓
5 smaller .tsx files
Prawdziwym celem jest struktura warstwowa, w której interfejs użytkownika, zachowanie, logika domeny i dostęp do danych mają po swoim miejscu:
ProductPage
│
├─────────────── UI
│
├─────────────── Hooks / Behavior
│
├─────────────── Domain Logic
│
└─────────────── Data Access
W praktyce foldery zorientowane na funkcjonalności są wygodnym sposobem na to wyrażenie:
features/products/
│
├── components/
│ ├── ProductFilters.tsx
│ ├── ProductTable.tsx
│ ├── ProductActions.tsx
│ └── ProductDialog.tsx
│
├── hooks/
│ ├── useProducts.ts
│ ├── useProductFilters.ts
│ └── useProductSelection.ts
│
├── services/
│ └── productService.ts
│
├── utils/
│ ├── productMapper.ts
│ └── productValidator.ts
│
└── types.ts
Nazwy folderów mają mniejsze znaczenie niż to, co zawierają poszczególne moduły:
ProductTablejest odpowiedzialny za wyświetlanie tabeli.useProductFiltersprzechowuje logikę filtrów.useProductsudostępnia dane produktów Reactowi.productServicekomunikuje się z serwerem.productValidatorzawiera czyste reguły walidacji.
Wymagana struktura to przepływ jednokierunkowy, w którym każda warstwa zależy tylko od tej poniżej niej:
UI
↓
Behavior
↓
Data Access
↓
Backend
a nie sieć, w której każda kwestia wpływa na wszystkie inne:
UI
↕
State
↕
API
↕
Business Logic
↕
UI
Aby lepiej przyjrzeć się aspektowi „prop-drilling” tego problemu, zobacz jak ograniczyć problem prop-drilling w React oraz nadmiarowe komponenty.
Krok pierwszy: przenieś czystą logikę poza React
Funkcje czyste stanowią najmniej ryzykowny sposób wyodrębnienia logiki, dlatego są naturalnym punktem startowym. Takie pomocniki do zarządzania stanem zazwyczaj są definiowane wewnątrz komponentu:
const getProductStatus = (product: Product) => {
if (!product.active) {
return "Inactive";
}
if (product.stock <= 0) {
return "Out of Stock";
}
return "Available";
};
Nic w nim nie zależy od React: żadnego useState, żadnego useEffect, żadnego Redux, żadnego kontekstu. Nie ma powodu, by komponent miał to przechowywać, więc przenieś to do osobnego modułu:
utils/productStatus.ts
Gdy znajdzie się tam, zasadę tę można przetestować bezpośrednio, bez renderowania niczego:
describe("getProductStatus", () => {
it("returns inactive for disabled products", () => {
expect(
getProductStatus({
active: false,
stock: 10
})
).toBe("Inactive");
});
});
To znacząca korzyść przy bardzo małym ryzyku: reguła biznesowa jest teraz oddzielona od procesu renderowania, a nieudana próba pokazuje bezpośrednio problem w regule, a nie na stronie. Zasada jest prosta: jeśli jakaś logika nie wymaga Reacta, React nie powinien być za nią odpowiedzialny.
Krok drugi: wyodrębnienie zachowań Reacta do hooków
Następnie przychodzą elementy, które faktycznie należą do Reacta: stan i efekty, które współpracują ze sobą. Ładowanie produktów to typowy przykład składający się z trzech zmiennych stanu i jednego efektu:
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchProducts()
.then(setProducts)
.catch(setError)
.finally(() => setLoading(false));
}, []);
Przeniesione dosłownie do niestandardowego hooka, staje się to pojedynczą jednostką z małą wartością zwracaną:
function useProducts() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchProducts()
.then(setProducts)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return {
products,
loading,
error
};
}
Ekran następnie wykorzystuje to w jednej linii:
function ProductManagement() {
const {
products,
loading,
error
} = useProducts();
// composition
}
Składnik zawiera teraz mniej szczegółów implementacyjnych i bardziej przypomina opis tego, z czego składa się ekran. Przeniesienie kodu dosłownie jest celowe – wyodrębnienie nie powinno zmieniać zachowania. Ulepszenia takie jak ustawienie początkowo loading na true (aby pierwsze wyświetlenie nie wyglądało przez chwilę na „załadowane”), ignorowanie odpowiedzi po odmontowaniu lub przyjęcie biblioteki do zarządzania stanem serwera powinny znaleźć się w oddzielnym, późniejszym zmianie.
Nie wszystko powinno stać się hookiem
Tutaj refaktoryzacja przechodzi w nadmierną inżynierię. Niektóre bazy kodu kończą się hookiem dla każdej małej funkcji:
useProductName()
useProductStatus()
useProductLabel()
useProductColor()
useProductFormatter()
To nie jest ulepszenie architektoniczne. Custom hook zasługuje na istnienie, gdy zapakowuje zachowanie Reacta: stan, efekty, kontekst, inne hooki, subskrypcje lub kwestie związane z cyklem życia. Jeśli potrzebny jest tylko formatownik,
formatProductName(product)
wtedy zwykłe wywołanie funkcji jest dokładnie tym, co potrzebne:
formatProductName(product)
Jeśli otoczymy je hookiem,
useProductName(product)
dodaje się zasady dotyczące hooków, co uniemożliwia ich wywoływanie warunkowe lub wewnątrz pętli, a nie przynosi żadnych korzyści. Abstrakcje powinny zmniejszyć obciążenie umysłowe kodu, a nie je zwiększać.
Decyduj, kto jest właścicielem każdego elementu stanu
Miejsce przechowywania stanu to jedna z najważniejszych decyzji podczas refaktoryzacji. Przydatna klasyfikacja dzieli dane serwera, globalny stan interfejsu użytkownika oraz lokalny stan interfejsu użytkownika:
Application State
│
├── Server State
│ ├── Products
│ ├── Orders
│ └── Customers
│
├── Global UI State
│ ├── Authentication
│ ├── Theme
│ └── Application Preferences
│
└── Local UI State
├── Modal
├── Selected Tab
└── Temporary Form State
Żaden z tych skrajnych rozwiązań nie jest dobry: umieszczanie wszystkiego w Redux tworzy przeciążony globalny magazyn danych, natomiast przechowywanie wszystkiego lokalnie prowadzi do duplikacji i łańcuchów propów. Dla każdego elementu stanu należy odpowiedzieć na:
- Które komponenty go odczytują?
- Jak długo musi istnieć?
- Czy jest własnością serwera, czy klienta?
- Czy musi przetrwać zmianę strony?
Niezawodna zasada heurystyczna: przechowuj stan jak najbliżej jego użytkowników, ale nie bliżej, niż na to pozwalają ich wspólne potrzeby.
Unikaj tworzenia „rodzica-boga”
Rozdzielanie komponentu może doprowadzić do nowego problemu. Dzieci są małe, ale teraz rodzic przechowuje cały stan i przekazuje go dalej:
<ProductPage
products={products}
filters={filters}
setFilters={setFilters}
selected={selected}
setSelected={setSelected}
permissions={permissions}
loading={loading}
error={error}
onExport={handleExport}
onDelete={handleDelete}
onEdit={handleEdit}
/>
Plik jest krótszy, a mimo to połączenie między komponentami pozostaje takie samo; po prostu przesunęło się o jeden poziom wyżej. Zanim zaczniesz korzystać z kontekstu lub globalnego magazynu danych, aby ukryć te właściwości, zastanów się, czy stan jest rzeczywiście współdzielony, czy też granice komponentów są wyznaczone w niewłaściwym miejscu. Kontekst jest odpowiedni, gdy kilka potomków rzeczywiście potrzebuje tej samej wartości, natomiast globalny magazyn danych – gdy stan należy do całej aplikacji. Żadne z tych rozwiązań nie powinno maskować granic, które nie odpowiadają strukturze domeny.
Zastępuj komponent stopniowo za pomocą wzorca Strangler Fig
Ponowne napisanie całego komponentu od razu to najbardziej ryzykowna opcja. Zastępowanie go krok po kroku jest znacznie bezpieczniejsze. Na początku stary komponent robi wszystko:
BigComponent
└── Everything
Następnie jedna z funkcji, np. filtrowanie, przenosi się do osobnego komponentu, podczas gdy reszta pozostaje nietknięta:
BigComponent
├── ProductFilters
└── Everything else
W kolejnej iteracji przenosi się tabelę:
BigComponent
├── ProductFilters
├── ProductTable
└── Everything else
Ostatecznie strona staje się czystą kompozycją, a starsza implementacja znika:
ProductPage
├── ProductFilters
├── ProductTable
├── ProductActions
└── ProductDialog
To jest wzorzec Strangler Fig, zwykle opisywany w kontekście migracji systemów backendowych, zastosowany do frontendu: nowe elementy rozwijają się wokół starej implementacji, każdy przejmując jedną funkcję, podczas gdy reszta aplikacji nadal działa. Zaletą jest to, że w produkcji nie trzeba nigdy przechodzić od
100% Legacy
bezpośrednio do
100% New
w jednej wersji. Każdy stan pośredni może zostać wydany i jest odwracalny.
Zachowaj stabilność publicznego kontraktu
W większych organizacjach taki komponent jest często renderowany na kilku ekranach z określonym zestawem właściwości:
<ProductManagement
clientId={clientId}
onSelect={handleSelect}
/>
Zachowaj tę interfejs bez zmian przez cały proces refaktoryzacji. Za jej plecami możesz swobodnie dokonać całkowitej reorganizacji:
Existing Public API
│
▼
ProductManagement
│
┌────┼────┐
▼ ▼ ▼
Hooks UI Services
Klienci nigdy nie muszą wiedzieć, że elementy wewnętrzne przeniesiono do hooków, komponentów UI i usług, co ogranicza wpływ każdego kroku. Ta dyscyplina jest szczególnie ważna dla wspólnych bibliotek komponentów, systemów projektowych, mikrofrontendów, aplikacji korporacyjnych oraz wszystkiego, co jest używane przez więcej niż jeden zespół. Jeśli API rzeczywiście musi ulec zmianie, należy to zrobić jako odrębną, wyraźną migrację.
Rozwiąż problemy spowodowane efektami ukrywającymi wiele odpowiedzialności
Złożone komponenty często zawierają efekty, które wykonywają kilka niepowiązanych ze sobą działań:
useEffect(() => {
fetchData();
updateAnalytics();
validatePermissions();
calculateSomething();
}, [dependency]);
Problemem nie jest liczba instrukcji, lecz to, że prawdopodobnie reprezentują one cztery odrębne obowiązki, z których każdy ma własne rzeczywiste zależności. Podziel je tak, aby każdy efekt określał to, na co faktycznie reaguje:
useEffect(() => {
fetchData();
}, [dependency]);
useEffect(() => {
updateAnalytics();
}, [analyticsDependency]);
Gdy już tam jesteś, poszukaj efektów, które jedynie obliczają wartości. Przechowywanie filtrowanej listy w stanie i synchronizowanie jej z efektem powoduje dodatkową renderizację oraz może prowadzić do użycia przestarzałych danych:
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
setFilteredProducts(
products.filter(product => product.active)
);
}, [products]);
Obliczanie jej podczas renderowania jest prostsze i zawsze spójne:
const filteredProducts = products.filter(
product => product.active
);
Kataloguj lub synchronizuj dane tylko wtedy, gdy istnieje ku temu rzeczywisty powód, np. kosztowne obliczenia, które można zmierzyć (w takim przypadku narzędziem jest useMemo, a nie efekt).
Rozdziel stan serwera od stanu interfejsu
W większych ekranach szybko widać korzyści z rozróżnienia tych dwóch kategorii:
ProductPage
│
┌───────────┴───────────┐
│ │
Server State UI State
│ │
┌─────┴─────┐ ┌──────┴──────┐
│ │ │ │
products loading filter modal
error status selected tab
Lista produktów oraz informacja o otwartym oknie dialogowym to zasadniczo różne rodzaje danych. Serwer zarządza produktami, natomiast przeglądarka – oknem dialogowym. Dane serwerowe wymagają cache’owania, ponownego pobierania, ładowania oraz obsługi błędów, podczas gdy stan interfejsu jest tymczasowy i lokalny. Mieszanie ich w jednej instancji useState utrudnia zrozumienie komponentów. Sposób radzenia sobie z tym za pomocą dedykowanych narzędzi omówiono w React Query i Redux: ponowne przemyślenie stanu serwera w dużych aplikacjach.
Proces refaktoryzacji, a nie przepisywanie
Refaktoryzacja w środowisku produkcyjnym to cykl:
Identify
↓
Understand
↓
Protect
↓
Extract
↓
Test
↓
Validate
↓
Deploy
↓
Measure
↓
Repeat
To nie jest to:
Delete component
↓
Rewrite everything
↓
Hope production works
Właśnie ten kontrast odróżnia refaktoryzację istniejącego systemu od rozpoczynania nowego projektu od zera.
Jak wygląda ostateczny przepływ danych
Gdy obowiązki są rozdzielone, interakcja użytkownika przechodzi przez dobrze zdefiniowane warstwy i wraca:
User
│
▼
ProductPage
│
▼
useProductFilters
│
▼
useProducts
│
▼
ProductService
│
▼
API
│
▼
Backend
│
▼
ProductService
│
▼
useProducts
│
▼
ProductTable
│
▼
Updated UI
W skrócie, zasada polega na prostej linii zależności:
Presentation
↓
Behavior
↓
Data Access
↓
Backend
Tabela nie powinna wiedzieć, jak działa autoryzacja, usługa API nie powinna wiedzieć, jak renderowana jest tabela, a reguła biznesowa nie powinna przejmować się tym, czy interfejs używa modalu, czy szuflady. To właśnie jest separacja zadań o konkretnym celu: każda zmiana pozostaje w obrębie jednej warstwy.
Uwaga na powtarzające się żądania API
Taka regresja jest na tyle powszechna, że zasługuje na osobne ostrzeżenie. Przed refaktoryzacją jeden komponent pobierał dane:
ProductPage
│
└── fetchProducts()
Po ekstrakcji zarówno strona, jak i jej komponenty potomne wywołują ten sam mechanizm pobierania danych:
ProductPage
│
├── useProducts()
│
└── ProductTable
│
└── useProducts()
Kod wygląda schludniej, ale zwykły hook typu fetch-in-effect jest teraz wykonywany dwa razy, co podwaja liczbę żądań i może powodować pokazanie dwóm komponentom różnych stanów. Zawsze, gdy przenosisz logikę pobierania danych, zastanów się, kto jest odpowiedzialny za ten stan serwera. Powinien istnieć jeden źródło prawdy. Jeśli kilka komponentów potrzebuje tych samych danych zdalnych, należy je pobrać raz i przekazać dalej lub użyć warstwy cache’ującej stan serwera, która eliminuje duplikaty żądań, zamiast pozwalać każdemu komponentowi pobierać dane niezależnie.
Testy to sieć bezpieczeństwa
Zanim zmienisz kluczowy komponent, wymień zachowania, od których zależy funkcjonowanie aplikacji:
Product Management
│
├── Filter products
├── Select products
├── Edit product
├── Delete product
├── Export products
└── Permission handling
Następnie zabezpiecz je na odpowiednim poziomie:
- Testy jednostkowe nadają się do weryfikacji, transformacji, reguł biznesowych, formatowania i mapowania.
- Testy komponentów nadają się do sprawdzania renderowania, interakcji oraz stanów ładowania i błędów.
Refaktoryzacja nie wymaga 100% pokrycia. Wymaga zaufania do zachowań, które są istotne. Najcenniejsze są testy napisane na podstawie obecnego zachowania przed rozpoczęciem prac, ponieważ potwierdzają, że nic się nie zmieniło.
Małe prośby o pull request są częścią projektu
Koszt przeglądania oraz koszt cofnięcia zmian rosną wraz z rozmiarem zmiany. Porównaj taką serię:
Extract ProductFilters
za którym następuje
Extract ProductTable
a potem
Extract product-fetching behavior
z pojedynczą zmianą zatytułowaną
Refactor ProductManagement
która dotyka
4,000 lines changed
Małe zmiany są łatwiejsze do przejrzenia i cofnięcia, zmniejszają ryzyko regresji, pozostawiają czytelną historię w Git, ułatwiają debugowanie i zmniejszają konflikty połączeniowe. Dla kierownika technicznego jest to raczej sposób na umożliwienie zespołowi równoległej pracy niż preferencja dotycząca jakości kodu.
Trzymaj refaktoryzację oddzielnie od innych zmian
Unikaj łączenia refaktoryzacji z wszystkimi innymi elementami z listy życzeń:
React upgrade
+
State migration
+
API changes
+
UI redesign
+
CSS migration
+
Component refactoring
Jeśli po takim wydaniu wystąpi awaria w środowisku produkcyjnym, znalezienie przyczyny jest trudne, ponieważ winna może być każda z sześciu zmian. Lepiej je uporządkować:
Refactor
↓
Stabilize
↓
Measure
↓
Optimize
Jedna istotna zmiana na raz zapewnia, że każda regresja może być łatwo zidentyfikowana.
Flagi funkcjonalne dla zastąpień o wysokim ryzyku
Dla krytycznych procesów pracy flagi funkcjonalne dodają kolejną warstwę bezpieczeństwa, umożliwiając współistnienie obu implementacji:
return useNewProductExperience
? <NewProductManagement />
: <LegacyProductManagement />;
Następnie można stopniowo zwiększać stopień ekspozycji, obserwując przy tym wskaźniki błędów oraz metryki biznesowe:
1%
↓
5%
↓
25%
↓
50%
↓
100%
Jest to szczególnie przydatne w przypadku ekranów o dużym ruchu, procesów kluczowych dla przychodów, aplikacji korporacyjnych, dużych mikrofrontendów oraz ryzykownych zmian architekturalnych. Traktuj flagę jako tymczasową – gdy nowa ścieżka się sprawdzi, usuń starszą implementację oraz flagę, w przeciwnym razie będziesz musiał utrzymywać obie.
Przed i po
Punkt wyjścia, w uproszczeniu, łączy dane, filtrowanie, selekcję, eksport oraz okna dialogowe w jednej funkcji:
function ProductManagement() {
const [products, setProducts] = useState([]);
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
const [showDialog, setShowDialog] = useState(false);
useEffect(() => {
// Fetch products
}, []);
const filteredProducts = products.filter(...);
const handleSelect = (...) => {
// Selection logic
};
const handleExport = (...) => {
// Export logic
};
return (
<>
{/* hundreds of lines of JSX */}
</>
);
}
Żadna z tych praktyk nie jest koniecznie błędna. Problem polega na tym, że jeden komponent odpowiada za zbyt wiele funkcji. Po refaktoryzacji strona łączy hooki z komponentami prezentacyjnymi:
function ProductManagement() {
const {
products,
loading
} = useProducts();
const {
filter,
setFilter,
filteredProducts
} = useProductFilters(products);
const {
selected,
handleSelect
} = useProductSelection();
return (
<ProductPage>
<ProductFilters
value={filter}
onChange={setFilter}
/>
<ProductTable
products={filteredProducts}
selected={selected}
onSelect={handleSelect}
loading={loading}
/>
<ProductActions
products={selected}
/>
</ProductPage>
);
}
Strona opisuje teraz zasadę kompozycji, która stanowi najzdrowszą formę funkcjonowania komponentu na poziomie strony. Każdy hook oraz potomek może być czytany, testowany i modyfikowany osobno.
Mniejszy nie oznacza szybszego
Kuszące jest założenie, że przekształcenie
1 large component
w
10 smaller components
poprawia wydajność. Samo w sobie tego nie robi, a może nawet pogorszyć sytuację poprzez dodatkowe renderowania, powtarzające się obliczenia, chaotyczne aktualizacje kontekstu, duplikowane żądania, niepotrzebne użycie useMemo i useCallback, oraz właściwości, które zmieniają swoją tożsamość przy każdym renderowaniu. Architektura i wydajność są powiązane, ale to odrębne kwestie. Należy najpierw zmierzyć, a dopiero potem optymalizować, korzystając z profiliowania oraz telemetryi produkcyjnej, aby upewnić się, czy coś rzeczywiście się poprawiło.
Oceniaj sukces pod kątem kosztu kolejnej zmiany
Zmniejszona liczba linii to przyjemny efekt uboczny:
1,200 lines → 300 lines
jednak to nie jest cel. Porównaj komponent przed i po modyfikacjami pod kątem takich wymiarów jak:
- złożoność kognitywna i stopień powiązań, które powinny spaść
- testowalność, która powinna wzrosnąć
- powtórzone wywołania API oraz liczba błędów w produkcji
- Czas renderowania w milisekundach
- wskaźnik regresji po wydaniach
- nakład pracy wymagany przy typowej modyfikacji
Kierunki, do których dążysz:
Complexity ↓
Coupling ↓
Regression risk ↓
Testability ↑
Maintainability ↑
Developer speed ↑
Najcenniejszym rezultatem jest niższy koszt wprowadzania zmian, a nie mniej linii kodu.
Kiedy nie refaktoryzować
Nie każdy duży komponent zasługuje na taki wysiłek. Do słabych powodów należą: plik jest długi, ktoś woli małe pliki, nowa architektura jest modna, chcesz wprowadzić inną bibliotekę lub kod nie odpowiada osobistym upodobaniom. Jeśli komponent jest stabilny, dobrze przetestowany, rzadko modyfikowany, ma niski poziom ryzyka i jest izolowany, duża refaktoryzacja może przynieść więcej ryzyka, niż go usunie. Decyzja sprowadza się do kompromisu:
Expected future benefit
│
▼
Is it worth the
refactoring risk?
│
Yes / No
To jest ocena inżynieryjna dotycząca przewidywanych przyszłych zmian, a nie preferencja dotycząca formatowania.
Rozumienie istniejącego zachowania to trudna część
Pisanie nowej implementacji rzadko stanowi trudny etap. Prawdziwym wyzwaniem jest wystarczające zrozumienie obecnego zachowania, aby zachować to, na czym polegają użytkownicy. Komponenty produkcyjne zawierają wiedzę, której nie da się poznać na podstawie samego ich przeczytania: niewypisane założenia dotyczące produktu, zasady dostępu, połączenia z starszymi systemami, nietypowe odpowiedzi backendu, które są tolerowane, śledzenie zdarzeń, od których zależą inni użytkownicy, rozwiązania tymczasowe dla określonych przeglądarek, funkcje wywoływane przez osoby, których nikt już pamięta, poprawki po wcześniejszych incydentach oraz oczekiwania innych zespołów. To ukryte wiedza sprawia, że całkowite przepisywanie jest o wiele bardziej ryzykowne niż stopniowa dekompozycja. Kolejność priorytetów, o której należy pamiętać: zachowanie obecnego funkcjonowania, następnie ulepszenie struktury, a dopiero potem optymalizacja.
Lista kontrolna refaktoryzacji w środowisku produkcyjnym
Zanim zaczniesz:
- Znajdź wszystkich użytkowników tego komponentu.
- Zdokumentuj jego obecne zachowanie i określ jego funkcje.
- Zachowaj stabilność publicznego kontraktu.
- Najpierw wyodrębnij czyste funkcje, a następnie zachowanie Reacta.
- Zachowaj stan w pobliżu jego użytkowników i unikaj abstrakcji, które same w sobie nie przynoszą korzyści.
- Pomijaj zmiany nieskorelowane, uważaj na duplikowane żądania i utrzymuj PR-y w małych rozmiarach.
- Weryfikacja typów, testy jednostkowe, komponentowe i end-to-end oraz proces budowania wszystko przejdzie pomyślnie.
- Brak duplikowanych żądań oraz brak nieoczekiwanych regresji w renderowaniu.
- Monitoruj błędy, ruch API i wydajność oraz porównuj je z wartościami bazowymi.
- Pilnuj kluczowych procesów pracy użytkowników.
- Usuń przestarzały kod, gdy nowa struktura zdobędzie zaufanie.
Podsumowanie
Wielkie komponenty React nie są wrogiem; wrogiem jest niekontrolowana złożoność. Strategia przegrywająca wygląda tak:
Big Component
↓
Rewrite Everything
Strategia zwycięska to cierpliwy proces, który prowadzi do prostszej architektury:
Big Component
↓
Understand
↓
Protect
↓
Extract
↓
Test
↓
Measure
↓
Repeat
↓
Simpler Architecture
Celem nie jest większa liczba plików, lecz bezpieczniejsze wprowadzanie zmian: wersje, które nikogo nie zaskoczą, oraz baza kodu, którą następny programista będzie mógł modyfikować za sześć miesięcy bez obaw.