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

Понятие многотаргетной сборки

В реальных проектах редко существует единственная среда выполнения. Один и тот же код может предназначаться сразу для нескольких окружений: браузер, Node.js, edge-runtime, тестовые стенды, различные версии ECMAScript или даже разные форматы модулей (ESM и CommonJS). Это приводит к необходимости формировать несколько независимых сборок из одного исходного кода.

Под многотаргетной сборкой в контексте Esbuild понимается процесс, при котором один исходный проект компилируется в несколько выходных конфигураций с различными параметрами: платформой (platform), целевым стандартом (target), форматом модулей (format), точками входа и настройками бандлинга.

Ключевая особенность Esbuild заключается в том, что такие сборки могут выполняться параллельно, поскольку API инструмента изначально асинхронный и оптимизирован под конкурентное выполнение задач.


Архитектурная основа параллелизма в Esbuild

Esbuild реализован на Go и использует модель конкурентного выполнения, в которой каждая задача сборки может выполняться независимо. JavaScript-обёртка над Esbuild лишь отправляет запросы в нативный процесс, который способен обрабатывать несколько задач одновременно.

Основные характеристики:

  • независимые контексты сборки для каждого вызова build
  • отсутствие глобального состояния между сборками
  • потокобезопасная обработка задач на уровне Go runtime
  • возможность асинхронного запуска множества сборок без блокировки event loop

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


Базовая модель параллельной сборки

Самый простой способ запустить несколько таргетов — использовать Promise.all, где каждая сборка представляет собой отдельный вызов esbuild.build.

import * as esbuild fr om 'esbuild';

await Promise.all([
  esbuild.build({
    entryPoints: ['src/index.ts'],
    outfile: 'dist/node.cjs',
    bundle: true,
    platform: 'node',
    format: 'cjs',
    target: 'node18'
  }),

  esbuild.build({
    entryPoints: ['src/index.ts'],
    outfile: 'dist/browser.js',
    bundle: true,
    platform: 'browser',
    format: 'esm',
    target: 'es2020'
  })
]);

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

Важно, что Esbuild не разделяет кеш между независимыми вызовами build, если не используется context API, поэтому каждая сборка строит граф отдельно.


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

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

const baseConfig = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  sourcemap: true,
  minify: true
};

const targets = [
  {
    ...baseConfig,
    outfile: 'dist/app.node.js',
    platform: 'node',
    format: 'cjs',
    target: 'node20'
  },
  {
    ...baseConfig,
    outfile: 'dist/app.browser.js',
    platform: 'browser',
    format: 'esm',
    target: 'es2022'
  },
  {
    ...baseConfig,
    outfile: 'dist/app.legacy.js',
    platform: 'browser',
    format: 'iife',
    target: 'es5'
  }
];

await Promise.all(targets.map(config => esbuild.build(config)));

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


Управление конкурентностью и нагрузкой

Несмотря на высокую производительность Esbuild, параллельный запуск множества тяжёлых сборок может привести к перегрузке CPU и памяти. Особенно это проявляется при:

  • включённом minify
  • большом количестве входных точек
  • использовании плагинов с тяжёлой логикой
  • больших монорепозиториях

Для контроля нагрузки используется ограничение конкурентности.

Последовательные батчи
async function buildInBatches(tasks, batchSize) {
  for (let i = 0; i < tasks.length; i += batchSize) {
    const batch = tasks.slice(i, i + batchSize);
    await Promise.all(batch.map(fn => fn()));
  }
}
Ограничение через пул
import pLimit fr om 'p-lim it';
import * as esbuild fr om 'esbuild';

const lim it = pLimit(2);

const builds = targets.map(config =>
  lim it(() => esbuild.build(config))
);

await Promise.all(builds);

Ограничение параллелизма особенно важно в CI/CD средах, где ресурсы фиксированы.


Разделение входных графов и кэширование

Каждый вызов esbuild.build строит собственный граф модулей. Это означает, что при нескольких таргетах один и тот же файл может парситься несколько раз.

Для оптимизации используется API context, который позволяет повторно использовать внутренние структуры.

const ctx = await esbuild.context({
  entryPoints: ['src/index.ts'],
  bundle: true
});

await Promise.all([
  ctx.rebuild({
    outfile: 'dist/node.js',
    platform: 'node'
  }),

  ctx.rebuild({
    outfile: 'dist/browser.js',
    platform: 'browser'
  })
]);

Хотя context чаще используется для watch-режима, он также уменьшает накладные расходы при повторных сборках.


Разделение платформенных особенностей

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

Node.js таргет
  • доступ к fs, path, process
  • использование CommonJS или ESM
  • отсутствие DOM API
Browser таргет
  • обязательная полифилизация Node-специфичных модулей
  • использование ESM или IIFE
  • необходимость tree-shaking
Edge runtime
  • ограниченные API
  • строгие требования к размеру бандла
  • часто используется только ESM

Эти различия напрямую влияют на конфигурацию Esbuild и требуют изоляции сборок.


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

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

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

  • packages/ui
  • packages/core
  • packages/server
  • packages/utils

Каждый пакет может иметь несколько таргетов:

const packages = ['ui', 'core', 'server', 'utils'];

const builds = packages.flatMap(pkg => [
  esbuild.build({
    entryPoints: [`packages/${pkg}/src/index.ts`],
    outfile: `dist/${pkg}/node.js`,
    platform: 'node'
  }),
  esbuild.build({
    entryPoints: [`packages/${pkg}/src/index.ts`],
    outfile: `dist/${pkg}/browser.js`,
    platform: 'browser'
  })
]);

await Promise.all(builds);

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


Изоляция выходных директорий

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

Практика:

  • разделение по платформам (dist/node, dist/browser)
  • использование уникальных имен файлов
  • избегание записи в один и тот же файл из разных процессов

Синхронизация побочных эффектов плагинов

Плагины Esbuild могут нарушать корректность параллельной сборки, если используют общие переменные или файловое состояние.

Типичные ошибки:

  • кэш без изоляции между сборками
  • запись в один лог-файл
  • модификация глобальных переменных

Корректный подход — делать плагины чистыми функциями:

const plugin = () => ({
  name: 'example',
  setup(build) {
    const cache = new Map();

    build.onResolve({ filter: /.*/ }, args => {
      if (cache.has(args.path)) return cache.get(args.path);
      const result = { path: args.path };
      cache.set(args.path, result);
      return result;
    });
  }
});

Каждый вызов build получает собственный экземпляр плагина, что обеспечивает изоляцию.


Параллельная минификация и влияние на производительность

Минификация — одна из самых ресурсоёмких операций в Esbuild. При параллельной сборке нескольких таргетов она становится главным фактором нагрузки.

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

  • рост потребления CPU линейно с числом таргетов
  • увеличение времени GC в Node.js процессе-обёртке
  • возможное насыщение дисковой подсистемы при записи файлов

Для снижения нагрузки часто используют стратегию разделения:

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

Разделение типов сборок: dev и production

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

  • development (быстро, без минификации)
  • production (медленно, с оптимизациями)
  • analysis (с sourcemap и метаданными)
await Promise.all([
  esbuild.build({
    entryPoints: ['src/index.ts'],
    outfile: 'dist/dev.js',
    minify: false,
    sourcemap: true
  }),

  esbuild.build({
    entryPoints: ['src/index.ts'],
    outfile: 'dist/prod.js',
    minify: true,
    sourcemap: false
  })
]);

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


Типичные ошибки при параллельной сборке

  • использование одинаковых outfile для разных таргетов
  • отсутствие изоляции плагинов
  • перегрузка системы чрезмерным числом одновременных сборок
  • смешивание платформенных зависимостей в одном конфиге
  • игнорирование различий в format при одинаковом output имени

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


Масштабирование параллельной сборки

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

  • группировка таргетов по платформам
  • разнесение сборок по процессам (multi-process build runners)
  • использование CI matrix для распределения сборок
  • кеширование зависимостей на уровне системы сборки

В крупных системах Esbuild часто используется как быстрый слой трансформации, а оркестрация параллельных таргетов выносится в отдельный build-tool слой.