Tree shaking и CommonJS: известные ограничения

Tree shaking основан на идее удаления кода, который не используется в конечной сборке. В современных бандлерах эта техника опирается на статическую структуру модулей, прежде всего на ESM (ECMAScript Modules), где зависимости и экспортируемые сущности известны до выполнения кода.

В контексте esbuild tree shaking реализуется как часть этапа бандлинга и опирается на граф модулей, построенный из import/export. Важное свойство ESM — возможность анализировать зависимости без исполнения кода, что делает возможным удаление неиспользуемых экспортов.

Базовый принцип tree shaking

Для корректного удаления кода необходимы три условия:

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

Если хотя бы одно из условий нарушается, безопасность удаления кода становится недоказуемой, и бандлер вынужден сохранять больший объём кода.


Почему CommonJS ломает модель tree shaking

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

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

В CommonJS допустимы конструкции:

const mod = require(condition ? "./a" : "./b");

или:

const name = "utils";
const mod = require("./" + name);

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

Следствие: бандлер не может построить точный граф зависимостей.


Перезапись module.exports

В CommonJS экспорт может изменяться в процессе выполнения:

module.exports = function () {};
module.exports = { a: 1 };

или даже:

exports.a = 1;
delete exports.a;
exports.a = 2;

Такая изменяемость нарушает предсказуемость структуры экспорта. Tree shaking требует неизменяемой экспортной модели.


Побочные эффекты при импорте

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

console.log("module loaded");

globalState.init();

Даже если импортируется только часть API, сам факт подключения модуля запускает выполнение. Это делает невозможным безопасное удаление “неиспользуемых частей” — модуль считается единым побочным эффектом.


ESM как основа эффективного tree shaking

ESM предоставляет:

  • статические import/export;
  • отсутствие динамической замены экспорта;
  • возможность анализа до выполнения.

Пример:

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

Если используется только a, то b может быть безопасно удалена при корректной настройке.


Как esbuild выполняет tree shaking

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

Ключевые этапы:

  1. построение графа импортов;
  2. анализ экспортируемых символов;
  3. пометка используемых сущностей;
  4. удаление неиспользуемых узлов;
  5. генерация финального кода.

Tree shaking в большинстве конфигураций включён по умолчанию и не требует отдельного флага, но его эффективность зависит от формата модулей.


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

1. Отсутствие точного графа экспортов

CommonJS не предоставляет явной структуры экспортов. В результате:

  • экспорт считается “неразделимым” объектом;
  • часто весь модуль помечается как используемый целиком.

2. Динамические свойства exports

Пример:

if (process.env.NODE_ENV === "production") {
  exports.debug = () => {};
}

Статический анализ не может гарантировать выполнение ветки, поэтому возможна утрата точности.


3. Неявные зависимости через require

require("./setup")();

или:

const plugin = require(getPluginName());

Такие конструкции разрушают предсказуемость графа модулей.


4. Barrel-файлы CommonJS

Файлы вида:

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

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


Side effects и поле package.json

Важный механизм, влияющий на удаление кода — поле:

{
  "sideEffects": false
}

Оно сообщает бандлеру, что модули пакета не имеют побочных эффектов при импорте.

При true или отсутствии поля:

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

В CommonJS это особенно критично, так как побочные эффекты встречаются чаще и труднее анализируются.


Interop ESM и CommonJS в esbuild

При смешанном использовании модулей возникает слой совместимости:

  • ESM импортирует CommonJS как namespace;
  • CommonJS получает ESM через синтетический объект.

Пример проблемы:

import * as lib from "cjs-lib";

Если cjs-lib — CommonJS, то lib становится обёрткой над module.exports, а не набором статически анализируемых экспортов.

Это ограничивает возможность удаления неиспользуемых частей.


Особенности обработки namespace import

import * as utils from "./utils";

В ESM это всё ещё анализируемо, но в CommonJS-совместимых модулях:

  • весь объект utils часто сохраняется полностью;
  • отдельные свойства не могут быть удалены, так как они динамические.

Проблема re-export в CommonJS стиле

exports.a = require("./a");
exports.b = require("./b");

или:

Object.assign(exports, require("./a"));

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


Оптимизация через преобразование модулей

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

  • ESM остаётся ESM-совместимым;
  • CommonJS преобразуется в форму, пригодную для анализа;
  • но полная статическая точность не достигается.

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

  • tree shaking эффективен для ESM;
  • ограниченно эффективен для CJS;
  • минимален при динамических require.

Побочные эффекты минификации и dead code elimination

Tree shaking тесно связан с DCE (Dead Code Elimination):

  • удаление недостижимых веток;
  • упрощение выражений;
  • инлайнинг констант.

Однако CommonJS снижает эффективность DCE, поскольку:

  • выполнение кода происходит при импорте;
  • зависимости могут влиять на глобальное состояние;
  • анализ требует консервативного подхода.

Практические ограничения, возникающие в реальных проектах

1. Библиотеки без ESM-сборки

Если пакет публикуется только в CommonJS:

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

2. Непредсказуемые side effects

Модули с инициализацией:

  • регистрация полифилов;
  • изменение глобальных объектов;
  • патчинг прототипов.

Такие модули никогда не считаются безопасными для удаления.


3. Barrel-файлы как антипаттерн

Большие index.js:

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

В ESM это работает лучше, но в CJS-обёртках часто превращается в единый блок без возможности точечного удаления.


Итоговая картина взаимодействия

Tree shaking эффективен только тогда, когда система модулей:

  • статична;
  • предсказуема;
  • не зависит от выполнения кода.

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