Budowa monorepo z pnpm i Turborepo dla aplikacji Node.js krok po kroku
Utwórz monorepo w TypeScript z pustej folderu za pomocą pnpm workspaces i Turborepo, a następnie uruchom zadania budowania i filtrowania w aplikacji internetowej, API oraz wspólnych pakietach.
Gdy produkt ma interfejs użytkownika, warstwę backendową oraz kod, który obie one wymagają, oddzielne repozytoria zaczynają stanowić problem: typy są dzielone między repozytoria, konfiguracja jest kopiowana ręcznie, a jedna zmiana dotyczy kilku żądań pull request. pnpm workspaces w połączeniu z Turborepo rozwiązują ten problem bez konieczności łączenia projektów w jeden. Ten przewodnik pokazuje, jak z pustego katalogu stworzyć dwa aplikacje w TypeScript oraz wspólny pakiet, który można rozwijać, kompilować i filtrować z jednego korzenia.
Ostateczna struktura:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
├── packages/
│ ├── types/
│ └── eslint-config/
│
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.json
└── pnpm-lock.yaml
Co daje monorepo
Monorepo to jeden repozytorium Git zawierające kilka aplikacji i pakietów. Alternatywą jest jeden repozytorium na każdą kategorię:
frontend-repository
backend-repository
shared-types-repository
ui-library-repository
W monorepo te elementy stają się folderami – kod przeznaczony do wdrożenia znajduje się w katalogu apps, a kod wielokrotnie używalny w katalogu packages:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
└── packages/
├── types/
└── ui/
Kod jest następnie udostępniany bezpośrednio, zamiast najpierw być publikowany. Pakiet typów używany zarówno przez frontend, jak i backend oznacza, że zmiana treści pakietu dociera do obu stron w ramach jednego commitu:
apps/web
↓
packages/types
↑
apps/api
Gdzie pasuje Turborepo
Przestrzenie robocze pnpm łączą pakiety; Turborepo decyduje o tym, w jaki sposób zadania są wykonywane pomiędzy nimi. Oferuje orkiestrację zadań, uporządkowanie uwzględniające zależności, wykonywanie równoległe, lokalne i zdalne cacheowanie, budowę w formie kroków oraz wsparcie dla przestrzeni roboczych.
Weźmy repozytorium z trzema przestrzeniami roboczymi:
apps/web
apps/api
packages/types
Każda z nich może definiować te same skrypty:
build
lint
test
dev
Zamiast wprowadzać każdy katalog w odpowiedniej kolejności, uruchamiasz je od korzenia, a Turborepo paralelizuje proces tam, gdzie to możliwe. Aby dowiedzieć się, kiedy ta metoda jest przydatna, sprawdź gdzie Turborepo pasuje do monorepo NestJS i kiedy można go pominąć.
Wymagania wstępne
Potrzebujesz Node.js, pnpm, Git oraz edytora. Sprawdź Node.js:
node -v
A także pnpm:
pnpm -v
Jeśli brakuje pnpm, Corepack, dostarczany wraz z Node.js, może go zapewnić. Włącz go:
corepack enable
Aktywuj najnowszą wersję pnpm:
corepack prepare pnpm@latest --activate
Potwierdź:
pnpm -v
Ustawianie przestrzeni roboczej korzeniowej
Stwórz katalog:
mkdir my-monorepo
cd my-monorepo
Zainicjuj Git:
git init
Stwórz plik manifestu korzeniowego:
pnpm init
Co pozostawia:
my-monorepo/
└── package.json
Zainstaluj Turborepo w katalogu głównym
Turborepo obsługuje całe repozytorium, dlatego opcja --workspace-root instaluje je w katalogu głównym, a nie w jakimś pakiecie:
pnpm add turbo --save-dev --workspace-root
Plik manifestu w katalogu głównym zawiera skrypty, które odwołują się do turbo run; tag private zapobiega publikacji tego katalogu:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "..."
}
}
Wersja Twojego narzędzia turbo zależy od czasu instalacji. Nowsze wersje wymagają również pola packageManager w pliku package.json znajdującym się w katalogu głównym; jeśli turbo nie może zidentyfikować Twojego menedżera pakietów, sprawdź dokumentację.
Zadeklaruj przestrzenie robocze
pnpm znajduje pakiety za pomocą pliku w katalogu głównym:
pnpm-workspace.yaml
Wymień nazwy plików, które mają być traktowane jako pakiety:
packages:
- "apps/*"
- "packages/*"
Każdy katalog znajdujący się bezpośrednio pod nimi staje się przestrzenią roboczą:
apps/*
packages/*
Stwórz foldery dla obu aplikacji oraz pakietu types:
mkdir -p apps/web
mkdir -p apps/api
mkdir -p packages/types
Drzewo struktury do tej pory:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
├── packages/
│ └── types/
│
├── package.json
└── pnpm-workspace.yaml
Dodawanie dwóch aplikacji
Aplikacja internetowa
Aby skupić uwagę na monorepo, aplikacja internetowa jest na razie zwykłym projektem Node.js. Wprowadź ją:
cd apps/web
Daj jej manifest:
pnpm init
Docelowa struktura:
apps/web/
├── package.json
└── src/
└── index.ts
Stwórz plik wejściowy:
mkdir src
touch src/index.ts
Dodaj zastępczy element:
console.log("Hello from Web application");
Każde środowisko pracy wymaga TypeScript, więc wróć do katalogu głównego:
cd ../..
I zainstaluj go raz:
pnpm add typescript --save-dev --workspace-root
API
Ten sam schemat: wprowadź i zainicjalizuj:
cd apps/api
pnpm init
Stwórz plik wejściowy:
mkdir src
touch src/index.ts
Dodaj jego zastępczy element:
console.log("Hello from API application");
Obyie aplikacje są teraz zgodne:
apps/
├── web/
│ ├── src/
│ │ └── index.ts
│ └── package.json
│
└── api/
├── src/
│ └── index.ts
└── package.json
Dzielenie się konfiguracją TypeScript
Wróć do katalogu głównego:
cd ../..
Stwórz podstawową konfigurację:
tsconfig.json
Zawiera opcje wspólne dla wszystkich przestrzeni roboczych: nowoczesny cel, rozwiązanie NodeNext oraz ścisłą weryfikację:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Każda aplikacja ją rozszerza i dodaje tylko ustawienia lokalne. Dla aplikacji internetowej utwórz:
apps/web/tsconfig.json
Wskazuje na plik główny, ustawia folder wyjściowy i kompiluje tylko katalog src:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
API otrzymuje ten sam plik:
apps/api/tsconfig.json
Ze identyczną zawartością:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
Zmiany dotyczące ścisłości lub celu odbywają się teraz w jednym miejscu.
Dostarczanie skryptów budowania dla każdej przestrzeni roboczej
Turborepo uruchamia skrypty zdefiniowane przez przestrzenie robocze. Otwórz manifest internetowy:
apps/web/package.json
Ustaw nazwę z zakresem i trzy skrypty: build za pomocą tsc, dev w trybie obserwacji, lint za pomocą ESLint:
{
"name": "@repo/web",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint ."
}
}
Następnie manifest API:
apps/api/package.json
Z własną nazwą:
{
"name": "@repo/api",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint ."
}
}
Filtrzy i zależności przestrzeni roboczej odnoszą się do tych nazw @repo/.... Skrypt dev wymaga pliku tsx:
pnpm add tsx --save-dev --workspace-root
Skrypt lint zakłada również, że ESLint jest zainstalowany i skonfigurowany; należy go dodać albo usunąć ten skrypt, w przeciwnym razie pnpm lint nie zadziała.
Konfiguracja łańcucha zadań
turbo.json opisuje, jak zachowują się poszczególne zadania. Utwórz go w katalogu głównym:
turbo.json
Zdefiniuj zadania:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
}
}
}
Na co zwrócić uwagę:
"dependsOn": ["^build"]sprawia, że pakiet czeka na zakończenie budowy pakietów przestrzeni roboczej, od których zależy; znak kierownika oznacza zależności.
outputs określa, co należy zapamiętać w pamięci cache i przywrócić, dzięki czemu nieprzemienione pakiety nie są ponownie budowane.dev jest niezapisywany w cache i ma właściwość persistent, ponieważ proces nadzoru nigdy się nie kończy.lint również najpierw uruchamia zależności.tasks to obecny klucz; starsze wersje używały pipeline, więc starsze przykłady mogą wymagać dostosowania.
Uruchamianie i budowanie od korzenia
Rozpocznij wszystkie procesy deweloperskie:
pnpm dev
To działa, ponieważ skrypt dev w korzeniu jest:
"dev": "turbo run dev"
Turborepo znajduje pliki dev w każdym miejscu pracy i uruchamia je razem, używając jednego terminala dla aplikacji webowej:
cd apps/web
pnpm dev
A innego dla API:
cd apps/api
pnpm dev
Przy użyciu jednego polecenia w korzeniu:
pnpm dev
Buduj wszystko w ten sam sposób:
pnpm build
Turborepo uruchamia budowanie według zależności pakietów, najpierw te wspólne:
pnpm build
│
▼
turbo run build
│
├── packages/types
│
├── apps/api
│
└── apps/web
Taka kolejność wynika z deklarowanych zależności: dopóki packages/types nie będzie miał pliku package.json i aplikacje od niego będą zależeć, Turborepo nie uruchomi jego budowania jako pierwszego.
Celowanie w pojedyncze środowisko pracy
Parametr --filter w pnpm uruchamia skrypt w konkretnym środowisku pracy, na przykład serwer rozwojowy API:
pnpm --filter @repo/api dev
Lub budowanie strony internetowej:
pnpm --filter @repo/web build
Własny filtr Turborepo zarządza cache’em oraz kolejnością uruchamiania zadań:
pnpm turbo run build --filter=@repo/api
Polecenia, których będziesz używać codziennie
Zainstaluj wszystko:
pnpm install
Rozpocznij rozwój:
pnpm dev
Zbuduj wszystko:
pnpm build
Przeprowadź analizę stylu we wszystkich plikach:
pnpm lint
Zbuduj jeden pakiet:
pnpm --filter @repo/api build
Uruchom jedną aplikację:
pnpm --filter @repo/web dev
Dodaj zależność do jednego środowiska pracy:
pnpm --filter @repo/api add express
Zależność od lokalnego pakietu; workspace:* łączy kopię repozytorium zamiast pobierać ją z rejestru:
pnpm --filter @repo/api add @repo/types@workspace:*
Dlaczego nie poprzestać na workspacesach pnpm?
Moglibyśmy polegać wyłącznie na workspacesach:
apps/
packages/
Jednak w miarę rozwoju repozytorium coraz więcej rzeczy koordynuje się ręcznie:
build
test
lint
typecheck
dev
dependencies
task ordering
caching
Turborepo dodaje mechanizmy orkiestracji: jeden polecenie rozumie relacje między pakietami, pomija te, które się nie zmieniły, i uruchamia niezależne zadania równolegle:
pnpm turbo run build
Dla jednej aplikacji i jednego pakietu zwykłe workspacesy mogą wystarczyć. Nadal wybierasz menedżer pakietów? Przeczytaj nasze porównanie npm i pnpm.
Podsumowanie
Dobry monorepo to wspólne środowisko, w którym aplikacje i pakiety rozwijają się przy użyciu jednego zestawu narzędzi. Tutaj stos jest prosty:
pnpm
+
Turborepo
+
TypeScript
Zacznij od dwóch aplikacji:
apps/
├── web/
└── api/
Rozwijaj je w coraz więcej usług:
apps/
├── web/
├── admin/
├── api/
└── worker/
Wspierane przez wspólne pakiety:
packages/
├── ui/
├── types/
├── database/
├── auth/
└── utils/
Zaletą jest możliwość dzielenia się kodem, typami, konfiguracją i procesami pracy, przy jednoczesnym zachowaniu niezależnej organizacji każdej aplikacji. W miarę jej rozbudowywania:
- deklaruj lokalne zależności za pomocą
workspace:*, aby budowanie odbywało się we właściwej kolejności - wymień wszystkie pliki w sekcji
outputs, w przeciwnym razie przywracanie z pamięci podręcznej je pominie - przechowuj podstawową konfigurację w katalogu głównym i rozwijaj ją dalej
- dodaj
packageManageroraz narzędzia takie jak ESLint, zanim będziesz polegać na skryptach w katalogu głównym w środowisku CI
Źródła: dokumentacja Turborepo, repozytorium Turborepo, dokumentacja pnpm oraz dokumentacja Node.js.
Literatura pokrewna
- Co dzielić między aplikacjami NestJS, Next.js i Expo w Turborepo — Jak skonfigurować przestrzeń roboczą Turborepo dla API NestJS, strony Next.js oraz aplikacji Expo oraz co należy umieścić w pakietach współdzielonych.
- Gdzie mieści się Turborepo w monorepo NestJS i kiedy go pominąć — Jak Turborepo, przestrzenie robocze pnpm oraz NestJS dzielą zadania w monorepo typu TypeScript: współdzielone pakiety, grafy zadań, cacheowanie, ryzyko powiązań oraz sytuacje, gdy warto go pominąć.