Plik wygląda na martwy, a trzyma stronę: dynamiczny import, entry poza grafem i efekt uboczny
Analizator pokaże pliki, do których nie prowadzi żaden import. Trzy przypadki, w których taki plik trzyma stronę, i kolejność bezpiecznego usuwania.
Plik w repozytorium jest używany wtedy i tylko wtedy, gdy prowadzi do niego ścieżka importów od pliku wejściowego. Nie decyduje o tym data modyfikacji, obecność w katalogu src/ ani to, że edytor pokazuje trafienia przy wyszukiwaniu nazwy komponentu. Trafienia w wyszukiwaniu bywają trafieniami w pliku, którego nic nie importuje — i wtedy poprawka wchodzi do kodu, który nie trafia do przeglądarki.
Przy pracy z modelem takich plików robi się więcej, bo kolejna iteracja częściej dopisuje nowy moduł niż przepisuje stary. Problem ujawnia się w momencie przejęcia: ktoś czyta projekt po raz pierwszy i nie ma jak odróżnić pliku, który trzyma stronę, od pozostałości po trzeciej wersji galerii. Poniżej jest reguła, która to rozstrzyga, i trzy wyjątki, przy których plik tylko wygląda na martwy.
Osiągalność w grafie zamiast trafień w wyszukiwaniu
Analizatory martwego kodu nie przeglądają katalogów — budują graf. Dokumentacja knipa opisuje to wprost: narzędzie startuje od plików wejściowych i idzie za każdym importem do plików, do których on prowadzi, parsując po drodze każdy nowo osiągnięty plik. To, co zostaje poza tym zbiorem, jest raportowane jako nieużywane. Rozwiązywanie importów obejmuje aliasy ścieżek, odwołania z plików konfiguracyjnych i skryptów powłoki oraz kompilowanie plików .vue i .svelte.
Reguły schodzą kaskadowo i w tej samej dokumentacji opisane są trzy poziomy: plik jest nieużywany, gdy żaden wpis go nie osiąga; eksport jest nieużywany, gdy żaden plik go nie importuje; zależność jest nieużywana, gdy żaden plik jej nie używa, a unlisted wtedy, gdy plik ją importuje, a package.json jej nie wymienia. Kolejność czytania wyniku ma znaczenie, bo jeden nieosiągnięty plik generuje dziesiątki pozycji niżej — dokumentacja zaleca czytać od plików, potem eksporty, potem zależności.
Tam też jest zdanie, które decyduje o tym, jak traktować raport: wyniki są dokładne tylko tyle, ile kompletny jest graf. Lista nieużywanych plików nie jest listą do usunięcia. Jest listą pytań o to, czy graf obejmuje wszystkie wejścia do aplikacji.
Dlaczego po iteracjach z modelem takich plików jest więcej
Kierunek zmiany w publicznych danych o repozytoriach jest jeden. GitClear przeanalizował 211 mln zmienionych linii kodu z okresu od stycznia 2020 do grudnia 2024 i raportuje spadek udziału linii przeniesionych — czyli tych, które odpowiadają przeniesieniu istniejącego kodu w inne miejsce — z 25 proc. w 2021 roku do poniżej 10 proc. w 2024, przy wzroście udziału linii skopiowanych z 8,3 proc. w 2021 do 12,3 proc. w 2024. To korelacja z okresem upowszechnienia asystentów, nie wynik badania z grupą kontrolną, i tak należy ją czytać.
Mechanizm po stronie warsztatu jest prostszy niż dane. Prośba „zrób to inaczej” rzadko kończy się usunięciem poprzedniej wersji: w repozytorium zostaje Gallery.jsx, GalleryNew.jsx i GallerySlider.jsx, a importowany jest jeden z nich. Dochodzą do tego moduły pomocnicze dopisane pod funkcję, która zmieniła kształt, i paczki dodane do package.json przy podejściu, z którego się wycofano. Każdy taki plik kosztuje później trzy rzeczy: czas czytania przy przejęciu, ryzyko poprawki w złym miejscu i powierzchnię, którą trzeba aktualizować przy zmianie zależności.
Wyjątek pierwszy: dynamiczny import ze ścieżką składaną w locie
Analiza statyczna rozwiązuje ścieżki, które da się odczytać z kodu. Dokumentacja knipa wymienia wśród ograniczeń dynamiczny import, za którym narzędzie nie podąży statycznie, i ścieżkę, której nie potrafi rozwiązać. Wzorzec, który to wywołuje, jest w generowanych projektach częsty:
const mod = await import(`./blocks/${typ}.js`)— ścieżka powstaje z danych, więc żaden z plików wblocks/nie jest osiągalny z punktu widzenia analizatora;require(nazwaZKonfiguracji)w skrypcie budowania — ta sama sytuacja po stronie Node;- nazwa komponentu wstawiana z pola w CMS albo z pliku JSON z treścią strony.
Rozwiązanie jest tanie i poprawia jednocześnie czytelność i wynik narzędzia: jawny rejestr zamiast szablonu ścieżki. Mapa { hero: () => import('./blocks/hero.js'), cennik: () => import('./blocks/cennik.js') } ma te same właściwości wykonawcze, ale każdy import jest zapisany jawnie, więc graf go obejmuje, a nieobsługiwany typ bloku daje błąd przy budowaniu, nie u klienta. Jeżeli rejestr nie wchodzi w grę, pliki ładowane dynamicznie trafiają do konfiguracji analizatora jako wejścia — z komentarzem, dlaczego tam są.
Wyjątek drugi: wejście, którego nikt nie importuje
Druga grupa to pliki wywoływane przez platformę, a nie przez import z kodu aplikacji. Dokumentacja knipa nazywa to wprost: pominięcie jednego wejścia sprawia, że wszystko osiągalne tylko z niego wygląda na nieużywane. W projektach stron lista kandydatów jest przewidywalna:
- funkcje bezserwerowe i endpointy wywoływane przez routing frameworka albo katalog
functions/u hostingodawcy; service-worker.jsrejestrowany ciągiem znaków, nie importem;- pliki szablonów motywu WordPressa dobierane po nazwie przez hierarchię szablonów —
single-oferta.phpnie jest nigdzie importowany; - skrypty uruchamiane z
package.json, konfiguracje narzędzi, pliki migracji bazy; - moduły wczytywane przez testy end-to-end albo przez generator danych, jeżeli ich katalog jest poza wzorcem projektu.
Poprawną reakcją jest uzupełnienie wejść w konfiguracji, a nie dodanie pliku do listy ignorowanych. Różnica jest praktyczna: wejście wciąga do grafu całe poddrzewo plików, które z niego wynika, więc po jego dopisaniu z raportu znika zwykle kilkanaście pozycji naraz. Wpis na liście ignorowanych ukrywa jedną pozycję i nic nie mówi o tym, co z niej wynika — dokumentacja zaleca właśnie dopisanie wejścia, konfigurację wtyczki albo zadeklarowanie zależności zamiast szerokich wzorców ignorowania.
Wyjątek trzeci: moduł, który działa przez efekt uboczny
Trzecia grupa nie eksportuje niczego, co ktokolwiek importuje, a jej usunięcie psuje stronę. Dokumentacja webpacka definiuje efekt uboczny jako kod, który przy zaimportowaniu wykonuje specjalne działanie poza udostępnieniem eksportów, i podaje jako przykład polyfille, które zmieniają zakres globalny i zwykle nie mają eksportu. Tą samą kategorią są arkusze stylów importowane dla samego efektu, rejestracja web componentu przez customElements.define, ustawienie lokalizacji w bibliotece dat i każdy moduł, który po wczytaniu podpina nasłuch zdarzeń.
Stąd wynika pułapka przy czyszczeniu zależności. Ustawienie "sideEffects": false w package.json mówi bundlerowi, że może bezpiecznie usuwać nieużywane eksporty, a dokumentacja ostrzega, że każdy zaimportowany plik podlega tree shakingowi, więc plik CSS importowany wyłącznie dla efektu ubocznego musi trafić na listę, żeby nie został niezamierzenie usunięty w trybie produkcyjnym. Zapis jest wtedy tablicą wzorców:
"sideEffects": ["./src/polyfills.js", "*.css"]— wzorce bez ukośnika są traktowane jak**/*.css;- pojedynczy plik rejestrujący komponenty wymieniony jawnie, nie przez katalog;
- brak wpisu dla modułów czysto obliczeniowych, bo tam flaga daje najwięcej.
Dlaczego nieużywany eksport nadal bywa w bundlu
Raport z analizatora i zawartość bundla to dwa różne odczyty i mylenie ich prowadzi do fałszywego wniosku, że narzędzie się myli. Dokumentacja webpacka rozdziela dwie optymalizacje: sideEffects i usedExports to dwie różne rzeczy, przy czym pierwsza jest znacznie skuteczniejsza, bo pozwala pominąć całe moduły wraz z poddrzewem, a druga opiera się na Terserze wykrywającym efekty uboczne w instrukcjach i nie może pominąć poddrzewa zależności. Do tego dochodzą trzy warunki, bez których nic się nie usunie: składnia modułów ES, brak transpilacji import/export do CommonJS i tryb production.
Dokumentacja podaje też obserwację, która tłumaczy większość nieporozumień przy odbiorze: tree shaking w pełni aktywuje się zwykle tylko w trybie produkcyjnym, a testowanie wyłącznie w trybie deweloperskim ukrywa problemy do momentu wdrożenia. W trybie deweloperskim nieużywana funkcja zostaje w pliku z adnotacją unused harmony export — to komentarz dla minifikatora, nie dowód, że kod pojedzie na produkcję.
| Odczyt | Co pokazuje | Czego nie rozstrzyga |
|---|---|---|
| Analizator grafu (knip) | Pliki nieosiągalne z wejść, nieużywane eksporty, nieużywane i niezadeklarowane zależności | Czy brak osiągalności wynika z martwego kodu, czy z niekompletnej konfiguracji wejść |
| Analizator zależności (depcheck) | Paczki w package.json, których nie widzi w importach, i paczki używane bez deklaracji |
Użycia poza importami: skrypty, konfiguracje, wtyczki narzędzi |
| Mapa bundla z source map (source-map-explorer) | Co faktycznie znalazło się w wysyłanych plikach i ile waży | Czy moduł został pominięty zgodnie z zamiarem, czy przypadkiem |
| Raport bundlera (rollup-plugin-visualizer) | Rozmiary modułów z konkretnego przebiegu budowania | To samo co wyżej; wymaga Rollupa lub Vite |
| Pokrycie testami | Kod, przez który nie przeszedł żaden test | Różnicy między kodem nieprzetestowanym i nieosiągalnym |
Narzędzia do mapy bundla różnią się jedną cechą praktyczną: source-map-explorer czyta zbudowany plik wraz z jego source mapą, więc działa niezależnie od użytego bundlera. Ta sama dokumentacja przypomina, że source mapy generuje się do analizy i trzyma poza wdrożeniem, bo publikowanie ich na produkcji ma konsekwencje dla bezpieczeństwa i wydajności.
Zależności: trzy kategorie wyniku, trzy różne reakcje
Przy paczkach wynik dzieli się na nieużywane, niezadeklarowane i obecne tylko przechodnio. Trzecia kategoria jest najmniej oczywista, a dokumentacja knipa wymienia ją wśród ograniczeń: paczka może rozwiązywać się wyłącznie dlatego, że instaluje ją coś innego. Taki import działa do pierwszej aktualizacji, która zmieni drzewo zależności, i wtedy build przestaje przechodzić bez żadnej zmiany w kodzie projektu.
Narzędzia do samych zależności mają własne ograniczenia i ich autorzy to deklarują. README depchecka zapowiada przypadki, w których zależność jest używana, a zostaje zgłoszona jako nieużywana, i odwrotnie — traktuje je jako fałszywe alarmy. Typowe źródła takich alarmów to importy wyłącznie typów, paczki @types/*, subpath exports i zależności wymagane przez wtyczki narzędzi, a nie przez kod aplikacji. Zanim paczka wyleci z package.json, przechodzi się jedną ścieżkę: wyszukanie nazwy w całym repozytorium razem z plikami konfiguracyjnymi i skryptami, a potem czysta instalacja i pełne budowanie.
Kolejność, w której to się robi bez psucia builda
Poniższa kolejność jest procedurą, a nie listą życzeń: ma zadziałać u kogoś, kto projektu nie pisał, i dlatego warunek wykonania każdego kroku jest w niej zapisany jawnie — jak opisać proces, żeby ktoś inny faktycznie go wykonał, opisuje to od strony samego zapisu.
- Uzupełnij wejścia i konfigurację wtyczek, dopóki raport się zmienia. Dopiero stabilny wynik jest materiałem do decyzji.
- Rozdziel raport na cztery kubełki: pliki nieosiągalne, eksporty bez importu, paczki nieużywane, paczki niezadeklarowane. Niezadeklarowane załatw od razu — to jedyna kategoria, która psuje build sama z siebie.
- Zbuduj projekt w trybie produkcyjnym i zapisz mapę bundla przed zmianami. To jest punkt odniesienia, do którego porównasz stan po.
- Usuwaj w osobnych commitach, po jednej kategorii. Commit z usunięciem dwudziestu plików i jedną paczką nie da się wycofać częściowo.
- Po każdym commicie: pełne budowanie produkcyjne, przejście po stronie w przeglądarce z otwartą konsolą i sprawdzenie ścieżek ładowanych dynamicznie — formularz, galeria, wyszukiwarka, wybór wariantu produktu.
- Na koniec wpisz przebieg analizatora do CI z progiem zero dla kategorii niezadeklarowanych zależności. Reszta może zostać ostrzeżeniem, dopóki ktoś nie zdecyduje inaczej.
Jeżeli projekt po oddaniu zostaje u wykonawcy na utrzymaniu, ten przebieg jest jedną z pozycji, które dają się rozliczać cyklicznie razem z aktualizacjami zależności i przeglądem logów — to materiał na stałą umowę serwisową, a nie na pracę doliczaną do kolejnego zlecenia. Przejściu z rozliczeń projektowych na usługi rozliczane abonamentem poświęcony jest osobny ebook na GeekWork.
Co z tego zapisać przy przekazaniu projektu
Do wyboru są dwa stany końcowe i oba są uczciwe wobec następnego zespołu. Pierwszy: projekt przechodzi analizator z wynikiem zero, konfiguracja wejść jest w repozytorium, a przebieg stoi w CI — wtedy reguła „plik w repo jest używany” obowiązuje dosłownie i nowa osoba może jej zaufać. Drugi: wynik nie jest zerowy, więc lista pozycji trafia do dokumentacji przekazania z datą, liczbą plików i jednym zdaniem przy każdej grupie, dlaczego została.
Stan trzeci, w którym nikt nie uruchomił analizatora, też jest decyzją — tylko podjętą za następny zespół i bez jego wiedzy. Przebieg na świeżym klonie zajmuje kilka minut, a jego wynik odpowiada na pytanie, które pojawi się przy pierwszej poważniejszej zmianie: czy te pliki w ogóle należą do strony.