Проблемы с CommonJS и Tree Shaking

Tree Shaking в Webpack основан на статическом анализе импортов и экспортов. Его эффективность напрямую зависит от формата модулей. Наиболее критичная проблема возникает при использовании CommonJS, поскольку он изначально не проектировался как статически анализируемая система модулей.

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


Динамическая природа CommonJS и потеря статической структуры

В CommonJS импортирование выполняется через функцию:

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

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

  • условные конструкции
  • циклы
  • функции
  • вычисляемые выражения
if (condition) {
  const mod = require('./module');
}

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


Экспорт как изменяемый объект

В CommonJS экспорт представляет собой обычный объект:

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

или его мутация:

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

Проблема Tree Shaking заключается в том, что объект можно изменять динамически:

module.exports.a = condition ? value1 : value2;

или даже перезаписывать:

module.exports = getExports();

Такая гибкость делает невозможным гарантированное определение «чистого» набора экспортов.


Отсутствие гранулярных экспортов

Tree Shaking эффективно работает, когда есть независимые именованные экспорты:

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

В CommonJS всё упаковано в один объект. Даже если используется только одно свойство, на уровне модуля остаётся зависимость от всего объекта:

const { a } = require('./lib');

На практике Webpack часто вынужден включать весь модуль, поскольку анализ деструктуризации в CommonJS не всегда безопасен. Особенно если объект формируется динамически.


Влияние Babel и транспиляции на Tree Shaking

Даже если исходный код написан в формате ES Modules, транспиляция через Babel может преобразовать его в CommonJS:

export const a = 1;

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

exports.a = 1;

В результате Tree Shaking полностью деградирует, поскольку Webpack теряет возможность использовать ESM-граф зависимостей.

Критический момент — preset @babel/preset-env при неправильной конфигурации modules: "commonjs".


Side Effects и невозможность безопасного удаления кода

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

console.log('module loaded');

module.exports = function() {};

Даже если экспорт не используется, модуль нельзя безопасно удалить, потому что:

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

Webpack учитывает это как потенциальный side effect и оставляет модуль в бандле.


Пакеты с mixed exports и проблема интеропа

Многие npm-пакеты одновременно используют CommonJS и ES Module синтаксис. Это создаёт дополнительные сложности:

  • различия в default export
  • обёртки Webpack (__esModule)
  • синтетические поля экспорта

Пример:

module.exports = require('./lib');
module.exports.default = module.exports;

Webpack вынужден добавлять слой совместимости, что ухудшает возможность точного Tree Shaking.


Barrel-файлы и агрегация экспортов

Частая практика — использование индексных файлов:

export * from './a';
export * from './b';
export * from './c';

В CommonJS эквиваленте:

module.exports = {
  ...require('./a'),
  ...require('./b'),
  ...require('./c')
};

Проблема в том, что агрегация через spread или ручное объединение разрушает связь между конкретным экспортом и его источником. Webpack не может точно удалить неиспользуемые части.


Динамические require и контекстные зависимости

Особенно проблемными являются конструкции:

require(`./modules/${name}`);

или:

const mod = require(paths[i]);

Webpack превращает такие случаи в require.context, что приводит к включению потенциально всех модулей директории. Tree Shaking в таком случае становится неприменимым, поскольку границы зависимости неопределены.


Оптимизация usedExports и ограниченность анализа

Webpack использует флаг:

  • optimization.usedExports

Он позволяет помечать используемые экспорты, но в CommonJS его эффективность ограничена:

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

В результате Webpack часто помечает модуль как «используемый целиком».


Минификация и пост-обработка как компенсация

Даже при невозможности полноценного Tree Shaking часть мёртвого кода может быть удалена на этапе минификации (Terser):

  • удаление неиспользуемых функций внутри модуля
  • устранение unreachable-кода
  • упрощение выражений

Однако это работает только на уровне синтаксического анализа AST и не заменяет модульный Tree Shaking.


Условия, при которых CommonJS частично поддаётся оптимизации

Несмотря на ограничения, Webpack способен выполнять ограниченную оптимизацию, если соблюдаются условия:

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

Пример более «дружелюбного» CommonJS:

exports.a = function a() {};
exports.b = function b() {};

Даже в этом случае анализ остаётся менее точным, чем в ES Modules.


Разница поведения Tree Shaking в ESM и CommonJS

Ключевое различие:

ES Modules:

  • статическая структура
  • декларативные exports/imports
  • возможность точного графа зависимостей

CommonJS:

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

Именно поэтому Tree Shaking в Webpack по своей природе ориентирован на ESM и рассматривает CommonJS как ограниченный или частично непрозрачный источник зависимостей.


Практическое влияние на сборку

При использовании CommonJS в крупных проектах наблюдаются следующие эффекты:

  • увеличение размера бандла из-за включения целых модулей
  • невозможность удаления «неиспользуемых» утилит
  • дублирование кода при агрегации библиотек
  • ухудшение эффективности code splitting
  • рост времени анализа зависимостей

Эти факторы особенно заметны в проектах с большим количеством сторонних npm-пакетов, не переведённых на ESM.