Стратегия вывода: dual package (ESM + CJS)

Стратегия dual package (ESM + CJS) в Rollup основана на одновременной генерации двух форматов модуля: ECMAScript Modules (ESM) и CommonJS (CJS). Такая схема используется для обеспечения совместимости библиотеки с разными окружениями: современными сборщиками и браузерами, которые предпочитают ESM, и Node.js-экосистемой, где до сих пор широко используется CommonJS.

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

CommonJS:

  • синхронная загрузка (require)
  • динамическая природа зависимостей
  • широко поддерживается Node.js без дополнительных настроек
  • хуже поддаётся статическому анализу

ESM:

  • статическая структура импортов (import)
  • поддержка tree-shaking на уровне сборщиков
  • асинхронная модель загрузки в runtime
  • стандарт ECMAScript

Библиотека, выпускаемая только в одном формате, неизбежно ограничивает свою применимость. Dual package позволяет:

  • поддерживать старые проекты на CJS без миграции
  • обеспечивать оптимальную сборку для ESM-окружений
  • сохранять tree-shaking для современных бандлеров

Базовая структура сборки в Rollup

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

Типичная конфигурация:

export default {
  input: 'src/index.js',
  output: [
    {
      file: 'dist/index.cjs',
      format: 'cjs',
      exports: 'auto',
      sourcemap: true
    },
    {
      file: 'dist/index.esm.js',
      format: 'esm',
      sourcemap: true
    }
  ]
};

Ключевой момент — Rollup не требует дублирования входной точки. Один graph модулей используется для построения нескольких вариантов вывода.

Разделение выходных директорий

На практике dual package почти всегда разделяется по директориям:

dist/
  esm/
    index.js
  cjs/
    index.cjs

Это снижает риск конфликтов и упрощает настройку package.json.

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

export default {
  input: 'src/index.js',
  output: [
    {
      dir: 'dist/esm',
      format: 'esm',
      preserveModules: true,
      sourcemap: true
    },
    {
      dir: 'dist/cjs',
      format: 'cjs',
      exports: 'auto',
      preserveModules: true,
      sourcemap: true
    }
  ]
};

preserveModules и его роль в dual package

preserveModules сохраняет структуру исходных файлов при сборке.

Без него Rollup объединяет весь граф в один или несколько бандлов. С ним:

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

Особенно важно для библиотек, которые:

  • предоставляют набор утилит
  • используют deep imports
  • стремятся к максимальной оптимизации размера

Однако preserveModules увеличивает количество файлов в сборке, что требует аккуратной настройки package.json.

Настройка package.json для dual package

Современный подход — использование поля exports:

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

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

  • import указывает на ESM-версию
  • require указывает на CJS-версию
  • Node.js сам выбирает нужный формат

Дополнительно можно указать типы:

{
  "types": "./dist/types/index.d.ts"
}

Проблема interop между ESM и CJS

Rollup должен корректно обрабатывать различия модулей. Основные сложности:

Named exports в CommonJS

CommonJS не имеет нативных named exports, поэтому Rollup эмулирует их:

module.exports = {
  foo: 1,
  bar: 2
};

В ESM:

import { foo } from 'lib';

Rollup использует трансформацию через exports или interop.

Настройка:

output: {
  format: 'cjs',
  exports: 'named'
}

или

exports: 'auto'

auto выбирает стратегию в зависимости от структуры модуля.

Работа с external зависимостями

В dual package важно исключить зависимости из бандла:

external: ['react', 'lodash']

При необходимости можно использовать функцию:

external: (id) => id.startsWith('react')

Это особенно важно, потому что:

  • ESM-сборка часто используется в tree-shaking окружениях
  • CJS-сборка не должна дублировать зависимости

Плагины и dual package

@rollup/plugin-commonjs

Необходим для преобразования CJS-зависимостей в ESM:

import commonjs from '@rollup/plugin-commonjs';

Без него многие npm-пакеты не будут корректно работать в ESM-сборке.

@rollup/plugin-node-resolve

Обеспечивает резолвинг модулей из node_modules:

import resolve from '@rollup/plugin-node-resolve';

Часто используется в связке с CommonJS.

TypeScript

При использовании TypeScript dual package стратегия усложняется:

import typescript from '@rollup/plugin-typescript';

Типичная проблема — генерация типов только один раз:

  • либо через tsc
  • либо через rollup-plugin-dts

Рекомендуемый подход — отдельный этап сборки типов.

Два отдельных билда vs один конфиг с output[]

Существует два подхода:

1. Один конфиг с массивом output

Плюсы:

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

Минусы:

  • ограниченная гибкость
  • сложнее кастомизировать плагины под формат

2. Два отдельных конфига

// rollup.config.cjs.js
// rollup.config.esm.js

Плюсы:

  • полный контроль над pipeline
  • возможность различной трансформации
  • проще оптимизировать под конкретный формат

Минусы:

  • дублирование конфигурации
  • риск расхождения логики

Side effects и tree-shaking

Для корректного tree-shaking важно указать:

{
  "sideEffects": false
}

или более точно:

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

Это влияет на ESM-сборку, так как именно она используется большинством bundler’ов для анализа дерева зависимостей.

Различия поведения ESM и CJS в Rollup output

ESM output

output: {
  format: 'esm'
}

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

  • строгая статическая структура
  • поддержка top-level await
  • идеальная совместимость с tree-shaking

CJS output

output: {
  format: 'cjs'
}

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

  • оборачивается в require/module.exports
  • возможна потеря статической информации
  • иногда требуется interop-обертка

Частые проблемы dual package стратегии

1. Дублирование кода

Возникает при отсутствии external или неправильной конфигурации resolve.

2. Несовместимость named exports

CJS не всегда корректно транслируется в ESM интерфейс.

3. Разный runtime behavior

ESM и CJS могут по-разному обрабатывать:

  • циклические зависимости
  • lazy imports
  • singleton-модули

4. Ошибки Node.js resolution

Node может выбрать не тот entry point при отсутствии exports.

Рекомендуемая структура проекта

src/
  index.js
  utils/
dist/
  esm/
  cjs/
rollup.config.js
package.json

Оптимальная конфигурация Rollup для dual package

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

export default {
  input: 'src/index.js',
  external: ['react'],
  plugins: [
    resolve(),
    commonjs()
  ],
  output: [
    {
      dir: 'dist/esm',
      format: 'esm',
      sourcemap: true,
      preserveModules: true
    },
    {
      dir: 'dist/cjs',
      format: 'cjs',
      exports: 'auto',
      sourcemap: true,
      preserveModules: true
    }
  ]
};

Подходы к версионированию dual package

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

  • идентичную логику в ESM и CJS
  • синхронную версию API
  • единый entry point

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

Практическая стратегия публикации

  1. Сборка ESM
  2. Сборка CJS
  3. Генерация типов
  4. Проверка exports map
  5. Публикация через npm

Критически важно, чтобы оба формата проходили одинаковые тесты, иначе dual package теряет смысл как единая библиотека.

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

  • Vite и Snowpack предпочитают ESM
  • Webpack 5 поддерживает оба формата
  • Node.js 18+ корректно обрабатывает exports map
  • Jest может требовать отдельной настройки для ESM

Dual package становится стандартом для публичных библиотек, так как обеспечивает баланс между legacy и современными системами модулей.