Перейти к содержимому

42. Project References

Привет, кодер! Сегодня мы погрузимся в мир больших проектов и узнаем, как TypeScript помогает нам управлять ими, как настоящий дирижер оркестром. Мы поговорим о “Проектных Ссылках” (Project References) — фиче, которая становится незаменимой, когда твой код разрастается до размеров целого города, а не просто уютного домика.

Представь, что у тебя не один, а несколько связанных проектов: общая библиотека компонентов UI, API-клиент, написанный на TypeScript, и несколько фронтенд-приложений, которые все это используют. Раньше, чтобы все это работало вместе, приходилось мудрить с npm link или постоянно пересобирать зависимости вручную. TypeScript Project References — это твой личный менеджер по сборке в таких сценариях. Он позволяет TypeScript-компилятору понять, как разные части твоего монорепозитория связаны между собой, и автоматически пересобирать только то, что нужно.

Project References позволяют разбить один большой TypeScript-проект на множество маленьких, взаимосвязанных подпроектов. Каждый подпроект имеет свой собственный tsconfig.json. Когда ты указываешь один проект как “ссылку” для другого, TypeScript получает информацию о зависимостях между ними.

Основные преимущества:

  • Ускорение сборки: tsc -b (Build Mode) пересобирает только те проекты, которые изменились или от которых зависят изменившиеся проекты. Это значительно сокращает время сборки в больших кодовых базах.
  • Улучшенная изоляция: Каждый подпроект компилируется независимо, что помогает избежать проблем с глобальными типами и улучшает изоляцию модулей.
  • Навигация в IDE: Редакторы кода (VS Code, WebStorm) лучше понимают связи между проектами, предоставляя более точную автодополнение, навигацию и рефакторинг между границами проектов.
  • Монорепозитории: Идеально подходит для монорепозиториев, где множество пакетов живут в одном репозитории и зависят друг от друга.

Чтобы проект мог быть целью ссылки (то есть, на него ссылаются другие), он должен быть composite. Это как фундамент, на котором будут строиться другие здания.

Давай представим, что у нас есть монорепозиторий со структурой:

my-monorepo/
├── packages/
│ ├── utils/ <-- Общие утилиты (composite)
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── api-client/ <-- Клиент для API, использует 'utils' (composite)
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ └── web-app/ <-- Фронтенд-приложение, использует 'api-client'
│ ├── src/
│ │ └── main.ts
│ └── tsconfig.json
└── tsconfig.json <-- Корневой tsconfig для сборки всех проектов

Это наш “фундамент”. Он должен быть composite.

packages/utils/tsconfig.json
{
"compilerOptions": {
"composite": true, // Обязательно для проекта, на который ссылаются
"outDir": "./dist",
"rootDir": "./src",
"declaration": true, // Генерируем .d.ts файлы для публичного API
"strict": true,
"module": "esnext",
"target": "es2019",
"esModuleInterop": true,
"moduleResolution": "node"
},
"include": ["src"]
}
packages/utils/src/index.ts
export function formatString(text: string): string {
return text.toUpperCase(); // Простая утилита для форматирования строки
}
export function generateId(): string {
return Math.random().toString(36).substring(2, 9); // Генерируем случайный ID
}

Этот проект ссылается на utils. Он тоже будет composite, так как на него может ссылаться web-app.

packages/api-client/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"strict": true,
"module": "esnext",
"target": "es2019",
"esModuleInterop": true,
"moduleResolution": "node",
// Очень важно: baseUrl и paths для корректного разрешения модулей
"baseUrl": ".",
"paths": {
"@my-monorepo/utils": ["../utils/src"] // Ссылка на исходники utils
}
},
"include": ["src"],
"references": [
// Указываем, что этот проект зависит от 'utils'
{ "path": "../utils" }
]
}
packages/api-client/src/index.ts
import { formatString, generateId } from '@my-monorepo/utils'; // Импорт из utils через paths
export interface User {
id: string;
name: string;
email: string;
}
export class ApiClient {
private apiUrl: string;
constructor(apiUrl: string) {
this.apiUrl = apiUrl;
}
fetchUser(userId: string): User {
console.log(`Fetching user ${formatString(userId)} from ${this.apiUrl}`);
return {
id: generateId(),
name: `User ${userId}`,
email: `${userId}@example.com`,
};
}
}

Обрати внимание на paths в api-client/tsconfig.json. Это позволяет нам импортировать модули из utils с использованием не относительных путей, а красивых алиасов, как если бы это был npm-пакет.

Этот проект использует api-client и не является composite, так как от него никто не зависит.

packages/web-app/tsconfig.json
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"module": "esnext",
"target": "es2019",
"esModuleInterop": true,
"moduleResolution": "node",
"jsx": "react-jsx", // Если используем React
"baseUrl": ".",
"paths": {
"@my-monorepo/api-client": ["../api-client/src"], // Ссылка на исходники api-client
"@my-monorepo/utils": ["../utils/src"] // Также можем напрямую сослаться на utils
}
},
"include": ["src"],
"references": [
// Указываем, что web-app зависит от api-client
{ "path": "../api-client" }
// Можно также добавить ссылку на utils, если бы web-app его напрямую использовал
// { "path": "../utils" }
]
}
packages/web-app/src/main.ts
import { ApiClient, User } from '@my-monorepo/api-client';
import { formatString } from '@my-monorepo/utils'; // Прямое использование utils
const client = new ApiClient("https://api.example.com");
function displayUser(user: User): void {
console.log(`Displaying user: ${formatString(user.name)} (${user.email})`);
}
const fetchedUser = client.fetchUser("john-doe-123");
displayUser(fetchedUser);
// Пример, демонстрирующий, что можно импортировать и из utils напрямую
console.log(`Formatted message: ${formatString("hello from web app")}`);

Этот файл нужен для того, чтобы запустить сборку всех проектов из корня монорепозитория с помощью tsc -b.

my-monorepo/tsconfig.json
{
"files": [], // Не компилируем файлы напрямую
"references": [
// Указываем все проекты, которые нужно собрать
{ "path": "packages/utils" },
{ "path": "packages/api-client" },
{ "path": "packages/web-app" }
]
}

Теперь, чтобы собрать весь монорепозиторий, достаточно запустить одну команду из корня:

Окно терминала
tsc -b

TypeScript проанализирует my-monorepo/tsconfig.json, найдет все ссылки, определит порядок зависимостей (utils -> api-client -> web-app) и соберет их. Если ты изменишь файл в packages/utils, а затем снова запустишь tsc -b, TypeScript пересоберет только utils, затем api-client (потому что utils изменился) и, наконец, web-app (потому что api-client изменился). Это магия!

  1. Забыли composite: true:

    • Ошибка: Error: Project 'path/to/project/tsconfig.json' cannot be referenced without setting 'composite: true'.
    • Решение: Добавь "composite": true в compilerOptions каждого проекта, на который ссылаются другие проекты.
  2. Неправильные paths или baseUrl:

    • Ошибка: Module not found при импорте из ссылочного проекта.
    • Решение: Убедись, что baseUrl установлен корректно (обычно . для текущего tsconfig.json) и paths указывают на исходные файлы (чаще всего src) ссылочного проекта, а не на его dist. Пути в paths должны быть относительны baseUrl.
  3. Не используешь tsc -b:

    • Ошибка: Изменения в зависимостях не отражаются в конечном приложении, или tsc выдает ошибки, что не может найти модули.
    • Решение: Всегда используй tsc -b для сборки проектов с Project References. Обычный tsc не будет учитывать связи между проектами.
  4. Циклические зависимости:

    • Ошибка: TypeScript выдаст ошибку, если обнаружит циклическую зависимость между проектами (например, A ссылается на B, а B ссылается на A).
    • Решение: Рефакторинг. Разбей проекты так, чтобы зависимости были однонаправленными. Возможно, выдели общую часть в третий, независимый проект.

Создай следующий монорепозиторий и настрой Project References:

my-advanced-project/
├── packages/
│ ├── logger/ <-- Проект для логирования (composite)
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── data-validator/ <-- Проект для валидации данных (composite), использует 'logger'
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ └── backend-service/ <-- Бэкенд-сервис, использует 'logger' и 'data-validator'
│ ├── src/
│ │ └── server.ts
│ └── tsconfig.json
└── tsconfig.json <-- Корневой tsconfig

Задания:

  1. Настрой tsconfig.json для packages/logger:

    • Сделай его composite.
    • В src/index.ts создай простую функцию log(message: string, level: 'info' | 'warn' | 'error').
  2. Настрой tsconfig.json для packages/data-validator:

    • Сделай его composite.
    • Добавь ссылку на packages/logger.
    • Настрой paths, чтобы импортировать log из logger как @my-project/logger.
    • В src/index.ts создай функцию isValidEmail(email: string): boolean, которая использует log для вывода информации о валидации.
  3. Настрой tsconfig.json для packages/backend-service:

    • Добавь ссылки на packages/logger и packages/data-validator.
    • Настрой paths для обоих проектов (например, @my-project/logger и @my-project/data-validator).
    • В src/server.ts используй функции log и isValidEmail для обработки какого-либо “запроса”.
  4. Создай корневой tsconfig.json:

    • Включи в него ссылки на все три пакета.
  5. Проверь сборку:

    • Запусти tsc -b из корня проекта.
    • Измени что-нибудь в packages/logger/src/index.ts и снова запусти tsc -b. Убедись, что пересобираются только изменившиеся проекты и их зависимости.

Всегда стремись к тому, чтобы твои composite проекты экспортировали только тот код, который действительно является частью их публичного API. Используй declaration: true для генерации .d.ts файлов, которые будут использоваться ссылающимися проектами. Это помогает сохранить чистоту зависимостей и предотвратить случайное использование внутренних реализаций. Имя алиасов в paths (например, @my-monorepo/utils) лучше всего делать уникальными и соответствующими именам пакетов, чтобы избежать конфликтов и улучшить читаемость.