Проблемы с CommonJS и динамическими импортами

Система модулей CommonJS, несмотря на историческую значимость в экосистеме Node.js, вступает в фундаментальное противоречие с моделью статического анализа, на которой основан Rollup. Основная идея Rollup заключается в построении статического графа зависимостей и последующем удалении неиспользуемого кода (tree-shaking). CommonJS же изначально проектировался как динамическая система загрузки модулей, где структура зависимостей может быть определена только во время выполнения.

Ключевая проблема заключается в отсутствии гарантированно статического контекста. В CommonJS используются конструкции вида:

const dep = require(someVariable);

или даже:

if (condition) {
  require('./module-a');
} else {
  require('./module-b');
}

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

Отсутствие статической структуры и последствия для tree-shaking

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

CommonJS нарушает эту модель по нескольким причинам:

  • экспорты могут быть изменены в любой момент выполнения;
  • объект module.exports может быть перезаписан целиком;
  • доступ к экспортам может происходить через вычисляемые свойства;
  • зависимости могут быть вызваны условно или циклически без явной структуры.

Пример:

module.exports = Math.random() > 0.5
  ? require('./a')
  : require('./b');

В такой ситуации невозможно определить, какие именно символы будут доступны в результате выполнения модуля. Rollup вынужден считать такой модуль неоптимизируемым с точки зрения tree-shaking, что приводит к включению большего объёма кода в бандл.

Интерпретация require и эвристический анализ

Для поддержки CommonJS Rollup использует плагинную систему, чаще всего через @rollup/plugin-commonjs. Этот плагин не превращает CommonJS в полноценный ES-модуль, а лишь пытается эвристически реконструировать статическую структуру.

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

  1. Поиск статических вызовов require('module')
  2. Анализ присваиваний module.exports = ...
  3. Определение свойств объекта exports
  4. Попытка преобразования CommonJS в ES-эквивалент

Однако любая динамика резко снижает точность анализа. Например:

const name = './module-' + process.env.TYPE;
const mod = require(name);

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

Динамические импорты как отдельный источник неопределённости

Помимо CommonJS, аналогичную проблему создают динамические импорты через import() в ES-модулях. Несмотря на то, что import() является частью стандарта, его поведение принципиально отличается от статического import.

Динамический импорт:

import('./module.js').then(m => {
  m.run();
});

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

Основные последствия:

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

Особенно проблематичны случаи, когда путь в import() вычисляется динамически:

import(`./locale/${lang}.js`);

Такие конструкции превращают граф зависимостей в частично неопределённый, где Rollup может лишь предполагать возможные варианты, но не гарантировать полноту анализа.

Взаимодействие CommonJS и динамического импорта

Наиболее сложные случаи возникают при комбинации CommonJS и динамических импортов. Например, когда CommonJS модуль возвращает функцию, которая внутри вызывает import():

module.exports = function load() {
  return import('./heavy-module.js');
};

Здесь возникает двойная неопределённость:

  • CommonJS скрывает структуру экспортов;
  • динамический import скрывает структуру зависимостей.

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

Ограничения преобразования CommonJS в ES-модули

Преобразование CommonJS в ES-модули в Rollup не является изоморфным. Это всегда приближённая модель, зависящая от структуры кода.

Основные ограничения:

  • невозможность корректной обработки module.exports = function
  • сложность с exports.foo = ... при динамическом добавлении свойств
  • неоднозначность при смешении default export и named exports
  • потеря семантики при ленивых вычислениях через функции

Пример проблемного случая:

Object.defineProperty(exports, 'value', {
  get() {
    return computeExpensiveValue();
  }
});

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

Побочные эффекты и влияние на оптимизацию

Дополнительная сложность возникает из-за побочных эффектов. CommonJS модули часто предполагают выполнение кода при загрузке:

console.log('module loaded');
global.state = true;

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

В результате:

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

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

Проблемы дедукции экспортов

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

for (const key of Object.keys(someObject)) {
  exports[key] = someObject[key];
}

Такой код полностью разрушает статическую модель экспортов. Плагин CommonJS вынужден либо:

  • предположить, что экспортируется всё;
  • либо пропустить модуль без оптимизации.

Оба варианта ухудшают эффективность tree-shaking.

Динамические require как анти-паттерн для бандлинга

Наиболее проблемный паттерн:

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

Он делает невозможным:

  • предсказание списка файлов;
  • построение графа зависимостей;
  • разделение чанков;
  • анализ dead code.

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

Итоговая природа проблемы

Конфликт CommonJS и Rollup является не технической недоработкой, а следствием различий в философии модульности:

  • CommonJS ориентирован на runtime-резолюцию зависимостей;
  • Rollup требует compile-time полной информации о графе;
  • динамические импорты нарушают статическую целостность графа;
  • гибкость исполнения напрямую снижает возможности оптимизации.

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