W pewnym SaaS do generowania obrazów skasowałem .prettierrc, eslint.config.mjs i siedem zależności z package.json w jednym commicie. Lockfile stracił 545 linii. Następnego dnia zrobiłem to samo w innym projekcie, i w kolejnym. Za piątym razem już niczego nie oceniałem: otwierałem repo, wyrzucałem ESLint i Prettier, i pisałem biome.json.
To już nie jest preferencja. To kryterium, i warto umieć go bronić.
Co mnie do tego popchnęło za pierwszym razem
Nie szybkość. Drzewo zależności. Konfiguracja, którą wyrzuciłem, wyglądała tak:
const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];
FlatCompat, żeby flat config rozumiał stare konfiguracje. eslint-config-prettier, którego jedynym zadaniem jest wyłączanie reguł ESLint kolidujących z Prettierem. eslint-plugin-tailwindcss i prettier-plugin-tailwindcss, robiące podobne rzeczy z dwóch różnych stron. Sześć paczek, których powodem istnienia jest to, żeby pozostałe narzędzia sobie nawzajem nie wchodziły w drogę.
Biome robi formatowanie i lint w tym samym binarium. Ten problem znika, bo przestają istnieć dwa narzędzia do skoordynowania.
Co się psuje, a nikt nie uprzedza
Migracja to nie jest skasowanie plików i jazda dalej. To przelot formatowania zaraz potem: 78 plików ruszonych w jednym commicie, w którym niczego nie przejrzysz, bo to same cudzysłowy i wcięcia. Robisz to i akceptujesz.
Prawdziwy problem zaczyna się, kiedy włączysz linter na serio. W bocie do przypomnień na Telegramie pierwsze przejście z rekomendowanymi regułami dało 60 błędów w webie. Prawie żaden nie był szumem:
- Niepowiązane etykiety. Pola logowania bez
htmlFor/id. ESLint z konfiguracją Nexta nigdy mi tego nie powiedział. - Dekoracyjne SVG. Reguła nie rozpoznaje skróconej formy
aria-hidden; trzeba wpisaćaria-hidden="true"wprost. - Kolizja nazw. Zaimportowany typ
TaskListkolidujący z komponentem o tej samej nazwie. Kompilowało się bez zarzutu i było źle. - Klucze po indeksie. W komponencie z rotującym tekstem kluczem każdego znaku był indeks z
map. Rozwiązaniem nie było wyciszenie reguły: było policzenie z góry bezwzględnego offsetu w memo, żeby mieć stabilny klucz.
I to jest ciekawy punkt. Z tych 60 większość to były prawdziwe błędy siedzące tam od miesięcy. Reszta poszła przez udokumentowane wyciszenia: efekt uruchamiany tylko raz, pętle po szkieletach o stałym rozmiarze, !important nadpisujący react-remove-scroll-bar, <img> z awatarami z OAuth.
biome-ignore bez komentarza z uzasadnieniem to dług. Z komentarzem to decyzja.
Szybkość liczy się przez to, gdzie pozwala ci wstawić lint
To rzecz najczęściej powtarzana w porównaniach i najgorzej tłumaczona. Liczba nie ma znaczenia. Znaczenie ma to, gdzie możesz wpiąć komendę, kiedy trwa krótko.
Przy ESLincie lint mieszkał w CI, bo wsadzenie go do lokalnego builda było skazaniem się na czekanie. Przy Biome skrypt wygląda tak:
"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."
Lint przed buildem. Jak jest czerwono, nie kompilujesz. Ta jedna linia to cała różnica: błąd przestaje się ujawniać piętnaście minut później na runnerze i ujawnia się, zanim build w ogóle ruszy. Z wolnym narzędziem ta linia jest nie do zniesienia i kończysz na jej usunięciu.
Co tracisz
Dwie rzeczy, i warto je nazwać.
Pierwsza to ekosystem pluginów. Jeśli zależysz od bardzo konkretnego pluginu ESLint, to go nie ma i prawdopodobnie nie będzie. Sprawdziłem to, wyrzucając eslint-plugin-tailwindcss: kolejność klas załatwia formater Tailwinda, nie linter, więc mi go nie brakowało. Ale to szczęśliwy przypadek, nie ogólna reguła.
Druga jest głupsza i bardziej uciążliwa: domyślna konfiguracja nie jest wszędzie taka sama. Jeden projekt wyszedł z "indentStyle": "tab", a drugi z "indentStyle": "space" i "indentWidth": 2. Tygodnie później musiałem zrobić commit, którego całą treścią była zamiana tabów na spacje w biome.json, żeby wszędzie było tak samo. Ustaw $schema z dokładną wersją i kopiuj ten sam biome.json między projektami. Wygoda tego narzędzia polega na tym, że prawie nie ma konfiguracji; wykorzystaj to i miej jedną.
Mój dzisiejszy stack jakości
Biome, TypeScript w trybie strict, testy i knip na martwy kod. Niewiele więcej.
Knip to element, którego używa najmniej ludzi i który najbardziej zaskakuje. Jedno przejście w bocie na Telegramie skasowało 19 martwych plików i 21 nieużywanych zależności: nadmiarowe podpakiety @radix-ui/* dublujące metapakiet, całe @react-three/fiber i three, date-fns oraz eslint, wciąż zainstalowany miesiące po wyrzuceniu. Wypada też cały klaster akcji i przypadków użycia, których już nikt nie wołał.
Ale knip zostaje poza CI, celowo. W dużym repo pierwsze przejście dało 107 wcześniej istniejących znalezisk: wpięcie go w bramkę daje czerwono od pierwszego dnia i kończysz na ignorowaniu czerwonego. Idzie jako narzędzie ręczne, co kilka miesięcy, z przycinką w osobnym commicie, zweryfikowaną przez tsc i testy przed mergem.
Konfiguracja musi być jawna co do entrypointów, bo inaczej skasuje ci pół aplikacji. W monorepo z Nextem trzeba mu ręcznie zadeklarować, które pliki są punktami wejścia:
"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]
Komponenty wygenerowane przez shadcn idą do ignore, bo zawsze jest ich więcej niż używasz i to normalne. sharp i tailwindcss idą do ignoreDependencies, bo nikt ich nie importuje — używa ich build.
Wolny linter to linter, który kończy w osobnym jobie, na który nikt nie patrzy. Szybki linter wchodzi do builda i staje się podłogą projektu.
