Генерация ESM и CJS одновременно

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

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


Основы модульных форматов

ESM (ECMAScript Modules) — стандарт модулей JavaScript, использующий import и export.

// ESM
export function sum(a, b) {
  return a + b;
}

import { sum } from './math.js';

Ключевые особенности:

  • статическая структура импортов;
  • поддержка tree-shaking;
  • асинхронная загрузка в браузере;
  • нативная поддержка в современных Node.js.

CommonJS (CJS) — традиционная система модулей Node.js.

// CJS
function sum(a, b) {
  return a + b;
}

module.exports = { sum };

const { sum } = require('./math');

Ключевые особенности:

  • динамическая система require;
  • синхронная загрузка;
  • широкая совместимость со старым Node.js-кодом.

Подходы к генерации двух форматов

Esbuild не создаёт два формата «из одного запуска автоматически», но предоставляет несколько стратегий, позволяющих получить ESM и CJS параллельно или последовательно.

Основные подходы:

  • отдельные сборки с разными format;
  • использование нескольких конфигураций;
  • обёртка сборки через скрипт;
  • интеграция с npm scripts или CI.

Базовая конфигурация для ESM

ESM-выход формируется через параметр:

import * as esbuild from 'esbuild';

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

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

  • format: 'esm' включает генерацию import/export;
  • файл обычно имеет расширение .esm.js или .mjs;
  • tree-shaking работает максимально эффективно.

Базовая конфигурация для CommonJS

import * as esbuild from 'esbuild';

esbuild.build({
  entryPoints: ['src/index.js'],
  outfile: 'dist/index.cjs.js',
  bundle: true,
  format: 'cjs',
  platform: 'node',
});

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

  • используется require/module.exports;
  • подходит для старых версий Node.js;
  • чаще используется расширение .cjs.js или .cjs.

Одновременная генерация через параллельные сборки

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

import * as esbuild from 'esbuild';

async function buildAll() {
  await Promise.all([
    esbuild.build({
      entryPoints: ['src/index.js'],
      outfile: 'dist/index.esm.js',
      bundle: true,
      format: 'esm',
      platform: 'node',
    }),
    esbuild.build({
      entryPoints: ['src/index.js'],
      outfile: 'dist/index.cjs.js',
      bundle: true,
      format: 'cjs',
      platform: 'node',
    }),
  ]);
}

buildAll();

Ключевая идея:

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

Использование отдельных конфигураций

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

// build.mjs
import * as esbuild from 'esbuild';

const shared = {
  entryPoints: ['src/index.js'],
  bundle: true,
  platform: 'node',
};

await esbuild.build({
  ...shared,
  outfile: 'dist/index.esm.js',
  format: 'esm',
});

await esbuild.build({
  ...shared,
  outfile: 'dist/index.cjs.js',
  format: 'cjs',
});

Преимущество подхода:

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

Разделение входных точек и экспортов

При генерации двух форматов важно учитывать структуру экспортов.

Рекомендуемая структура исходников:

export function sum(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

Esbuild автоматически преобразует:

  • в ESM: export
  • в CJS: module.exports

Особенности tree-shaking при двух форматах

Tree-shaking работает только в ESM-сборке.

export function used() {}
export function unused() {}

В ESM-выходе:

  • unused может быть удалён;
  • итоговый бандл меньше.

В CJS-выходе:

  • статический анализ невозможен;
  • код обычно включается полностью.

Следствие:

  • ESM используется для современных сборок;
  • CJS — для совместимости, но не для оптимизации.

Работа с внешними зависимостями

При генерации двух форматов важно контролировать external.

external: ['react', 'lodash']

Различия:

  • в ESM import react from 'react';
  • в CJS const react = require('react');

Esbuild корректно адаптирует синтаксис автоматически.


Условная логика экспорта

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

Пример:

function sum(a, b) {
  return a + b;
}

export { sum };

if (typeof module !== 'undefined') {
  module.exports = { sum };
}

Однако такой подход обычно избыточен, так как esbuild сам выполняет трансформацию.


Использование package.json для dual-package

При публикации библиотеки часто применяется схема dual-package.

{
  "name": "my-lib",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "exports": {
    "import": "./dist/index.esm.js",
    "require": "./dist/index.cjs.js"
  }
}

Значение:

  • require() получает CJS;
  • import получает ESM;
  • обеспечивается универсальная совместимость.

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

При генерации двух форматов одновременно важно учитывать производительность.

Рекомендации:

  • использовать metafile: true для анализа;
  • кэшировать зависимости;
  • разделять сборки по окружению;
  • избегать повторного бандлинга одинаковых модулей.

Метаданные сборки

Esbuild позволяет анализировать результат:

const result = await esbuild.build({
  entryPoints: ['src/index.js'],
  outfile: 'dist/index.esm.js',
  bundle: true,
  format: 'esm',
  metafile: true,
});

console.log(result.metafile);

Это помогает:

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

Сценарии использования dual build

Типовые случаи:

  • npm-библиотеки;
  • UI-компоненты;
  • утилиты общего назначения;
  • SDK для разных окружений;
  • плагины для сборщиков.

Различия поведения в Node.js

Node.js по-разному обрабатывает форматы:

  • .mjs всегда ESM;
  • .cjs всегда CommonJS;
  • .js зависит от package.json.

Esbuild позволяет стандартизировать выходные файлы, избегая неоднозначностей.


Совместимость и ограничения

При одновременной генерации ESM и CJS следует учитывать:

  • динамические require() могут вести себя иначе;
  • ESM не поддерживает __dirname без эмуляции;
  • CJS не поддерживает top-level await;
  • смешанные импорты требуют осторожности.

Практическая архитектура проекта

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

src/
  index.js
dist/
  index.esm.js
  index.cjs.js
build.js

Скрипт сборки:

{
  "scripts": {
    "build": "node build.js"
  }
}

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

Esbuild выполняет:

  • преобразование import/export;
  • адаптацию interop между модулями;
  • генерацию wrapper-кода при необходимости;
  • оптимизацию структуры зависимостей.

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


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

Для корректной генерации двух форматов важно:

  • использовать именованные экспорты;
  • избегать смешения default и module.exports;
  • поддерживать единый entrypoint.

Пример корректного API:

export function a() {}
export function b() {}

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

В сложных системах сборка часто разделяется:

  • core-бандл;
  • ESM-представление;
  • CJS-представление;
  • типы TypeScript (если применимо).

Esbuild отвечает только за JS-часть, но легко интегрируется в пайплайн.


Итоговая модель генерации

Логика dual-build сводится к простому принципу:

  • один исходный код;
  • две независимые сборки;
  • два формата вывода;
  • единая структура API.

Esbuild делает этот процесс быстрым и предсказуемым за счёт минимальной конфигурации и высокой скорости обработки кода.