CommonJS: особенности и ограничения при сборке

CommonJS остаётся одной из ключевых систем модулей в экосистеме JavaScript, особенно в среде Node.js и в большом количестве существующих npm-пакетов. При сборке с использованием Rollup работа с CommonJS требует понимания того, как устроена система модулей, какие трансформации выполняет сборщик и почему поведение таких модулей отличается от ES Modules.

CommonJS использует синхронную модель загрузки модулей через функцию require. Экспорт осуществляется через объект module.exports или его сокращённую форму exports.

const utils = require('./utils');

module.exports = {
  sum: (a, b) => a + b
};

Главное отличие от ES Modules заключается в том, что CommonJS:

  • выполняется в рантайме
  • не имеет статического графа импортов
  • допускает динамический require
  • экспортирует не привязанные (live bindings) значения, а снимок объекта экспорта

В ES Modules структура импортов и экспортов анализируется статически, что позволяет Rollup строить оптимизированный граф зависимостей и выполнять tree-shaking. В CommonJS такая оптимизация невозможна без дополнительных преобразований.

Проблема статического анализа

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

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

В CommonJS аналогичный импорт выглядит так:

const utils = require('./utils');

Проблема заключается в том, что require может:

  • вызываться условно
  • использовать переменные
  • зависеть от runtime-значений
const moduleName = condition ? './a' : './b';
const mod = require(moduleName);

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

@rollup/plugin-commonjs и преобразование модулей

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

Основная задача плагина:

  • преобразовать require в import
  • преобразовать module.exports в export default или именованные экспорты
  • эмулировать поведение CommonJS в рамках ES Module системы

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

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

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

После преобразования:

const add = (a, b) => a + b;

export default {
  add
};

Однако такая трансформация не всегда однозначна и зависит от структуры исходного кода.

Ограничения tree-shaking при использовании CommonJS

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

Причины:

Динамическая природа exports

module.exports[process.env.TYPE] = function () {};

Такой код невозможно анализировать статически.

Полный экспорт объекта

module.exports = {
  a,
  b,
  c
};

При импорте:

const mod = require('./mod');

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

Побочные эффекты

CommonJS-модули могут выполнять код при загрузке:

console.log('module loaded');

module.exports = {};

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

Named exports и неоднозначность преобразования

CommonJS не имеет строгого понятия именованных экспортов, однако Rollup пытается их эмулировать.

exports.sum = (a, b) => a + b;
exports.mul = (a, b) => a * b;

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

export const sum = (a, b) => a + b;
export const mul = (a, b) => a * b;

Однако проблемы возникают при смешанном использовании:

module.exports = function () {};
module.exports.helper = () => {};

Здесь одновременно присутствует default-экспорт и дополнительные свойства, что создаёт неоднозначную модель представления.

Interop между CommonJS и ES Modules

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

Импорт CommonJS в ES Module

import pkg from 'commonjs-package';

Поскольку CommonJS не имеет default export в строгом смысле, Rollup создаёт синтетический default.

Named imports из CommonJS

import { something } from 'commonjs-package';

Это возможно только при статическом анализе экспортов плагином. В противном случае Rollup выдаёт fallback через объектный доступ.

Деструктуризация и её проблемы

Часто CommonJS импортируется с деструктуризацией:

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

Если экспорт не статичен, это может привести к ошибкам:

module.exports = getUtils();

В этом случае структура экспортируемого объекта известна только в runtime.

Side effects detection

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

Факторы, влияющие на определение side effects:

  • top-level вызовы функций
  • изменение глобального состояния
  • модификация module.exports в runtime
  • использование require в условных блоках

Плагин CommonJS пытается пометить модули как side-effect free, но это часто невозможно без риска.

Динамический require и его последствия

function load(name) {
  return require('./' + name);
}

Такая конструкция:

  • ломает статический анализ
  • делает невозможным tree-shaking
  • увеличивает итоговый бандл

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

Особенности кеширования модулей

CommonJS использует кеширование через require.cache. Это означает:

  • модуль выполняется один раз
  • последующие require возвращают кешированный результат

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

Циклические зависимости

CommonJS поддерживает циклические зависимости, но их поведение отличается от ES Modules.

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

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

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

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

Производительность при сборке

Обработка CommonJS увеличивает время сборки по нескольким причинам:

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

Особенно заметно это в больших проектах с зависимостями из npm, где значительная часть пакетов до сих пор использует CommonJS.

Практическая модель совместимости

В реальных сборках Rollup CommonJS рассматривается как промежуточный слой:

  1. ES Modules — основной формат
  2. CommonJS — преобразуемый формат
  3. UMD/AMD — устаревшие или специализированные форматы

Плагин CommonJS служит адаптером между вторым и первым уровнем, обеспечивая единый граф модулей.

Ограничения, влияющие на архитектуру проекта

Использование CommonJS в цепочке зависимостей приводит к архитектурным ограничениям:

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

По этой причине в современных сборках предпочтение отдаётся ES Modules, а CommonJS рассматривается как совместимый, но не оптимальный формат для обработки Rollup.