Odpal npm install w projekcie, a potem pnpm install na tym samym package.json – dostajesz dwa różne drzewa node_modules i dwa różne lockfile’y. Dodaj kolegę, który sięga po Yarn, i w historii repozytorium lądują trzy lockfile’y, każdy opisujący nieco inne drzewo zależności. Wszystkie trzy czytają ten sam package.json i pobierają z tego samego rejestru npm. Nie zgadzają się co do tego, co zapisać na dysku.

Jak npm, Yarn i pnpm układają node_modules

Struktura npm jest płaska. Każdy pakiet, którego potrzebują twoje zależności, niezależnie od tego, jak głęboko w drzewie, trafia do głównego node_modules, jeśli nie ma konfliktu wersji. To domyślne zachowanie od npm 3. Efekt uboczny: twój kod może zrobić require() albo import pakietu, którego nigdy nie wpisałeś do package.json, o ile cokolwiek innego w drzewie od niego zależy. Działa to, dopóki ten inny pakiet nie usunie tej zależności w minor release – wtedy twój build się psuje, mimo że w twoim kodzie nic się nie zmieniło. To jest phantom dependency, a struktura npm nic z tym nie robi.

Yarn Classic (v1) podnosi zależności tak samo, więc ma tę samą słabość. Yarn Berry (v2 i nowsze) domyślnie używa Plug’n’Play, który w ogóle nie ma node_modules. Zależności zostają spakowane w .yarn/cache, a wygenerowany plik .pnp.cjs mapuje każdy import prosto na odpowiednie archiwum. Zacommituj .yarn/cache razem z lockfile’em, a świeży klon działa bez etapu instalacji – Yarn nazywa to zero-install. Koszt to kompatybilność. Narzędzia, które czytają node_modules bezpośrednio, i natywne dodatki, które oczekują prawdziwych plików na dysku, mogą nie działać pod PnP. Ustawienie nodeLinker: node-modules w .yarnrc.yml przywraca konwencjonalną płaską strukturę, kosztem zero-install.

pnpm trzyma jeden magazyn na maszynę, indeksowany po treści. Każda wersja każdego pakietu leży na dysku tylko raz, identyfikowana hashem swojej treści, niezależnie od liczby projektów, które jej używają. Dla każdego projektu pnpm tworzy hardlinki potrzebnych plików w wirtualnym magazynie pod node_modules/.pnpm, a potem tworzy symlinki tylko dla twoich bezpośrednich zależności w głównym node_modules. Możesz rozwiązać node_modules/react, bo zadeklarowałeś go. Nie możesz rozwiązać pakietu, który istnieje tylko dwa poziomy niżej, w drzewie innej zależności, bo nic go nie łączy z górnym poziomem. Problem phantom dependencies rozwiązuje sama struktura, nie dyscyplina osoby piszącej kod.

Jak się różnią komendy npm, pnpm i Yarn

Na co dzień komendy odpowiadają sobie prawie jeden do jednego. Różnica, która ma znaczenie, to flaga dla CI: zainstaluj dokładnie to, co mówi lockfile, niczego nie rozwiązuj, niczego nie zapisuj.

AkcjanpmpnpmYarn Berry
Zainstaluj wszystkonpm installpnpm installyarn install
Dodaj zależnośćnpm install axiospnpm add axiosyarn add axios
Dodaj dev dependencynpm install -D vitestpnpm add -D vitestyarn add -D vitest
Uruchom skryptnpm run buildpnpm run buildyarn run build
Bezpieczna instalacja dla CInpm cipnpm install --frozen-lockfileyarn install --immutable

npm ci najpierw usuwa node_modules i instaluje wyłącznie z package-lock.json, kończąc się błędem, jeśli lockfile i package.json się nie zgadzają. --frozen-lockfile i --immutable pomijają usuwanie, ale wymuszają tę samą regułę: brak nowych rozwiązań, brak zapisu lockfile’a, niezerowy kod wyjścia, jeśli lockfile nie da się spełnić w obecnej formie. Używaj bezpiecznej dla CI formy w każdym pipeline, a normalnej instalacji na swojej maszynie. Nieaktualny lockfile powinien głośno wywalić build, nie zostać po cichu rozwiązanym na nowo.

Jeśli instalujesz zależności w obrazie Docker, Dobre Praktyki Dockerfile: Mniejsze i Szybsze Buildy opisuje, jak skopiować manifest i lockfile i uruchomić instalację przed COPY . ., żeby ta warstwa przebudowywała się tylko wtedy, gdy zmienia się lockfile.

Czemu repozytorium powinno commitować tylko jeden lockfile

npm zapisuje package-lock.json, pnpm zapisuje pnpm-lock.yaml, a obie generacje Yarn zapisują yarn.lock (w formatach, które różnią się między v1 i Berry). Każdy z nich rejestruje dokładną wersję każdej zależności przechodniej. Żaden nie czyta tego, co zapisał inny.

Awaria wygląda tak. Ktoś odpala yarn add lodash w repozytorium, które do tej pory commitowało tylko package-lock.json. Teraz istnieje też yarn.lock, oba pliki nie zgadzają się co do tego, co zostało rozwiązane, a wygrywa to narzędzie, które przypadkiem odpali CI. Następna lokalna instalacja innym narzędziem znowu rozwiązuje coś innego.

