Монорепозиторий — это единое хранилище исходного кода, содержащее несколько приложений, библиотек, сервисов или пакетов. В экосистеме JavaScript подобная архитектура особенно распространена благодаря поддержке package manager workspace-механизмов и возможностям Webpack по работе с зависимостями, alias, symlink и многопакетными сборками.
Типичная структура:
project/
├─ apps/
│ ├─ admin/
│ ├─ client/
│ └─ mobile/
├─ packages/
│ ├─ ui/
│ ├─ utils/
│ └─ api/
├─ package.json
├─ webpack.config.js
└─ node_modules/
В такой модели:
apps содержит конечные приложения;packages содержит переиспользуемые модули;Поддержка workspace появилась в npm начиная с версии 7.
Пример:
{
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}
Каждый пакет содержит собственный package.json.
Пример:
{
"name": "@project/ui",
"version": "1.0.0"
}
После установки npm создаёт симлинки между пакетами.
Yarn workspace предоставляет аналогичный механизм:
{
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}
Yarn активно используется совместно с:
pnpm использует отдельный файл:
packages:
- 'apps/*'
- 'packages/*'
Особенность pnpm — нестандартная структура
node_modules.
Пакеты хранятся централизованно:
node_modules/.pnpm/
После чего создаются symlink-связи.
Это уменьшает размер диска и ускоряет установку зависимостей.
Webpack в монорепозитории сталкивается с несколькими типовыми задачами:
Предположим, имеется пакет:
packages/ui
и приложение:
apps/admin
Импорт:
import { Button } from '@project/ui';
Webpack должен:
const path = require('path');
module.exports = {
resolve: {
extensions: ['.js', '.ts', '.tsx']
}
};
Однако этого недостаточно для монорепозитория.
Наиболее распространённый подход:
const path = require('path');
module.exports = {
resolve: {
alias: {
'@project/ui': path.resolve(__dirname, '../. ./packages/ui/src'),
'@project/utils': path.resolve(__dirname, '../. ./packages/utils/src')
}
}
};
Преимущества:
При большом количестве пакетов alias лучше генерировать автоматически.
Пример:
const fs = require('fs');
const path = require('path');
const packagesDir = path.resolve(__dirname, 'packages');
const aliases = {};
for (const dir of fs.readdirSync(packagesDir)) {
aliases[`@project/${dir}`] = path.join(
packagesDir,
dir,
'src'
);
}
module.exports = aliases;
Использование:
const aliases = require('./aliases');
module.exports = {
resolve: {
alias: aliases
}
};
Workspace-пакеты обычно подключаются через symbolic links.
Webpack по умолчанию:
resolve: {
symlinks: true
}
Это означает:
Основные последствия:
Классическая ошибка:
Invalid hook call
Причина:
apps/admin/node_modules/react
packages/ui/node_modules/react
В результате:
Hooks перестают работать корректно.
resolve: {
alias: {
react: path.resolve(__dirname, 'node_modules/react'),
'react-dom': path.resolve(__dirname, 'node_modules/react-dom')
}
}
Теперь все workspace используют единый экземпляр React.
Иногда используется:
resolve: {
symlinks: false
}
Это заставляет Webpack сохранять symlink-путь вместо реального пути.
Преимущества:
Но некоторые loader и plugin могут ожидать реальные пути.
Обычно Babel-конфиг размещается в корне:
babel.config.js
Пример:
module.exports = {
presets: [
'@babel/preset-env',
'@babel/preset-react',
'@babel/preset-typescript'
]
};
.babelrc действует локально внутри пакета.
В монорепозитории это приводит к:
babel.config.js работает глобально.
Стандартная ошибка:
Module parse failed
Webpack не транспилирует код из packages.
Плохой вариант:
exclude: /node_modules/
Workspace-пакеты могут оказаться внутри node_modules
через symlink.
Правильный подход:
const path = require('path');
module.exports = {
module: {
rules: [
{
test: /\.[jt]sx?$/,
include: [
path.resolve(__dirname, 'src'),
path.resolve(__dirname, '../. ./packages')
],
use: 'babel-loader'
}
]
}
};
Общий конфиг:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"baseUrl": ".",
"paths": {
"@project/ui": [
"packages/ui/src"
],
"@project/utils": [
"packages/utils/src"
]
}
}
}
Внутри пакета:
{
"extends": "../. ./tsconfig.base.json"
}
Одна из частых проблем:
Необходимо синхронизировать:
tsconfig paths;webpack resolve.alias.Автоматическая интеграция:
npm install tsconfig-paths-webpack-plugin
const TsconfigPathsPlugin =
require('tsconfig-paths-webpack-plugin');
module.exports = {
resolve: {
plugins: [
new TsconfigPathsPlugin()
]
}
};
Теперь Webpack использует paths автоматически.
В монорепозитории зависимости часто располагаются в корне:
project/node_modules
Это уменьшает:
Hoisting — поднятие зависимостей вверх.
Пример:
packages/ui/node_modules/react
перемещается в:
project/node_modules/react
Иногда пакет случайно использует dependency, не указанную в
package.json.
Локально всё работает благодаря hoisting, но CI или публикация ломаются.
pnpm особенно полезен тем, что:
При Module Federation особенно важно избегать duplicate dependency.
Пример:
new ModuleFederationPlugin({
shared: {
react: {
singleton: true
},
'react-dom': {
singleton: true
}
}
});
Singleton гарантирует:
Webpack dev server может не отслеживать изменения в соседних пакетах.
Особенно часто проблема возникает:
module.exports = {
watchOptions: {
ignored: /node_modules/
}
};
Но workspace-пакеты могут находиться внутри
node_modules.
watchOptions: {
ignored: [
'**/node_modules/**',
'!**/node_modules/@project/**'
]
}
Иногда требуется:
resolve: {
symlinks: false
}
иначе HMR не замечает изменения.
Webpack 5:
module.exports = {
cache: {
type: 'filesystem'
}
};
В монорепозитории это особенно важно.
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
}
Иногда кэш выносится в корень:
cache: {
type: 'filesystem',
cacheDirectory:
path.resolve(__dirname, '../. ./.cache/webpack')
}
Это позволяет:
В монорепозитории часто существуют:
module.exports = [
clientConfig,
serverConfig,
adminConfig
];
Webpack запускает несколько compiler одновременно.
Пример структуры:
packages/ui
├─ src
├─ dist
├─ package.json
└─ webpack.config.js
module.exports = {
output: {
library: {
type: 'module'
}
},
experiments: {
outputModule: true
}
};
Для библиотек часто используется:
externals: {
react: 'react',
'react-dom': 'react-dom'
}
Это предотвращает:
Для корректного tree shaking:
{
"sideEffects": false
}
Неудачный пример:
export * from './Button';
export * from './Modal';
export * from './Chart';
Такие barrel-файлы иногда ухудшают tree shaking.
Лучше:
export { Button } from './Button';
export { Modal } from './Modal';
Плохой вариант:
include: path.resolve(__dirname, '../. ./')
Webpack начнёт обрабатывать весь монорепозиторий.
Лучше:
include: [
path.resolve(__dirname, 'src'),
path.resolve(__dirname, '../. ./packages/ui/src')
]
Для крупных монорепозиториев:
{
loader: 'thread-loader'
}
Позволяет распараллелить Babel-transpilation.
Webpack 5 значительно ускоряет rebuild благодаря persistent cache:
cache: {
type: 'filesystem'
}
Особенно заметный эффект:
Библиотека обычно публикует только:
dist/
{
"exports": {
".": "./dist/index.js"
}
}
{
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts"
}
Nx предоставляет:
Webpack интегрируется через:
@nx/webpack
Turborepo ускоряет:
Часто используется совместно с:
Yarn PnP полностью убирает node_modules.
Webpack требует специального resolver:
yarn add pnp-webpack-plugin
const PnpWebpackPlugin =
require('pnp-webpack-plugin');
module.exports = {
resolve: {
plugins: [
PnpWebpackPlugin
]
},
resolveLoader: {
plugins: [
PnpWebpackPlugin.moduleLoader(module)
]
}
};
Причины:
main;Симптомы:
Invalid hook call
Решение:
Причина:
Webpack не транспилирует workspace-пакет.
Решение:
include: [
path.resolve(__dirname, '../. ./packages')
]
Причины:
project/
├─ apps/
├─ packages/
├─ node_modules/
├─ package.json
├─ tsconfig.base.json
├─ babel.config.js
└─ webpack/
webpack/
├─ webpack.common.js
├─ webpack.dev.js
├─ webpack.prod.js
└─ aliases.js
Рекомендуемый namespace:
@project/ui
@project/utils
@project/config
Это:
{
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}
import { Button } from '@project/ui';
const path = require('path');
module.exports = {
resolve: {
alias: {
'@project/ui': path.resolve(
__dirname,
'../. ./packages/ui/src'
)
},
symlinks: false
},
module: {
rules: [
{
test: /\.[jt]sx?$/,
include: [
path.resolve(__dirname, 'src'),
path.resolve(__dirname, '../. ./packages')
],
use: 'babel-loader'
}
]
},
cache: {
type: 'filesystem'
}
};
Workspace позволяет:
Монорепозиторий обеспечивает:
Общие workspace уменьшают: