Параллельная сборка нескольких таргетов

Webpack способен собирать проект сразу под несколько окружений и платформ:

  • браузер;
  • Node.js;
  • Electron;
  • серверный рендеринг;
  • библиотечные пакеты;
  • worker-процессы;
  • микрофронтенды.

Подобная архитектура называется multi-target build или multi-compiler mode. Вместо запуска нескольких отдельных команд используется единая конфигурация, возвращающая массив настроек.

Параллельная сборка особенно важна в крупных приложениях:

  • frontend + backend в одном репозитории;
  • SSR-приложения;
  • универсальные библиотеки;
  • monorepo;
  • микросервисная инфраструктура;
  • мультиплатформенные SDK.

Простейшая multi-target конфигурация

Webpack поддерживает экспорт массива конфигураций:

// webpack.config.js

const path = require('path');

module.exports = [
    {
        name: 'client',

        target: 'web',

        entry: './src/client.js',

        output: {
            filename: 'client.bundle.js',
            path: path.resolve(__dirname, 'dist/client')
        }
    },

    {
        name: 'server',

        target: 'node',

        entry: './src/server.js',

        output: {
            filename: 'server.bundle.js',
            path: path.resolve(__dirname, 'dist/server')
        }
    }
];

Webpack создаёт два независимых компилятора:

  1. client
  2. server

Каждый компилятор имеет:

  • собственный dependency graph;
  • отдельный lifecycle;
  • собственные плагины;
  • уникальные output-файлы;
  • собственные loaders;
  • независимые optimization-настройки.

Как работает MultiCompiler

При экспорте массива Webpack создаёт объект MultiCompiler.

Внутри:

MultiCompiler
 ├── Compiler(client)
 └── Compiler(server)

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

Это позволяет:

  • эффективнее использовать CPU;
  • уменьшать общее время сборки;
  • разделять ответственность между таргетами;
  • изолировать конфигурации.

Параллельность и использование потоков

Webpack сам не создаёт полноценные worker-пулы для компиляции, однако способен:

  • выполнять независимые сборки параллельно;
  • использовать parallel minimization;
  • запускать thread-loader;
  • задействовать TerserPlugin parallel mode.

Пример:

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
    optimization: {
        minimizer: [
            new TerserPlugin({
                parallel: true
            })
        ]
    }
};

В этом случае минификация распределяется между CPU cores.


Разделение frontend и backend сборки

Одна из самых распространённых схем.

Структура проекта

project/
 ├── src/
 │    ├── client/
 │    └── server/
 ├── dist/
 └── webpack.config.js

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

const path = require('path');

const clientConfig = {
    name: 'client',

    target: 'web',

    entry: './src/client/index.js',

    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist/client')
    }
};

const serverConfig = {
    name: 'server',

    target: 'node',

    entry: './src/server/index.js',

    output: {
        filename: 'server.js',
        path: path.resolve(__dirname, 'dist/server')
    }
};

module.exports = [clientConfig, serverConfig];

Использование разных target

Webpack поддерживает множество таргетов.

Основные target

Target Назначение
web браузер
node Node.js
webworker Web Worker
electron-main Electron main process
electron-renderer Electron renderer
async-node асинхронный Node runtime

Пример сборки под browser и worker

module.exports = [
    {
        name: 'app',

        target: 'web',

        entry: './src/app.js',

        output: {
            filename: 'app.js'
        }
    },

    {
        name: 'worker',

        target: 'webworker',

        entry: './src/worker.js',

        output: {
            filename: 'worker.js'
        }
    }
];

Разделение конфигурации по файлам

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

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

webpack/
 ├── webpack.client.js
 ├── webpack.server.js
 ├── webpack.worker.js
 └── webpack.common.js

Главный webpack.config.js

module.exports = [
    require('./webpack/webpack.client'),
    require('./webpack/webpack.server'),
    require('./webpack/webpack.worker')
];

Подход повышает:

  • читаемость;
  • изоляцию окружений;
  • масштабируемость;
  • поддержку CI/CD.

Общая конфигурация

Общие настройки выносятся отдельно.

webpack.common.js

module.exports = {
    resolve: {
        extensions: ['.js', '.json']
    },

    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: 'babel-loader'
            }
        ]
    }
};

Наследование через spread

const common = require('./webpack.common');

module.exports = {
    ...common,

    target: 'web',

    entry: './src/index.js'
};

Использование webpack-merge

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

Используется пакет:

npm install webpack-merge --save-dev

Пример

const { merge } = require('webpack-merge');

const common = require('./webpack.common');

module.exports = merge(common, {
    target: 'web',

    mode: 'production',

    entry: './src/index.js'
});

Разные mode для разных таргетов

Frontend и backend часто требуют разных режимов.

module.exports = [
    {
        name: 'client',

        mode: 'production',

        target: 'web'
    },

    {
        name: 'server',

        mode: 'development',

        target: 'node'
    }
];

Например:

  • frontend минифицируется;
  • backend сохраняет читаемый output;
  • серверная часть генерирует sourcemaps;
  • клиентская использует aggressive optimization.

Зависимости между конфигурациями

Webpack позволяет указывать зависимости через dependencies.

Пример

module.exports = [
    {
        name: 'client',

        entry: './src/client.js'
    },

    {
        name: 'server',

        dependencies: ['client'],

        entry: './src/server.js'
    }
];

Сначала завершится client compiler, затем server compiler.


Когда dependencies особенно полезен

Типичные сценарии:

  • SSR-манифест генерируется frontend-сборкой;
  • backend использует client assets;
  • HTML-шаблоны зависят от manifest.json;
  • server bundle читает build metadata.

Использование cache между таргетами

Webpack 5 поддерживает filesystem cache.

module.exports = [
    {
        name: 'client',

        cache: {
            type: 'filesystem'
        }
    },

    {
        name: 'server',

        cache: {
            type: 'filesystem'
        }
    }
];

Разделение cache

Без разделения кэш может конфликтовать.

Правильный вариант:

cache: {
    type: 'filesystem',

    cacheDirectory: path.resolve(
        __dirname,
        '.cache/client'
    )
}

Для server:

cache: {
    type: 'filesystem',

    cacheDirectory: path.resolve(
        __dirname,
        '.cache/server'
    )
}

Параллельная сборка library targets

Библиотеки часто публикуются в нескольких форматах:

  • CommonJS;
  • ESM;
  • UMD;
  • SystemJS.

Пример multi-format build

const path = require('path');

module.exports = [
    {
        name: 'cjs',

        target: 'node',

        entry: './src/index.js',

        output: {
            filename: 'index.cjs.js',
            libraryTarget: 'commonjs2',
            path: path.resolve(__dirname, 'dist')
        }
    },

    {
        name: 'esm',

        target: 'web',

        entry: './src/index.js',

        experiments: {
            outputModule: true
        },

        output: {
            filename: 'index.esm.js',
            module: true,
            path: path.resolve(__dirname, 'dist')
        }
    }
];

Оптимизация времени сборки

Исключение лишних loaders

Каждый target должен содержать только необходимые loaders.

Плохой вариант:

{
    test: /\.scss$/,
    use: [
        'style-loader',
        'css-loader',
        'sass-loader'
    ]
}

для server bundle.


Правильный подход

if (target === 'web') {
    rules.push({
        test: /\.scss$/,
        use: [
            'style-loader',
            'css-loader',
            'sass-loader'
        ]
    });
}

Использование thread-loader

Для тяжёлых Babel/TypeScript трансформаций применяется thread-loader.

npm install thread-loader --save-dev

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

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,

                use: [
                    'thread-loader',

                    {
                        loader: 'babel-loader'
                    }
                ]
            }
        ]
    }
};

Ограничения thread-loader

Потоки не всегда ускоряют сборку.

Проблемы:

  • overhead на сериализацию;
  • рост потребления RAM;
  • неэффективность на маленьких проектах;
  • увеличение startup-time.

Максимальный эффект достигается:

  • при большом количестве файлов;
  • тяжёлых Babel plugins;
  • TypeScript transpilation;
  • massive JSX transforms.

Раздельная минификация

Разные таргеты могут иметь разные minimizer-настройки.

const TerserPlugin = require('terser-webpack-plugin');

module.exports = [
    {
        name: 'client',

        optimization: {
            minimize: true,

            minimizer: [
                new TerserPlugin({
                    parallel: true
                })
            ]
        }
    },

    {
        name: 'server',

        optimization: {
            minimize: false
        }
    }
];

Использование externals для server bundle

Backend-сборки обычно не включают node_modules внутрь bundle.

npm install webpack-node-externals --save-dev

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

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

module.exports = {
    target: 'node',

    externals: [nodeExternals()]
};

Это:

  • уменьшает размер bundle;
  • ускоряет компиляцию;
  • снижает нагрузку на память.

Параллельная watch-сборка

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

webpack --watch

Все compiler instances переходят в watch mode одновременно.


Особенности watch mode

При изменении файлов:

  • пересобирается только затронутый compiler;
  • используется incremental compilation;
  • filesystem cache ускоряет rebuild;
  • dependency graph обновляется частично.

Использование webpack-dev-server

Для multi-target сборок обычно dev-server применяется только для frontend.

module.exports = [
    {
        name: 'client',

        devServer: {
            hot: true,
            port: 3000
        }
    },

    {
        name: 'server',

        target: 'node'
    }
];

Проблемы output-конфликтов

Одна из критических ошибок — одинаковые output paths.

Плохой пример:

output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
}

для нескольких конфигураций одновременно.


Возможные последствия

  • перезапись файлов;
  • race condition;
  • повреждение assets;
  • нестабильные hashes;
  • проблемы chunk graph.

Правильная изоляция output

output: {
    path: path.resolve(__dirname, 'dist/client')
}

и:

output: {
    path: path.resolve(__dirname, 'dist/server')
}

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

Поле name крайне важно.

{
    name: 'client'
}

Webpack использует его:

  • в логировании;
  • при dependencies;
  • в infrastructure logging;
  • в profiling;
  • в diagnostics.

Infrastructure Logging

Webpack 5 поддерживает расширенное логирование.

module.exports = {
    infrastructureLogging: {
        level: 'verbose'
    }
};

Для multi-target проектов это особенно полезно.


Статистика сборки

Можно получать отдельную статистику для каждого compiler.

stats: {
    preset: 'normal'
}

или:

stats: 'errors-warnings'

Пример production architecture

Крупный SSR-проект может содержать:

configs/
 ├── client
 ├── server
 ├── worker
 ├── admin
 ├── mobile
 └── legacy

Webpack одновременно собирает:

  • клиентскую часть;
  • серверный renderer;
  • web workers;
  • административную панель;
  • legacy bundle;
  • mobile bundle.

Dynamic configuration factory

Иногда конфигурации генерируются автоматически.

const targets = [
    'web',
    'node',
    'webworker'
];

module.exports = targets.map(target => ({
    name: target,

    target,

    entry: `./src/${target}.js`,

    output: {
        filename: `${target}.bundle.js`
    }
}));

Условная генерация конфигурации

module.exports = env => {
    const configs = [];

    if (env.client) {
        configs.push(clientConfig);
    }

    if (env.server) {
        configs.push(serverConfig);
    }

    return configs;
};

Запуск отдельных таргетов

webpack --config-name client

или:

webpack --config-name server

Это особенно удобно:

  • в CI;
  • при отладке;
  • в monorepo;
  • при selective deployment.

Использование parallel-webpack

Существуют сторонние инструменты для агрессивной параллелизации.

Например:

npm install parallel-webpack --save-dev

Однако в Webpack 5 встроенный multi-compiler часто оказывается достаточным.


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

Повышенное потребление памяти

Каждый compiler создаёт:

  • собственный graph;
  • parser instances;
  • module cache;
  • plugin state.

На крупных проектах RAM usage может быть очень высоким.


Рост времени cold-start

Несколько compiler instances:

  • увеличивают startup;
  • создают больше IO;
  • дольше инициализируют plugins.

Сложность debugging

Появляются проблемы:

  • пересечения assets;
  • race conditions;
  • shared cache bugs;
  • plugin incompatibility.

Практика организации enterprise-сборки

Наиболее устойчивая схема:

webpack/
 ├── common/
 ├── client/
 ├── server/
 ├── shared/
 ├── plugins/
 └── utils/

Рекомендации по архитектуре

Изолировать output

Каждый target обязан иметь:

  • отдельную папку;
  • отдельные filenames;
  • собственный publicPath при необходимости.

Минимизировать shared state

Нельзя:

  • мутировать shared config objects;
  • переиспользовать plugin instances;
  • хранить compiler-specific состояние глобально.

Использовать cache отдельно

Для каждого target:

.cache/client
.cache/server
.cache/admin

Разделять optimization

Frontend и backend требуют разных стратегий:

Frontend Backend
aggressive splitting минимальная обработка
tree shaking readable output
minimization быстрый rebuild
asset optimization externals

Типичная схема SSR-сборки

client build
 ├── js bundles
 ├── css assets
 └── manifest.json

server build
 ├── renderer.js
 └── SSR templates

Server compiler использует assets client compiler через dependencies и manifest-файлы.


Multi-target и Module Federation

Module Federation также может использовать multi-compiler.

Например:

host
remote-admin
remote-shop
remote-profile

Каждый remote способен собираться отдельным compiler instance.


Профилирование multi-build

Webpack поддерживает profiling.

webpack --profile --json > stats.json

Далее анализ выполняется через:

  • webpack-bundle-analyzer;
  • speed-measure-webpack-plugin;
  • Chrome tracing;
  • custom stats processing.

Когда multi-target архитектура оправдана

Подход особенно эффективен при:

  • SSR;
  • универсальных приложениях;
  • monorepo;
  • мультиплатформенных SDK;
  • Electron;
  • worker architecture;
  • библиотечной разработке;
  • микрофронтендах.

Когда лучше отказаться от multi-target

Иногда независимые build pipelines оказываются проще.

Особенно если:

  • таргеты почти не связаны;
  • CI уже распараллелен;
  • проекты изолированы;
  • различаются toolchains;
  • используется разный runtime stack.

В таких случаях отдельные webpack-конфигурации и независимые процессы могут быть стабильнее и проще в сопровождении.