Работа с монорепозиторием и локальными пакетами

Монорепозиторий (monorepo) представляет собой единый репозиторий, содержащий несколько связанных проектов, пакетов или приложений. Вместо хранения каждого пакета в отдельном репозитории вся кодовая база располагается в общей структуре каталогов.

Типичная структура монорепозитория выглядит следующим образом:

project/
├── packages/
│   ├── core/
│   ├── ui/
│   ├── utils/
│   └── api/
├── apps/
│   ├── admin/
│   └── client/
├── package.json
└── rollup.config.js

Каждый пакет может обладать собственным package.json, набором исходников и конфигурацией сборки.

Подобный подход широко применяется при разработке:

  • библиотек компонентов;
  • дизайн-систем;
  • корпоративных платформ;
  • SDK;
  • наборов утилит;
  • взаимосвязанных приложений.

При использовании Rollup работа с монорепозиториями требует понимания особенностей разрешения модулей, локальных зависимостей и организации процесса сборки.


Особенности локальных пакетов

Предположим, существует два пакета:

packages/
├── core/
└── ui/

Пакет ui зависит от core.

Файл:

// packages/ui/src/index.js

import { formatDate } from '@company/core';

В монорепозитории зависимость физически находится рядом, однако Rollup должен корректно определить её расположение.

Для этого обычно используются:

  • npm workspaces;
  • pnpm workspaces;
  • Yarn Workspaces;
  • символьные ссылки;
  • специальные настройки резолвинга.

Использование npm Workspaces

Современный npm поддерживает рабочие пространства.

Корневой package.json:

{
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

Пакет core:

{
  "name": "@company/core",
  "version": "1.0.0"
}

Пакет ui:

{
  "name": "@company/ui",
  "version": "1.0.0",
  "dependencies": {
    "@company/core": "1.0.0"
  }
}

После выполнения установки npm автоматически создаёт связи между пакетами.

В результате импорт выглядит так же, как импорт внешней зависимости:

import { formatDate } from '@company/core';

Rollup получает возможность работать с локальным пакетом через стандартный механизм поиска модулей.


Роль node-resolve в монорепозитории

Большинство локальных пакетов подключается через пакет:

import resolve from '@rollup/plugin-node-resolve';

Конфигурация:

export default {
  input: 'src/index.js',

  plugins: [
    resolve()
  ]
};

Плагин выполняет поиск зависимостей:

node_modules/

Однако в случае workspaces многие локальные пакеты представлены символьными ссылками.

Например:

node_modules/
└── @company/
    └── core -> ../. ./packages/core

Для Rollup такая структура практически не отличается от обычной зависимости.


По умолчанию Rollup разворачивает символьные ссылки до реального пути.

Например:

node_modules/@company/core

может быть преобразован в:

packages/core

Иногда это вызывает проблемы:

  • дублирование зависимостей;
  • неправильное определение пакета;
  • ошибки tree shaking;
  • конфликтующие экземпляры библиотек.

Для изменения поведения используется параметр:

export default {
  preserveSymlinks: true
};

Теперь Rollup будет использовать символическую ссылку как самостоятельный путь.

Это особенно важно при работе с крупными монорепозиториями.


Сборка нескольких пакетов

Часто требуется собирать сразу несколько библиотек.

Пример структуры:

packages/
├── core/
├── ui/
├── icons/
└── utils/

Каждый пакет имеет собственную точку входа.

Можно экспортировать массив конфигураций:

export default [
  {
    input: 'packages/core/src/index.js',
    output: {
      file: 'packages/core/dist/index.js',
      format: 'esm'
    }
  },

  {
    input: 'packages/ui/src/index.js',
    output: {
      file: 'packages/ui/dist/index.js',
      format: 'esm'
    }
  }
];

Rollup последовательно выполнит сборку всех пакетов.


Генерация конфигураций программно

При большом количестве пакетов ручное описание становится неудобным.

Вместо этого конфигурация может генерироваться автоматически.

Пример:

import fs from 'fs';

const packages = fs.readdirSync('./packages');

export default packages.map(name => ({
  input: `packages/${name}/src/index.js`,

  output: {
    file: `packages/${name}/dist/index.js`,
    format: 'esm'
  }
}));

Подобный подход позволяет масштабировать сборочную систему без постоянного редактирования конфигурации.


Локальные зависимости как external

Иногда пакет не должен включать код соседнего пакета в свой бандл.

Например:

import { logger } from '@company/core';

Можно объявить зависимость внешней:

export default {
  external: [
    '@company/core'
  ]
};

В результате импорт сохранится:

import { logger } from '@company/core';

в итоговом файле.

Такой подход особенно полезен при публикации набора взаимосвязанных библиотек.


Bundled-подход внутри монорепозитория

Другой вариант — включать локальные пакеты непосредственно в сборку.

Конфигурация:

export default {
  plugins: [
    resolve()
  ]
};

При отсутствии настройки external Rollup анализирует код зависимого пакета и объединяет его с основным бандлом.

Получается единый файл:

ui bundle
 ├─ ui code
 ├─ core code
 └─ shared utilities

Преимущества:

  • меньше запросов;
  • проще распространение;
  • отсутствие дополнительных зависимостей.

Недостатки:

  • увеличение размера сборки;
  • вероятность дублирования кода между пакетами.

Предотвращение дублирования зависимостей

Рассмотрим структуру:

core
 └─ react

ui
 └─ react

Если оба пакета содержат собственные экземпляры React, возможно появление ошибок:

Invalid hook call

Причина заключается в загрузке нескольких копий библиотеки.

Часто проблему решают через peerDependencies.

Пакет:

{
  "peerDependencies": {
    "react": "^18.0.0"
  }
}

После этого React устанавливается только на верхнем уровне монорепозитория.


Использование peerDependencies

Для библиотек внутри монорепозитория рекомендуется следующая схема.

Пакет ui:

{
  "peerDependencies": {
    "@company/core": "^1.0.0"
  }
}

Пакет не содержит собственную копию зависимости.

Во время выполнения используется экземпляр, предоставленный потребителем.

Это позволяет избежать:

  • конфликтов версий;
  • повторного включения кода;
  • появления нескольких экземпляров одной библиотеки.

Общие настройки Rollup для всех пакетов

Во многих проектах пакеты используют одинаковые настройки.

Например:

import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';

export function createConfig(input, output) {
  return {
    input,

    output,

    plugins: [
      resolve(),
      terser()
    ]
  };
}

Использование:

import { createConfig } from './build/config.js';

export default [
  createConfig(
    'packages/core/src/index.js',
    {
      file: 'packages/core/dist/index.js',
      format: 'esm'
    }
  )
];

Такой подход обеспечивает единообразие сборки.


Кросс-пакетный tree shaking

Одним из преимуществ Rollup является способность удалять неиспользуемый код даже при работе между пакетами.

Пакет:

// core

export function used() {}

export function unused() {}

Другой пакет:

import { used } from '@company/core';

used();

Во время сборки функция:

unused()

может быть полностью исключена из результата.

Для монорепозиториев это особенно важно, поскольку общий объём кода часто достигает десятков или сотен тысяч строк.


Сборка пакетов в правильном порядке

Зависимости между пакетами образуют граф.

Пример:

utils
   ↓
core
   ↓
ui
   ↓
app

Сборка должна происходить в определённой последовательности.

Некоторые инструменты автоматически вычисляют порядок:

  • npm workspaces;
  • pnpm;
  • Yarn;
  • специализированные системы сборки.

Если порядок нарушен, возможны ошибки:

Cannot find module

или

Package not built

Поэтому в крупных монорепозиториях обычно реализуется механизм определения зависимостей между пакетами.


Инкрементальная сборка

Полная пересборка десятков пакетов может занимать значительное время.

Инкрементальная стратегия предполагает пересборку только изменённых модулей.

Пример сценария:

Изменён:
packages/utils

Требуется пересобрать:

utils
core
ui
app

Но не требуется пересобирать остальные независимые пакеты.

В сочетании с watch-режимом Rollup это существенно ускоряет разработку.


Использование watch в монорепозитории

Rollup способен отслеживать изменения файлов:

rollup -c -w

При работе с несколькими пакетами система наблюдения может реагировать на изменения во всех директориях:

packages/core/src
packages/ui/src
packages/utils/src

После изменения исходников автоматически запускается новая сборка.

Это особенно удобно при разработке библиотек, тесно связанных между собой.


Экспорт нескольких форматов

Каждый пакет может публиковаться одновременно в нескольких форматах.

Пример:

export default {
  input: 'src/index.js',

  output: [
    {
      dir: 'dist/esm',
      format: 'esm'
    },

    {
      dir: 'dist/cjs',
      format: 'cjs'
    }
  ]
};

Структура после сборки:

dist/
├── esm/
└── cjs/

Подобный подход часто используется для библиотек внутри монорепозитория, которые должны поддерживать разные среды выполнения.


Организация общего каталога сборки

Иногда результаты всех пакетов складываются в единый каталог.

Пример:

dist/
├── core/
├── ui/
├── icons/
└── utils/

Конфигурация:

output: {
  dir: `dist/${packageName}`,
  format: 'esm'
}

Централизованное хранение упрощает:

  • публикацию;
  • тестирование;
  • деплой;
  • создание артефактов CI/CD.

Типичные проблемы при работе с локальными пакетами

Наиболее распространённые ошибки:

Некорректный резолвинг модулей

Could not resolve '@company/core'

Причины:

  • отсутствует workspace;
  • не установлен пакет;
  • не подключён node-resolve.

Дублирование зависимостей

Multiple React instances detected

Причины:

  • несколько копий библиотеки;
  • отсутствие peerDependencies.

Циклические зависимости

core → ui → core

Последствия:

  • предупреждения Rollup;
  • неопределённые значения при выполнении;
  • ухудшение архитектуры проекта.

Несогласованные версии

"@company/core": "^1.0.0"

и

"@company/core": "^2.0.0"

в разных пакетах могут приводить к конфликтам и неоднозначному поведению менеджера пакетов.


Архитектурные рекомендации

При организации монорепозитория с Rollup обычно придерживаются следующих принципов:

  • каждый пакет обладает собственным package.json;
  • зависимости описываются явно;
  • общие настройки сборки выносятся в отдельные модули;
  • локальные библиотеки используют peerDependencies, когда это оправдано;
  • циклические зависимости исключаются;
  • порядок сборки определяется графом зависимостей;
  • все пакеты используют единые версии ключевых библиотек;
  • для резолвинга подключается @rollup/plugin-node-resolve;
  • tree shaking применяется на уровне всего графа модулей;
  • публикация пакетов автоматизируется через общую инфраструктуру монорепозитория.

Такой подход позволяет эффективно масштабировать кодовую базу, состоящую из множества взаимосвязанных библиотек и приложений, сохраняя преимущества Rollup в области оптимизации, анализа зависимостей и генерации компактных сборок.