В крупных проектах Webpack часто используется не только для сборки браузерного приложения, но и для упаковки собственной библиотеки. Такая архитектура встречается в монорепозиториях, дизайн-системах, UI-kit проектах, внутренних SDK, shared-модулях и enterprise-платформах.
Типичная структура:
project/
├── packages/
│ ├── ui-library/
│ └── app/
├── webpack.config.js
├── package.json
└── tsconfig.json
Внутри одного репозитория:
Один конфиг управляет:
Это снижает дублирование.
При раздельных проектах возникают проблемы:
Монорепозиторная сборка решает проблему единым dependency graph.
Приложение может импортировать библиотеку напрямую:
import { Button } from '@ui/button';
Без:
Webpack использует принципиально разные стратегии сборки.
Обычно:
Пример:
module.exports = {
target: 'web',
entry: './src/index.js',
};
Чаще:
Пример:
module.exports = {
entry: './src/index.js',
output: {
library: 'MyLibrary',
libraryTarget: 'umd',
},
};
Webpack позволяет экспортировать массив:
module.exports = [
appConfig,
libraryConfig,
];
Каждая конфигурация работает независимо.
const path = require('path');
const appConfig = {
name: 'app',
mode: 'production',
entry: './packages/app/src/index.js',
output: {
path: path.resolve(__dirname, 'dist/app'),
filename: 'bundle.js',
},
};
const libraryConfig = {
name: 'library',
mode: 'production',
entry: './packages/ui-library/src/index.js',
output: {
path: path.resolve(__dirname, 'dist/library'),
filename: 'index.js',
library: 'UILibrary',
libraryTarget: 'umd',
},
};
module.exports = [appConfig, libraryConfig];
nameПоле name важно для:
Пример вывода:
app:
asset bundle.js 320 KiB
library:
asset index.js 48 KiB
Повторяющиеся настройки выносятся в общий объект.
const common = {
resolve: {
extensions: ['.js', '.ts'],
},
module: {
rules: [
{
test: /\.ts$/,
loader: 'ts-loader',
},
],
},
};
Далее:
const appConfig = {
...common,
};
const libraryConfig = {
...common,
};
webpack-mergeПростое spread-копирование плохо подходит для глубоких структур.
Поэтому применяется пакет:
npm install webpack-merge
Пример:
const { merge } = require('webpack-merge');
const appConfig = merge(common, {
entry: './app/index.js',
});
const libraryConfig = merge(common, {
entry: './library/index.js',
});
Приложение и библиотека могут иметь разные цели.
target: 'web'
target: 'node'
target: 'async-node'
target: ['web', 'es5']
Приложение и библиотека часто компилируются по-разному.
Допускается:
Нужно:
{
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
targets: 'defaults',
useBuiltIns: 'usage',
corejs: 3,
},
],
],
},
}
{
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
modules: false,
},
],
],
},
}
modules: false сохраняет ES-модули для tree shaking.
Критически важная часть библиотечной сборки — исключение зависимостей из бандла.
Неправильно:
bundle = React + библиотека
Правильно:
React подключается отдельно
externalsexternals: {
react: 'React',
'react-dom': 'ReactDOM',
}
Теперь React не попадёт в output.
Можно исключить все dependencies.
npm install webpack-node-externals
const nodeExternals = require('webpack-node-externals');
externals: [nodeExternals()]
Особенно полезно для Node.js SDK.
Современные библиотеки часто публикуют:
Webpack позволяет создавать несколько конфигураций.
{
output: {
filename: 'index.cjs.js',
libraryTarget: 'commonjs2',
},
}
{
output: {
filename: 'index.umd.js',
libraryTarget: 'umd',
},
}
{
experiments: {
outputModule: true,
},
output: {
module: true,
filename: 'index.esm.js',
},
}
module.exports = [
appConfig,
cjsLibrary,
umdLibrary,
esmLibrary,
];
Webpack создаёт несколько независимых pipeline.
Webpack MultiCompiler способен выполнять сборки параллельно.
Это особенно полезно:
Иногда приложение зависит от результата библиотечной сборки.
Например:
library → app
dependencies{
name: 'app',
dependencies: ['library'],
}
Webpack гарантирует порядок выполнения.
const path = require('path');
const common = {
mode: 'production',
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
},
],
},
};
const library = {
...common,
name: 'library',
entry: './packages/library/src/index.js',
output: {
path: path.resolve(__dirname, 'dist/library'),
filename: 'index.js',
library: 'MyLibrary',
libraryTarget: 'umd',
clean: true,
},
externals: {
react: 'React',
},
};
const app = {
...common,
name: 'app',
dependencies: ['library'],
entry: './packages/app/src/index.js',
output: {
path: path.resolve(__dirname, 'dist/app'),
filename: 'bundle.js',
clean: true,
},
};
module.exports = [library, app];
Во время разработки приложение может импортировать исходники библиотеки напрямую.
resolve: {
alias: {
'@ui': path.resolve(__dirname, 'packages/ui-library/src'),
},
}
Теперь:
import { Button } from '@ui/components/Button';
Самая распространённая ошибка монорепозиториев:
Invalid hook call
Причина:
app/node_modules/react
library/node_modules/react
Загружаются две копии React.
resolve: {
alias: {
react: path.resolve(__dirname, 'node_modules/react'),
},
}
peerDependenciesДля библиотек React должен быть peer dependency.
{
"peerDependencies": {
"react": "^18.0.0"
}
}
Приложение и библиотека требуют разной обработки стилей.
Обычно:
MiniCssExtractPlugin
или:
style-loader
Часто используются:
const appStyles = {
test: /\.css$/,
use: ['style-loader', 'css-loader'],
};
const libraryStyles = {
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
],
};
Для библиотек особенно важно удаление неиспользуемого кода.
export const Button = () => {};
Плохо:
module.exports = {};
sideEffects{
"sideEffects": false
}
Если библиотека импортирует CSS:
{
"sideEffects": [
"*.css"
]
}
Приложение и библиотека обычно используют разные режимы.
devtool: 'source-map'
devtool: 'hidden-source-map'
или:
devtool: false
Webpack умеет отслеживать изменения сразу в нескольких конфигурациях.
webpack --watch
При изменении:
Библиотека сама по себе редко использует dev server.
Но приложение может выступать playground-средой.
Структура:
packages/
library/
demo-app/
Приложение используется для:
Если приложение импортирует библиотеку через alias:
alias: {
'@ui': path.resolve(...)
}
то HMR может обновлять библиотечный код без публикации пакета.
Многоконфигурационные проекты особенно выигрывают от filesystem cache.
cache: {
type: 'filesystem',
}
cache: {
type: 'filesystem',
name: 'library-cache',
}
Размер библиотеки и приложения анализируется отдельно.
npm install webpack-bundle-analyzer
Приложение:
plugins: [
new HtmlWebpackPlugin(),
]
Библиотека:
plugins: [
new BannerPlugin(),
]
new webpack.BannerPlugin({
banner: 'My Library v1.0.0',
})
Webpack сам не создаёт .d.ts.
Поэтому обычно используется:
tsc --emitDeclarationOnly
или:
rollup-plugin-dts
Частая схема:
Webpack → JS bundle
TypeScript → declaration files
Типичная структура:
dist/
├── app/
│ ├── bundle.js
│ └── styles.css
│
├── library/
│ ├── index.js
│ ├── index.esm.js
│ ├── index.d.ts
│ └── styles.css
experimentsСовременные сборки библиотек активно используют experimental API.
experiments: {
topLevelAwait: true,
}
experiments: {
outputModule: true,
}
Многоконфигурационные сборки особенно удобны для pipeline.
Один запуск:
webpack --mode production
создаёт:
Обычно используется фабрика конфигураций.
module.exports = (env, argv) => {
const isProd = argv.mode === 'production';
return [
createAppConfig(isProd),
createLibraryConfig(isProd),
];
};
function createLibraryConfig(isProd) {
return {
mode: isProd ? 'production' : 'development',
optimization: {
minimize: isProd,
},
};
}
Большие multi-build проекты требуют оптимизации.
{
loader: 'thread-loader',
}
cache: {
type: 'filesystem',
}
Плохо:
exclude: /node_modules/
Лучше:
include: path.resolve(__dirname, 'src')
Иногда библиотека выступает remote-модулем.
new ModuleFederationPlugin({
name: 'ui',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button',
},
})
Позволяет:
Несмотря на преимущества, возникают сложности:
Подход особенно эффективен для:
Иногда независимые репозитории предпочтительнее:
root/
├── packages/
│ ├── core-sdk/
│ ├── ui-kit/
│ ├── admin-app/
│ ├── client-app/
│ └── docs-site/
│
├── webpack/
│ ├── common.js
│ ├── app.js
│ ├── library.js
│ └── utils.js
│
├── tsconfig.base.json
└── package.json
Webpack при этом: