Совместимость с устаревшими CommonJS-пакетами

Экосистема JavaScript долгое время опиралась на формат модулей CommonJS, где экспорт осуществляется через module.exports, а импорт через require(). Несмотря на переход индустрии к ESM (ECMAScript Modules), значительное количество npm-пакетов продолжает распространяться именно в формате CommonJS. Это создаёт необходимость трансформации таких модулей при сборке через Rollup.

Rollup изначально ориентирован на ESM и не интерпретирует CommonJS без дополнительных преобразований. Для корректной работы используется специализированный плагин @rollup/plugin-commonjs, который конвертирует CommonJS-модули в ESM-совместимый формат на этапе бандлинга.


Базовая архитектура взаимодействия с CommonJS

CommonJS-модуль характеризуется синхронной моделью загрузки и динамической природой экспорта:

// commonjs-module.js
const value = 42;

module.exports = {
  value
};

Эквивалент в ESM:

// esm-эквивалент
export const value = 42;

Проблема заключается в том, что CommonJS допускает динамическое изменение экспорта:

module.exports.value = compute();

Rollup не может статически анализировать такие конструкции без дополнительной трансформации.


Плагин @rollup/plugin-commonjs

Основной инструмент поддержки CommonJS в Rollup — @rollup/plugin-commonjs. Он преобразует CommonJS-модули в ESM-совместимый код, позволяя Rollup включать их в граф зависимостей.

Пример конфигурации:

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

export default {
  input: 'src/index.js',
  output: {
    format: 'esm',
    file: 'dist/bundle.js'
  },
  plugins: [
    resolve(),
    commonjs()
  ]
};

Ключевой момент заключается в порядке подключения плагинов: сначала выполняется разрешение модулей (node-resolve), затем трансформация CommonJS.


Принцип трансформации CommonJS в ESM

Плагин выполняет статический анализ и преобразует CommonJS-экспорты в именованные и дефолтные экспорты ESM:

Исходный код:

module.exports = function () {
  return 10;
};

После трансформации:

const commonjsModule = function () {
  return 10;
};

export default commonjsModule;

При наличии нескольких свойств:

exports.a = 1;
exports.b = 2;

Преобразуется в:

const a = 1;
const b = 2;

export { a, b };

Обработка require() и статического анализа

CommonJS использует require() как механизм импорта:

const fs = require('fs');
const lib = require('./lib');

Rollup с плагином преобразует такие вызовы в ESM-импорты:

import fs from 'fs';
import lib from './lib.js';

Однако поддерживается только статическая форма require(). Динамические конструкции ограничены:

const mod = require(path); // сложный случай

Подобные выражения либо игнорируются, либо оборачиваются в fallback-обработчики, что снижает эффективность tree-shaking.


Ограничения tree-shaking при CommonJS

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

Причины:

  • отсутствие статической структуры экспорта
  • динамическое изменение module.exports
  • использование require() внутри условий
  • побочные эффекты при загрузке модуля

Пример:

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./prod');
} else {
  module.exports = require('./dev');
}

Такие конструкции препятствуют анализу зависимости на этапе сборки.


Настройка поведения экспорта

Плагин предоставляет параметры, влияющие на интерпретацию CommonJS-экспорта.

requireReturnsDefault

Определяет, как интерпретируется require() при экспорте:

  • true — всегда использовать default
  • false — использовать объект модулей
  • "auto" — автоматическое определение

Пример:

commonjs({
  requireReturnsDefault: 'auto'
});

transformMixedEsModules

Обеспечивает обработку модулей, содержащих одновременно ESM и CommonJS:

export const a = 1;
module.exports = { b: 2 };

Без трансформации такие модули приводят к конфликту форматов.


strictRequires

Улучшает предсказуемость обработки require():

commonjs({
  strictRequires: true
});

При включении снижается вероятность некорректной интерпретации динамических импортов.


Разрешение именованных экспортов

CommonJS не имеет формального механизма именованных экспортов, но многие библиотеки имитируют его:

exports.parse = function () {};
exports.stringify = function () {};

Плагин формирует именованные экспорты:

export const parse;
export const stringify;

Однако при нестандартных паттернах (например, замена module.exports целиком) требуется явная настройка:

commonjs({
  namedExports: {
    'some-commonjs-lib': ['parse', 'stringify']
  }
});

Обработка module.exports = function

Особый случай представляет экспорт функции:

module.exports = function add(a, b) {
  return a + b;
};

После трансформации:

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

export default add;

Если дополнительно добавляются свойства:

module.exports.version = '1.0.0';

формируется смешанный экспорт:

function add() {}

add.version = '1.0.0';

export default add;
export const version = add.version;

Взаимодействие с циклическими зависимостями

CommonJS допускает циклические зависимости через частично инициализированные объекты:

// a.js
const b = require('./b');
module.exports.a = true;

// b.js
const a = require('./a');
module.exports.b = true;

При трансформации Rollup сохраняет поведение, но структура становится менее предсказуемой из-за статической природы ESM, где циклы обрабатываются иначе (live bindings).


Побочные эффекты модулей

CommonJS-модули часто выполняют код при импорте:

console.log('init');
module.exports = {};

Rollup учитывает флаг sideEffects в package.json:

{
  "sideEffects": false
}

Однако для CommonJS это не всегда корректно, так как анализ побочных эффектов ограничен.


Работа с Node.js встроенными модулями

Встроенные модули Node.js (fs, path, crypto) при использовании через require() преобразуются в ESM-импорты:

import fs from 'fs';
import path from 'path';

Плагин @rollup/plugin-node-resolve совместно с commonjs обеспечивает корректное разрешение таких зависимостей.


Оптимизация производительности при большом количестве CommonJS-зависимостей

При сборке крупных проектов с множеством CommonJS-пакетов возникают следующие узкие места:

  • рост времени анализа AST
  • увеличение объёма трансформаций
  • снижение эффективности tree-shaking
  • необходимость обработки множества require() паттернов

Для уменьшения нагрузки используется ограничение области применения плагина:

commonjs({
  include: /node_modules/,
  exclude: ['src/**']
});

Смешанные ESM/CommonJS пакеты

Многие современные npm-пакеты используют гибридную структуру:

export const esm = true;

module.exports = {
  cjs: true
};

Такие случаи требуют комбинированной обработки. Rollup интерпретирует ESM-часть напрямую, а CommonJS-часть через трансформацию.


Поведение default-экспорта при interop

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

  • export default
  • module.exports

Плагин создаёт интероп-обёртку:

import pkg from 'cjs-package';

Фактически:

const pkg = interopRequireDefault(require('cjs-package'));

где interopRequireDefault обеспечивает корректное извлечение default-значения.


Роль порядка плагинов в графе сборки

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

  • node-resolve — определяет реальные пути модулей
  • commonjs — конвертирует найденные CommonJS-модули
  • другие трансформеры работают поверх уже нормализованного ESM

Нарушение порядка приводит к некорректной интерпретации зависимостей и потере экспортов.


Типичные ограничения и крайние случаи

Некоторые паттерны CommonJS остаются проблемными:

  • переопределение module.exports в рантайме
  • использование eval внутри модулей
  • условные экспорты на основе среды выполнения
  • паттерны с ленивым require
module.exports = condition ? require('./a') : require('./b');

Подобные конструкции могут приводить к частичной потере tree-shaking и необходимости сохранения лишнего кода в итоговом бандле.