26. Monorepo и Turborepo
🏗️ Монорепо с Turborepo — один репозиторий для всего!
Заголовок раздела «🏗️ Монорепо с Turborepo — один репозиторий для всего!»Представь: у тебя пять проектов. Веб-приложение, мобильное API, библиотека компонентов, утилиты, документация. Как хранить? Пять отдельных репозиториев? 😩
Так жили все раньше. А теперь познакомься с монорепо — одним репозиторием для всего!
📦 Что такое монорепо?
Заголовок раздела «📦 Что такое монорепо?»Аналогия: представь большой офисный шкаф 🗄️. В нём несколько ящиков: «Веб», «API», «UI-компоненты», «Утилиты». Каждый ящик — отдельный проект. Но шкаф один — и это монорепо!
Без монорепо — каждый проект сам по себе:
repo-web/ ← git clone 1, свои node_modulesrepo-mobile/ ← git clone 2, свои node_modulesrepo-api/ ← git clone 3, свои node_modulesrepo-ui-lib/ ← git clone 4, свои node_modulesПроблемы:
- Хочешь обновить кнопку? Публикуй npm-пакет, обнови версию в трёх репозиториях 😤
- Атомарный коммит через несколько проектов — невозможен
- Каждый репозиторий — свой CI, свои скрипты, своя конфигурация
С монорепо:
my-company/ apps/ web/ ← Next.js приложение mobile/ ← React Native docs/ ← Документация packages/ ui/ ← Shared компоненты utils/ ← Общие утилиты config/ ← КонфигиОдин git clone, один pnpm install, и все проекты видят друг друга! 🎯
Преимущества монорепо:
- Единый
git logдля всех изменений - Общий код без npm-публикаций
- Атомарные коммиты («сломал UI и исправил API — один коммит»)
- Единая система тестирования и CI/CD
- Рефакторинг через все проекты сразу
⚡ Turborepo — зачем и когда?
Заголовок раздела «⚡ Turborepo — зачем и когда?»Монорепо решает проблему хранения кода. Но появляется другая: как эффективно собирать все проекты?
Запускать npm run build в каждой папке вручную? Скрипт-оболочка? Нет! Turborepo — умная build-система от Vercel, которая:
- 🔄 Параллельно выполняет независимые задачи
- 💾 Кэширует результаты — не пересобирает то, что не менялось
- 🧠 Строит граф зависимостей — понимает, что собирать сначала
- ☁️ Remote Cache — кэш между разными машинами и CI
💡 Без Turborepo: 60 секунд на сборку. С Turborepo (с прогретым кэшем): 0.3 секунды! Это не гипербола — это реальные цифры.
Когда использовать:
- Есть 2+ проекта, которые делят код
- Команда разрабатывает несколько связанных продуктов
- Хочешь ускорить CI/CD
- Устал от «обновил кнопку — публикуй 3 npm-пакета»
🗂️ Структура монорепо
Заголовок раздела «🗂️ Структура монорепо»Стандартная структура Turborepo-монорепо:
my-monorepo/├── apps/│ ├── web/ ← Next.js app│ │ ├── app/│ │ ├── package.json│ │ └── next.config.mjs│ └── docs/ ← Документация (Next.js)│ ├── app/│ └── package.json├── packages/│ ├── ui/ ← Shared UI компоненты│ │ ├── src/│ │ │ ├── Button.tsx│ │ │ ├── Card.tsx│ │ │ └── index.ts│ │ ├── package.json│ │ └── tsconfig.json│ ├── utils/ ← Утилиты│ │ ├── src/│ │ │ ├── format.ts│ │ │ └── index.ts│ │ └── package.json│ ├── typescript-config/ ← Shared tsconfig│ │ ├── base.json│ │ ├── nextjs.json│ │ └── package.json│ └── eslint-config/ ← Shared ESLint конфиг│ ├── index.js│ └── package.json├── pnpm-workspace.yaml├── turbo.json└── package.jsonКлючевые правила:
apps/— конечные приложения (то, что деплоится пользователям)packages/— переиспользуемые библиотеки (не деплоятся напрямую)
🔧 pnpm workspaces
Заголовок раздела «🔧 pnpm workspaces»Turborepo работает с любым пакетным менеджером, но pnpm — стандарт де-факто. pnpm workspaces связывают все пакеты вместе через symlinks.
packages: - 'apps/*' - 'packages/*'Это говорит pnpm: «смотри в папках apps/ и packages/ — там тоже пакеты!»
Корневой package.json:
{ "name": "my-monorepo", "private": true, "scripts": { "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", "test": "turbo run test", "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "devDependencies": { "turbo": "^2.0.0", "prettier": "^3.0.0" },}package.json каждого пакета:
{ "name": "@repo/ui", "version": "0.0.1", "private": true, "main": "./src/index.ts", "exports": { ".": "./src/index.ts" }, "devDependencies": { "@repo/typescript-config": "workspace:*" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" }}Заметь "@repo/typescript-config": "workspace:*" — это ссылка на локальный пакет в монорепо! pnpm создаёт symlink. Никаких публикаций в npm 🔗
⚙️ turbo.json — конфигурация pipeline
Заголовок раздела «⚙️ turbo.json — конфигурация pipeline»turbo.json — мозг Turborepo. Здесь описывается граф зависимостей задач.
{ "$schema": "https://turbo.build/schema.json", "ui": "tui", "tasks": { "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "lint": { "dependsOn": ["^lint"] }, "test": { "dependsOn": ["^build"], "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"], "outputs": ["coverage/**"] }, "dev": { "cache": false, "persistent": true }, "typecheck": { "dependsOn": ["^typecheck"] } }}Разберём ключевые настройки:
| Параметр | Значение | Что значит |
|---|---|---|
dependsOn: ["^build"] | ^ перед именем | Сначала выполни build у ЗАВИСИМОСТЕЙ |
dependsOn: ["build"] | Без ^ | Сначала выполни build в ЭТОМ ЖЕ пакете |
outputs | Массив glob | Что кэшировать после выполнения |
inputs | Массив glob | При изменении каких файлов инвалидировать кэш |
cache: false | Булево | Никогда не кэшировать (для dev) |
persistent: true | Булево | Долгоживущий процесс (dev-серверы) |
🎯 Задачи с зависимостями между ними
Заголовок раздела «🎯 Задачи с зависимостями между ними»Как работает граф зависимостей. Допустим, три пакета: ui, utils, web. web зависит от ui и utils.
Граф пакетов:ui ─────┐ ├──→ webutils ──┘При команде turbo run build:
1. ui:build ← запускается первым (нет зависимостей) utils:build ← параллельно с ui:build!2. web:build ← ТОЛЬКО после завершения ui:build и utils:buildTurborepo сам строит граф, определяет порядок и параллелизм 🧠
// Пример зависимостей в turbo.json — расширенная конфигурация{ "tasks": { "build": { // ^build: сначала собери все зависимости пакета "dependsOn": ["^build"] }, "test": { // Тести только после сборки (в том же пакете) "dependsOn": ["build"] }, "lint": { // lint не зависит от build — можно параллельно с ним! "dependsOn": [] }, "deploy": { // Деплой только после build и test "dependsOn": ["build", "test", "^deploy"] } }}🚀 Параллельное выполнение задач
Заголовок раздела «🚀 Параллельное выполнение задач»Turborepo — мастер параллелизма. При turbo run lint build test:
Без Turborepo (последовательно):[ui:lint] [ui:build] [ui:test] [utils:lint] [utils:build] [utils:test] ...Время: ~60 секунд 🐢
С Turborepo (параллельно):t=0s: [ui:lint] [utils:lint]t=2s: [ui:build] [utils:build] [web:lint]t=4s: [web:build] (ждёт ui+utils build)t=6s: [ui:test] [utils:test] [web:test]Время: ~8 секунд 🚀Контролируй параллелизм флагом --concurrency:
# Максимум 4 задачи одновременноturbo run build --concurrency=4
# 50% от числа CPUturbo run build --concurrency=50%
# Одна задача за раз (для отладки)turbo run build --concurrency=1💾 Кэширование: локальный кэш Turborepo
Заголовок раздела «💾 Кэширование: локальный кэш Turborepo»Это магия Turborepo. Запусти turbo run build дважды:
# Первый запуск$ turbo run build web:build: cache miss, executing... web:build: ✓ 23.4s
# Второй запуск (ничего не менялось!)$ turbo run build web:build: cache hit, replaying output... web:build: ✓ 0.3ms ← 23 секунды → 0.3 миллисекунды! 🤯Turborepo хэширует входные данные задачи:
- Содержимое исходных файлов (
inputsв turbo.json) - Переменные окружения
- Зависимости пакета
- Конфигурацию turbo.json
Если хэш совпадает — достаёт результат из кэша и воспроизводит вывод.
~/.turbo/cache/ └── c4bf9a... (хэш задачи) ├── .turbo/ │ └── turbo-build.log ← сохранённый вывод консоли └── outputs/ ← сохранённые артефакты └── .next/Управление кэшем:
# Принудительно пересобрать всё (игнорировать кэш)turbo run build --force
# Dry run — покажи, что будет запущеноturbo run build --dry-run
# Dry run в JSON формате (для скриптов)turbo run build --dry-run=json
# Посмотреть граф зависимостейturbo run build --graph☁️ Remote Caching с Vercel
Заголовок раздела «☁️ Remote Caching с Vercel»Локальный кэш — хорошо. Но у тебя 10 разработчиков и CI. Каждый компилирует одно и то же! 😤
Remote Cache решает это: кэш в облаке, все делят его.
# 1. Войти в Vercelnpx turbo login
# 2. Привязать монорепо к команде Vercelnpx turbo link
# Теперь кэш автоматически загружается и скачивается!Что происходит:
Разработчик A: [build] → upload → ☁️ Remote CacheРазработчик B: ☁️ Remote Cache → download → [cache hit!]CI (GitHub): ☁️ Remote Cache → download → [cache hit!]// turbo.json — настройка remote cache{ "remoteCache": { "enabled": true, "signature": true }}Self-hosted remote cache — альтернативы Vercel:
@turborepo/remote-cache— собственный сервер- Nx Cloud — аналог
- GitHub Actions Cache — через
actions/cache
- name: Setup Turborepo Remote Cache uses: dtinth/setup-github-actions-caching-for-turbo@v1🎨 Shared UI Library (packages/ui)
Заголовок раздела «🎨 Shared UI Library (packages/ui)»Главная ценность монорепо — переиспользуемый код:
import * as React from 'react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; size?: 'sm' | 'md' | 'lg'; loading?: boolean;}
export function Button({ variant = 'primary', size = 'md', loading = false, children, disabled, className = '', ...props}: ButtonProps) { const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2';
const variants: Record<string, string> = { primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500', ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-500', danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', };
const sizes: Record<string, string> = { sm: 'px-3 py-1.5 text-sm gap-1.5', md: 'px-4 py-2 text-base gap-2', lg: 'px-6 py-3 text-lg gap-2.5', };
return ( <button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} disabled={disabled || loading} {...props} > {loading && ( <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none"> <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25" /> <path fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" className="opacity-75" /> </svg> )} {children} </button> );}import * as React from 'react';
export interface CardProps { title?: string; description?: string; children?: React.ReactNode; footer?: React.ReactNode; className?: string;}
export function Card({ title, description, children, footer, className = '' }: CardProps) { return ( <div className={`rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden ${className}`}> {(title || description) && ( <div className="p-6 pb-4"> {title && <h3 className="text-lg font-semibold text-gray-900">{title}</h3>} {description && <p className="mt-1 text-sm text-gray-500">{description}</p>} </div> )} <div className="p-6">{children}</div> {footer && <div className="border-t border-gray-100 px-6 py-4 bg-gray-50">{footer}</div>} </div> );}// packages/ui/src/index.ts — всё из одного местаexport { Button } from './Button';export type { ButtonProps } from './Button';export { Card } from './Card';export type { CardProps } from './Card';export { Badge } from './Badge';export { Input } from './Input';{ "name": "@repo/ui", "version": "0.0.1", "private": true, "exports": { ".": { "types": "./src/index.ts", "default": "./src/index.ts" } }, "devDependencies": { "@repo/typescript-config": "workspace:*", "react": "^19.0.0", "@types/react": "^19.0.0" }}🔩 Shared Config (typescript-config, eslint-config)
Заголовок раздела «🔩 Shared Config (typescript-config, eslint-config)»Единые правила для всех проектов монорепо:
{ "$schema": "https://json.schemastore.org/tsconfig", "display": "Default", "compilerOptions": { "declaration": true, "declarationMap": true, "esModuleInterop": true, "incremental": false, "isolatedModules": true, "lib": ["es2022", "DOM", "DOM.Iterable"], "moduleDetection": "force", "moduleResolution": "Bundler", "noUncheckedIndexedAccess": true, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, "target": "ES2022" }}// packages/typescript-config/nextjs.json — для Next.js приложений{ "$schema": "https://json.schemastore.org/tsconfig", "display": "Next.js", "extends": "./base.json", "compilerOptions": { "plugins": [{ "name": "next" }], "module": "ESNext", "jsx": "preserve", "allowJs": true, "paths": {} }}import js from '@eslint/js';import tseslint from 'typescript-eslint';import pluginReact from 'eslint-plugin-react';
export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, { plugins: { react: pluginReact }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'warn', 'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', }, });🛠️ Shared Utils (packages/utils)
Заголовок раздела «🛠️ Shared Utils (packages/utils)»/** Форматирует дату в читаемый вид */export function formatDate(date: Date | string, locale = 'ru-RU'): string { const d = typeof date === 'string' ? new Date(date) : date; return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric', }).format(d);}
/** Форматирует число как цену */export function formatPrice(amount: number, currency = 'RUB'): string { return new Intl.NumberFormat('ru-RU', { style: 'currency', currency, maximumFractionDigits: 0, }).format(amount);}
/** Обрезает строку до максимальной длины */export function truncate(str: string, maxLength: number): string { if (str.length <= maxLength) return str; return str.slice(0, maxLength - 3) + '...';}
/** Slug из строки */export function slugify(str: string): string { return str .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .trim();}// packages/utils/src/cn.ts — утилита для classNameexport function cn(...classes: (string | undefined | null | false)[]): string { return classes.filter(Boolean).join(' ');}export * from './format';export * from './cn';📥 Как Next.js app импортирует из packages/ui
Заголовок раздела «📥 Как Next.js app импортирует из packages/ui»// apps/web/package.json — добавляем зависимость{ "name": "web", "private": true, "dependencies": { "@repo/ui": "workspace:*", "@repo/utils": "workspace:*", "next": "^15.0.0", "react": "^19.0.0" }, "devDependencies": { "@repo/typescript-config": "workspace:*", "@repo/eslint-config": "workspace:*" }}// apps/web/app/page.tsx — используем shared компонентыimport { Button, Card } from '@repo/ui';import { formatDate, formatPrice } from '@repo/utils';
export default function HomePage() { const products = [ { id: 1, name: 'Next.js курс', price: 9900, date: new Date() }, { id: 2, name: 'React курс', price: 7900, date: new Date() }, ];
return ( <main className="container mx-auto p-8"> <h1 className="text-3xl font-bold mb-8">Наши курсы</h1> <div className="grid grid-cols-2 gap-4"> {products.map(product => ( <Card key={product.id} title={product.name} description={formatDate(product.date)} footer={ <Button variant="primary"> Купить за {formatPrice(product.price)} </Button> } /> ))} </div> </main> );}// apps/web/next.config.mjs — транспилируй пакеты монорепоconst nextConfig = { transpilePackages: ['@repo/ui', '@repo/utils'],};
export default nextConfig;💡
transpilePackagesговорит Next.js: «компилируй эти пакеты вместе с приложением». Нужно, потому что пакеты экспортируют TypeScript/JSX напрямую.
// apps/web/tsconfig.json — расширяем shared конфиг{ "extends": "@repo/typescript-config/nextjs.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"]}📋 Версионирование пакетов — Changesets
Заголовок раздела «📋 Версионирование пакетов — Changesets»В монорепо все пакеты взаимозависимы. Как управлять версиями при публичных пакетах? Changesets — стандартный инструмент:
# Установкаpnpm add -D @changesets/cli -wpnpm changeset init
# Создать changeset (описание изменений)pnpm changeset
# Обновить версии согласно changesetspnpm changeset version
# Опубликовать изменённые пакеты в npmpnpm changeset publishПример changeset-файла:
---"@repo/ui": minor"@repo/utils": patch---
Добавлены новые варианты кнопки `danger` и `ghost` (minor).Исправлена функция formatDate для Safari (patch).{ "changelog": "@changesets/cli/changelog", "commit": false, "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch"}💡 Для private пакетов (
"private": true) changesets не нужны — они не публикуются в npm. Changesets нужны только если пакеты публичные.
🔍 Фильтрация: turbo run build —filter
Заголовок раздела «🔍 Фильтрация: turbo run build —filter»Не всегда нужно запускать всё. Фильтры — мощный инструмент:
# Только конкретный пакетturbo run build --filter=web
# Все пакеты из папки appsturbo run build --filter='./apps/*'
# Пакеты, которые изменились относительно HEAD^1turbo run build --filter='[HEAD^1]'
# Пакеты, которые зависят от @repo/ui (и сам @repo/ui)turbo run build --filter=...@repo/ui
# Пакеты, от которых зависит web (включая web)turbo run build --filter=web...
# Комбинацииturbo run build --filter=web --filter=docsТаблица синтаксиса фильтров:
| Фильтр | Что выбирает |
|---|---|
web | Только пакет web |
./apps/* | Все из папки apps/ |
...web | web + всё, от чего он зависит |
web... | web + всё, что зависит от него |
[HEAD^1] | Изменённые с последнего коммита |
[main...HEAD] | Изменённые с ветки main |
# .github/workflows/ci.yml — только затронутые пакеты в CI- name: Build & Test run: turbo run build test --filter='[HEAD^1]...'➕ Добавление нового приложения в монорепо
Заголовок раздела «➕ Добавление нового приложения в монорепо»Добавим новое приложение admin:
# Создаём Next.js приложение в нужной папкеcd appspnpm create next-app admin --typescript --tailwind --app
# Возвращаемся в корень и устанавливаем зависимостиcd ../..pnpm install// apps/admin/package.json — обновляем после создания{ "name": "admin", "private": true, "scripts": { "dev": "next dev --port 3002", "build": "next build", "lint": "next lint", "start": "next start", "typecheck": "tsc --noEmit" }, "dependencies": { "@repo/ui": "workspace:*", "@repo/utils": "workspace:*", "next": "^15.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@repo/typescript-config": "workspace:*", "@repo/eslint-config": "workspace:*", "@types/react": "^19.0.0", "typescript": "^5.0.0" }}{ "extends": "@repo/typescript-config/nextjs.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"]}# Запускаем только новое приложениеturbo run dev --filter=admin
# Собираем только admin и его зависимостиturbo run build --filter=admin...Turborepo автоматически подхватит новый пакет — никакой дополнительной конфигурации не нужно! Просто потому что папка apps/admin соответствует паттерну apps/* в pnpm-workspace.yaml 🎉
🎓 Итоги
Заголовок раздела «🎓 Итоги»Turborepo монорепо — это всё вместе:
| Инструмент | Роль |
|---|---|
pnpm workspaces | Связывает пакеты symlinks |
turbo.json | Описывает граф задач |
packages/ui | Shared компоненты |
packages/utils | Shared утилиты |
packages/typescript-config | Единые TS настройки |
| Remote Cache | Кэш между машинами и CI |
| Changesets | Версионирование публичных пакетов |
Типичный рабочий день с монорепо:
# Разработкаturbo run dev --filter=web
# Проверка перед PRturbo run lint typecheck test --filter='[HEAD^1]'
# Сборка в CI (с remote cache = молниеносно!)turbo run build testТеперь посмотри на интерактивный визуализатор pipeline 👇