Wybierz jeden menedżer pakietów na repozytorium, commituj tylko jego lockfile i zignoruj pozostałe, zanim ktoś wygeneruje je przez przypadek:

1
2
3
# jeśli to repozytorium używa npm
yarn.lock
pnpm-lock.yaml

Jak przypiąć menedżera pakietów z corepack

.gitignore nie powstrzyma nikogo przed odpaleniem złego narzędzia, jeśli ma je na swoim PATH. corepack już tak. Czyta pole packageManager w package.json i uruchamia dokładnie tę wersję npm, Yarn albo pnpm, ściągając ją przy pierwszym użyciu:

1
2
3
{
  "packageManager": "[email protected]"
}

Odpal corepack enable raz, żeby wstawić na PATH shimy Yarn i pnpm (shim npm instaluje się tylko, jeśli o niego wprost poprosisz). Od tego momentu pnpm install w tym repozytorium odpala przypiętą wersję 10.12.1 na każdej maszynie i nikt nie musi pamiętać, żeby to sprawdzać.

Corepack jest dołączony do Node od wersji 14.19.0 do (nie wliczając) 25.0.0. Na Node 25 i nowszych zainstaluj go z npm install -g corepack, a potem odpal corepack enable jak wcześniej. W CI opcja cache z actions/setup-node ('npm', 'yarn' albo 'pnpm') działa razem z corepack. Cache Zależności w GitHub Actions wyjaśnia, co stoi za tymi kluczami cache.

Jak workspace’y różnią się w npm, Yarn i pnpm

Wszystkie trzy obsługują monorepo złożone z lokalnych, połączonych ze sobą pakietów. Różnią się tym, gdzie deklarujesz członków i jak celujesz w konkretny pakiet.

npm i Yarn czytają tablicę workspaces z głównego package.json:

1
2
3
4
5
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*"]
}

Zainstaluj raz w katalogu głównym, a lokalne pakiety zostają połączone wzajemnie w swoich node_modules. Żeby uruchomić skrypt w jednym pakiecie, npm wymaga ścieżki:

1
npm run build --workspace=packages/api

Yarn wymaga nazwy pakietu:

1
yarn workspace api run build

pnpm ignoruje pole workspaces i czyta przynależność z własnego pnpm-workspace.yaml:

1
2
packages:
  - "packages/*"

Celuje w pakiety po nazwie za pomocą --filter:

1
pnpm --filter api build

Migracja monorepo z npm albo Yarn do pnpm oznacza, że sam piszesz ten pnpm-workspace.yaml, na podstawie istniejących globów z workspaces. pnpm import może przekonwertować stary lockfile, tak żeby pierwsza instalacja zachowała wersje, które już miałeś.

Kiedy sztywna struktura pnpm psuje zależność

Struktura, która blokuje phantom dependencies w twoim kodzie, odkrywa też pakiety, które na jedną z nich liczyły. Pakiet, który importuje lodash bez wpisania go we własne dependencies, działa pod npm, bo lodash i tak siedzi na górnym poziomie, niezależnie od tego, kto go tam umieścił. Pod pnpm to samo require('lodash') wywala Cannot find module.

Prawdziwa poprawka należy do package.json tego pakietu, więc zgłoś issue albo wyślij patch upstream. W międzyczasie pnpm ma furtki. Od pnpm 10.6 żyją w pnpm-workspace.yaml; na starszej, przypiętej wersji ustaw te same klucze, z myślnikami, w .npmrc (public-hoist-pattern, shamefully-hoist, node-linker). publicHoistPattern podnosi tylko pakiety pasujące do globu, co sprawdza się przy jednym czy dwóch wyjątkach:

1
2
publicHoistPattern:
  - "lodash"

shamefullyHoist: true podnosi wszystko, blisko domyślnego układu npm. Używaj tego tylko, gdy kilka niezwiązanych pakietów zakłada płaskie drzewo, bo oddajesz ochronę, dla której pnpm istnieje. nodeLinker: hoisted idzie dalej i usuwa symlinki całkowicie, dla narzędzi, które psują się na symlinkach, a nie na braku hoistingu. Yarn Berry ma ten sam typ problemu pod PnP, a jego odpowiedzią jest nodeLinker: node-modules w .yarnrc.yml.

Którego menedżera pakietów używać w nowym projekcie

W nowym projekcie użyj pnpm. Jedna kopia każdej wersji pakietu na maszynę trzyma instalacje małe na dysku, a sztywna struktura wyciąga brakującą zależność na twoim laptopie, zamiast w zepsutym release miesiące później. Licz się z dodaniem jednego czy dwóch wpisów do publicHoistPattern w pierwszym tygodniu.

Jeśli dziedziczysz projekt npm, który instaluje się bez problemów i nic cię nie zmusza do zmiany, zostań przy npm. Dodaj pole packageManager, odpal corepack enable i każda maszyna uruchomi przynajmniej tę samą wersję npm.

Wybierz Yarn Berry, gdy chcesz zero-install z cache’em zacommitowanym do gita. Bez tego koszt kompatybilności PnP nie daje ci nic.

Powiązane artykuły