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

В крупных проектах Webpack часто используется не только для сборки браузерного приложения, но и для упаковки собственной библиотеки. Такая архитектура встречается в монорепозиториях, дизайн-системах, UI-kit проектах, внутренних SDK, shared-модулях и enterprise-платформах.

Типичная структура:

project/
├── packages/
│   ├── ui-library/
│   └── app/
├── webpack.config.js
├── package.json
└── tsconfig.json

Внутри одного репозитория:

  • библиотека экспортирует переиспользуемые компоненты;
  • приложение использует библиотеку напрямую;
  • Webpack собирает оба артефакта параллельно;
  • окружения и правила обработки могут различаться.

Причины объединения сборки

Централизованная инфраструктура

Один конфиг управляет:

  • Babel;
  • TypeScript;
  • PostCSS;
  • алиасами;
  • source map;
  • оптимизацией;
  • кэшированием;
  • анализом бандла.

Это снижает дублирование.


Единая версия зависимостей

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

  • разные версии React;
  • несовместимые Babel-плагины;
  • конфликтующие loader;
  • расхождение target-сред.

Монорепозиторная сборка решает проблему единым dependency graph.


Ускорение разработки

Приложение может импортировать библиотеку напрямую:

import { Button } from '@ui/button';

Без:

  • публикации в npm;
  • ручного линкования;
  • промежуточной упаковки.

Различие между библиотекой и приложением

Webpack использует принципиально разные стратегии сборки.

Приложение

Обычно:

  • содержит entry point;
  • включает runtime;
  • генерирует HTML;
  • оптимизируется под браузер;
  • использует code splitting.

Пример:

module.exports = {
  target: 'web',
  entry: './src/index.js',
};

Библиотека

Чаще:

  • экспортирует API;
  • не содержит HTML;
  • не должна включать внешние зависимости;
  • поддерживает несколько форматов.

Пример:

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 важно для:

  • логирования;
  • cache group;
  • parallel builds;
  • MultiCompiler API;
  • разделения статистики.

Пример вывода:

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

Приложение и библиотека могут иметь разные цели.

Приложение

target: 'web'

Node-библиотека

target: 'node'

SSR-модуль

target: 'async-node'

Универсальная библиотека

target: ['web', 'es5']

Разделение Babel-конфигурации

Приложение и библиотека часто компилируются по-разному.

Для приложения

Допускается:

  • полифиллинг;
  • aggressive transpilation;
  • React Refresh;
  • browser targeting.

Для библиотеки

Нужно:

  • минимальное вмешательство;
  • отсутствие полифиллов;
  • сохранение tree shaking;
  • совместимость с разными bundler.

Пример Babel для приложения

{
  loader: 'babel-loader',
  options: {
    presets: [
      [
        '@babel/preset-env',
        {
          targets: 'defaults',
          useBuiltIns: 'usage',
          corejs: 3,
        },
      ],
    ],
  },
}

Пример Babel для библиотеки

{
  loader: 'babel-loader',
  options: {
    presets: [
      [
        '@babel/preset-env',
        {
          modules: false,
        },
      ],
    ],
  },
}

modules: false сохраняет ES-модули для tree shaking.


External-зависимости библиотеки

Критически важная часть библиотечной сборки — исключение зависимостей из бандла.

Неправильно:

bundle = React + библиотека

Правильно:

React подключается отдельно

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

externals: {
  react: 'React',
  'react-dom': 'ReactDOM',
}

Теперь React не попадёт в output.


Автоматическое исключение зависимостей

Можно исключить все dependencies.

npm install webpack-node-externals
const nodeExternals = require('webpack-node-externals');

externals: [nodeExternals()]

Особенно полезно для Node.js SDK.


Генерация разных форматов библиотеки

Современные библиотеки часто публикуют:

  • CommonJS;
  • UMD;
  • ESM.

Webpack позволяет создавать несколько конфигураций.


CommonJS-сборка

{
  output: {
    filename: 'index.cjs.js',
    libraryTarget: 'commonjs2',
  },
}

UMD-сборка

{
  output: {
    filename: 'index.umd.js',
    libraryTarget: 'umd',
  },
}

ESM-сборка

{
  experiments: {
    outputModule: true,
  },

  output: {
    module: true,
    filename: 'index.esm.js',
  },
}

Одновременная сборка нескольких форматов

module.exports = [
  appConfig,
  cjsLibrary,
  umdLibrary,
  esmLibrary,
];

Webpack создаёт несколько независимых pipeline.


Параллельная компиляция

Webpack MultiCompiler способен выполнять сборки параллельно.

Это особенно полезно:

  • при множестве таргетов;
  • больших монорепозиториях;
  • CI/CD;
  • design systems.

Управление зависимостями между сборками

Иногда приложение зависит от результата библиотечной сборки.

Например:

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];

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

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

resolve: {
  alias: {
    '@ui': path.resolve(__dirname, 'packages/ui-library/src'),
  },
}

Теперь:

import { Button } from '@ui/components/Button';

Проблема двойного React

Самая распространённая ошибка монорепозиториев:

Invalid hook call

Причина:

app/node_modules/react
library/node_modules/react

Загружаются две копии React.


Решение через alias

resolve: {
  alias: {
    react: path.resolve(__dirname, 'node_modules/react'),
  },
}

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

Для библиотек React должен быть peer dependency.

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

Разделение CSS

Приложение и библиотека требуют разной обработки стилей.


В приложении

Обычно:

MiniCssExtractPlugin

или:

style-loader

В библиотеке

Часто используются:

  • CSS Modules;
  • extracted css;
  • inline styles;
  • CSS-in-JS.

Пример разделения loader

const appStyles = {
  test: /\.css$/,
  use: ['style-loader', 'css-loader'],
};

const libraryStyles = {
  test: /\.css$/,
  use: [
    MiniCssExtractPlugin.loader,
    'css-loader',
  ],
};

Tree Shaking библиотеки

Для библиотек особенно важно удаление неиспользуемого кода.


Условия tree shaking

ES-модули

export const Button = () => {};

Отсутствие CommonJS

Плохо:

module.exports = {};

sideEffects

{
  "sideEffects": false
}

Исключения для CSS

Если библиотека импортирует CSS:

{
  "sideEffects": [
    "*.css"
  ]
}

Source map

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


Для приложения

devtool: 'source-map'

Для библиотеки

devtool: 'hidden-source-map'

или:

devtool: false

Watch mode

Webpack умеет отслеживать изменения сразу в нескольких конфигурациях.

webpack --watch

При изменении:

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

Dev Server и библиотека

Библиотека сама по себе редко использует dev server.

Но приложение может выступать playground-средой.

Структура:

packages/
  library/
  demo-app/

Playground-приложение

Приложение используется для:

  • визуального тестирования;
  • проверки компонентов;
  • hot reload;
  • интерактивной разработки.

HMR при работе с библиотекой

Если приложение импортирует библиотеку через alias:

alias: {
  '@ui': path.resolve(...)
}

то HMR может обновлять библиотечный код без публикации пакета.


Кэширование сборки

Многоконфигурационные проекты особенно выигрывают от filesystem cache.

cache: {
  type: 'filesystem',
}

Разделение cache name

cache: {
  type: 'filesystem',
  name: 'library-cache',
}

Анализ размеров

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

npm install webpack-bundle-analyzer

Разные плагины для разных сборок

Приложение:

plugins: [
  new HtmlWebpackPlugin(),
]

Библиотека:

plugins: [
  new BannerPlugin(),
]

BannerPlugin для библиотеки

new webpack.BannerPlugin({
  banner: 'My Library v1.0.0',
})

Генерация типов TypeScript

Webpack сам не создаёт .d.ts.

Поэтому обычно используется:

tsc --emitDeclarationOnly

или:

rollup-plugin-dts

Совмещение Webpack и TypeScript

Частая схема:

Webpack → JS bundle
TypeScript → declaration files

Output structure

Типичная структура:

dist/
├── app/
│   ├── bundle.js
│   └── styles.css
│
├── library/
│   ├── index.js
│   ├── index.esm.js
│   ├── index.d.ts
│   └── styles.css

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

Современные сборки библиотек активно используют experimental API.


Top-level await

experiments: {
  topLevelAwait: true,
}

Output module

experiments: {
  outputModule: true,
}

CI/CD сборка

Многоконфигурационные сборки особенно удобны для pipeline.

Один запуск:

webpack --mode production

создаёт:

  • библиотеку;
  • playground;
  • production app;
  • ESM output;
  • UMD output.

Разделение production и development

Обычно используется фабрика конфигураций.

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 проекты требуют оптимизации.


thread-loader

{
  loader: 'thread-loader',
}

persistent cache

cache: {
  type: 'filesystem',
}

include вместо exclude

Плохо:

exclude: /node_modules/

Лучше:

include: path.resolve(__dirname, 'src')

Module Federation и библиотечная сборка

Иногда библиотека выступает remote-модулем.

new ModuleFederationPlugin({
  name: 'ui',

  filename: 'remoteEntry.js',

  exposes: {
    './Button': './src/Button',
  },
})

Сборка библиотеки как remote

Позволяет:

  • подключать UI динамически;
  • обновлять модули независимо;
  • переиспользовать runtime-компоненты.

Ограничения multi-build архитектуры

Несмотря на преимущества, возникают сложности:

  • увеличение времени сборки;
  • сложная зависимость конфигов;
  • конфликт target;
  • неоднозначные loader;
  • проблемы source map;
  • сложность HMR;
  • проблемы externals.

Когда использовать единый Webpack

Подход особенно эффективен для:

  • UI-kit;
  • design system;
  • enterprise frontend;
  • SDK + demo app;
  • монорепозиториев;
  • shared platform;
  • microfrontend ecosystem.

Когда лучше разделять проекты

Иногда независимые репозитории предпочтительнее:

  • разные release cycle;
  • независимые команды;
  • разные Node.js версии;
  • разные bundler;
  • разные CI pipeline;
  • radically different architecture.

Практическая схема enterprise-проекта

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 при этом:

  • собирает приложения;
  • публикует библиотеки;
  • генерирует shared chunks;
  • управляет alias;
  • поддерживает dev workflow;
  • оптимизирует production bundles.