Многоэтапная сборка

Концепция многоэтапной сборки

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

Ключевая особенность подхода заключается в том, что Esbuild не навязывает фиксированный пайплайн. Вместо этого разработчик сам выстраивает цепочку сборок, комбинируя отдельные вызовы build() или transform().

Основные причины применения многоэтапной сборки:

  • необходимость разделения клиентской и серверной логики;
  • поэтапная оптимизация (сначала сборка, затем минификация);
  • разные конфигурации для dev и prod;
  • промежуточная генерация артефактов (метаданные, декларации, карты исходников);
  • интеграция с внешними инструментами.

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

Esbuild предоставляет два ключевых API:

  • build() — полная сборка проекта;
  • transform() — преобразование отдельного файла или фрагмента кода.

Многоэтапность достигается за счёт последовательного выполнения нескольких build() с разными параметрами:

import * as esbuild from 'esbuild';

// Этап 1: сборка исходного TypeScript в промежуточный JS
await esbuild.build({
  entryPoints: ['src/index.ts'],
  outdir: 'dist/intermediate',
  bundle: false,
  platform: 'node',
  format: 'esm'
});
// Этап 2: финальная сборка и бандлинг
await esbuild.build({
  entryPoints: ['dist/intermediate/index.js'],
  outdir: 'dist',
  bundle: true,
  minify: true,
  splitting: true,
  format: 'esm'
});

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


Разделение стадий по назначению

Типичная многоэтапная сборка включает несколько логических уровней.

1. Стадия транспиляции

На этом этапе выполняется:

  • TypeScript → JavaScript
  • JSX → JavaScript
  • современный синтаксис → совместимый ES

Основная цель — получить чистый JS без объединения модулей.

await esbuild.build({
  entryPoints: ['src/app.tsx'],
  outdir: 'dist/step1',
  bundle: false,
  loader: {
    '.tsx': 'tsx',
    '.ts': 'ts'
  }
});

2. Стадия объединения модулей

На этом этапе включается bundle: true, и Esbuild начинает строить граф зависимостей.

await esbuild.build({
  entryPoints: ['dist/step1/app.js'],
  outdir: 'dist/step2',
  bundle: true,
  format: 'esm',
  splitting: true
});

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

  • формируется dependency graph;
  • объединяются модули;
  • удаляется неиспользуемый код (tree shaking);
  • создаются чанки при включённом splitting.

3. Стадия оптимизации

Здесь происходит:

  • минификация;
  • удаление whitespace;
  • сокращение идентификаторов;
  • оптимизация условий;
  • инлайнинг констант.
await esbuild.build({
  entryPoints: ['dist/step2/app.js'],
  outdir: 'dist/final',
  bundle: true,
  minify: true,
  sourcemap: true
});

Разделение клиентской и серверной сборки

Одним из наиболее распространённых сценариев многоэтапной сборки является разделение target-окружений.

Серверный этап
await esbuild.build({
  entryPoints: ['src/server/index.ts'],
  platform: 'node',
  bundle: true,
  outdir: 'dist/server',
  external: ['express', 'fs']
});
Клиентский этап
await esbuild.build({
  entryPoints: ['src/client/index.tsx'],
  platform: 'browser',
  bundle: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist/client'
});

Такое разделение позволяет:

  • исключить Node.js зависимости из браузерного бандла;
  • оптимизировать каждый target отдельно;
  • ускорить сборку за счёт параллелизма.

Последовательные трансформации через transform()

Иногда многоэтапность реализуется не через build(), а через цепочку transform().

import { transform } from 'esbuild';

let code = `
  const x: number = 10;
  console.log(x);
`;

const step1 = await transform(code, {
  loader: 'ts'
});

const step2 = await transform(step1.code, {
  minify: true
});

Такой подход используется:

  • при генерации кода на лету;
  • в препроцессорах;
  • в интеграции с кастомными пайплайнами.

Использование контекста и инкрементальных этапов

Esbuild Context API позволяет ускорить многоэтапные сборки за счёт переиспользования состояния.

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

await ctx.rebuild();
await ctx.rebuild();

Инкрементальная модель особенно полезна при:

  • watch-режиме;
  • сложных пайплайнах с частыми изменениями;
  • многоуровневой сборке, где часть графа стабильна.

Многоэтапная сборка и метаданные

Esbuild позволяет извлекать метаинформацию через metafile.

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  metafile: true,
  outfile: 'dist/app.js'
});

Метаданные используются для следующего этапа:

  • анализа зависимостей;
  • построения оптимизированного второго бандла;
  • выделения общих чанков;
  • интеграции с внешними оптимизаторами.

Пример цепочки:

  1. сборка с metafile: true;
  2. анализ зависимостей;
  3. повторная сборка с изменёнными параметрами splitting или external.

Каскадные конфигурации сборки

Многоэтапный процесс часто оформляется как последовательность конфигураций:

const shared = {
  logLevel: 'info',
  sourcemap: true
};

// этап 1
await esbuild.build({
  ...shared,
  entryPoints: ['src/index.ts'],
  bundle: false,
  outdir: 'dist/step1'
});

// этап 2
await esbuild.build({
  ...shared,
  entryPoints: ['dist/step1/index.js'],
  bundle: true,
  outdir: 'dist/step2'
});

// этап 3
await esbuild.build({
  ...shared,
  entryPoints: ['dist/step2/index.js'],
  bundle: true,
  minify: true,
  outdir: 'dist/final'
});

Такой стиль упрощает поддержку и снижает дублирование конфигурации.


Многоэтапная сборка в монорепозиториях

В монорепозиториях многоэтапная сборка применяется для:

  • изоляции пакетов;
  • предварительной компиляции библиотек;
  • сборки shared-кода;
  • построения зависимостей между пакетами.

Пример:

  1. сборка packages/shared;
  2. сборка packages/ui, зависящего от shared;
  3. сборка приложения.
await esbuild.build({
  entryPoints: ['packages/shared/src/index.ts'],
  bundle: true,
  outdir: 'dist/shared'
});

await esbuild.build({
  entryPoints: ['packages/app/src/index.ts'],
  bundle: true,
  external: ['shared']
});

Параллельные этапы и независимые пайплайны

Многоэтапность не всегда означает последовательность. Часто этапы выполняются параллельно:

  • клиентская сборка;
  • серверная сборка;
  • сборка тестов;
  • генерация типов.
await Promise.all([
  esbuild.build({
    entryPoints: ['src/client.ts'],
    outdir: 'dist/client',
    bundle: true
  }),
  esbuild.build({
    entryPoints: ['src/server.ts'],
    outdir: 'dist/server',
    bundle: true
  })
]);

Влияние многоэтапной сборки на производительность

Многоэтапная архитектура может как ускорять, так и замедлять процесс.

Ускорение достигается за счёт:

  • разделения ответственности между этапами;
  • кэширования промежуточных результатов;
  • инкрементальных rebuild;
  • параллельного выполнения.

Замедление возникает из-за:

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

Поэтому оптимальная стратегия требует баланса между количеством этапов и их стоимостью.


Организация production pipeline

В production-сборках часто выделяются фиксированные этапы:

  1. очистка выходной директории;
  2. транспиляция;
  3. бандлинг;
  4. оптимизация;
  5. анализ метафайла;
  6. финальная минификация и разделение чанков.

Каждый этап может быть реализован отдельным вызовом Esbuild, что упрощает отладку и контроль качества артефактов.