Почему tree-shaking требует ES-модулей

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

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

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

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

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

Почему CommonJS мешает tree-shaking

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

Основные проблемы:

Динамический характер require

const moduleName = getName();
const lib = require(moduleName);

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

Изменяемые экспорты

module.exports.value = 1;
if (condition) {
  module.exports.value = 2;
}

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

Экспорт как объект

CommonJS экспортирует объект, который можно мутировать в любой момент:

module.exports = {};
module.exports.a = 1;
delete module.exports.a;

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

Как Rollup анализирует ES-модули

Rollup строит граф зависимостей на основе следующих принципов:

1. Парсинг без выполнения кода

Rollup использует AST (Abstract Syntax Tree), анализируя структуру файла:

  • обнаруживает import/export
  • фиксирует связи между модулями
  • не запускает код

Это позволяет точно определить, какие узлы графа связаны.

2. Замкнутый граф зависимостей

Каждый модуль рассматривается как узел графа, а импорты — как рёбра:

A → B → C
  → D

Если из модуля A используется только часть API модуля B, Rollup может проследить цепочку до конкретного экспортируемого символа.

3. Удаление неиспользуемых экспортов

Если экспорт не используется ни в одном из downstream-модулей, он считается “dead code” и удаляется.

Пример:

// utils.js
export function used() {}
export function unused() {}
import { used } from './utils.js';

В итоговом бандле функция unused будет исключена.

Проблема «нечистых» модулей

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

// side-effect.js
console.log('module loaded');

export const value = 1;

Даже если value не используется, console.log создаёт побочный эффект, и модуль не может быть безопасно удалён.

ESM позволяет точнее контролировать такие ситуации через поле sideEffects в package.json:

{
  "sideEffects": false
}

Это сигнализирует сборщику, что модуль не имеет побочных эффектов и может быть безопасно сокращён.

Live bindings и их значение

ES-модули используют концепцию live bindings, которая играет важную роль в tree-shaking.

// counter.js
export let count = 0;
export function inc() {
  count++;
}
import { count, inc } from './counter.js';

inc();
console.log(count);

Значение count не копируется, а остаётся связанным с оригинальным модулем. Это означает:

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

В CommonJS такой модели нет — экспортируемые значения копируются, что делает оптимизацию небезопасной.

Динамические особенности ESM, ограничивающие tree-shaking

Несмотря на статическую природу ES-модулей, некоторые конструкции усложняют анализ:

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

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

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

Re-export цепочки

export { a } from './a.js';

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

Namespace imports

import * as utils from './utils.js';

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

utils.a();

Tree-shaking становится менее точным, если доступ к модулю происходит через namespace без явного указания символов.

Почему Rollup требует ESM для полной оптимизации

Ключевая причина заключается в том, что tree-shaking основан на статическом анализе графа зависимостей, а не на выполнении кода.

ES-модули обеспечивают:

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

CommonJS, напротив, представляет собой runtime-систему, где:

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

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

Влияние транспиляции на tree-shaking

Использование Babel или TypeScript может негативно влиять на tree-shaking, если они преобразуют ESM в CommonJS:

// исходный код
export function a() {}

После транспиляции:

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

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

Поэтому важным условием эффективного tree-shaking является сохранение ESM-формата вплоть до этапа сборки.

Роль package.json и поля module

Для корректной работы tree-shaking важно, чтобы пакет явно указывал ESM-версию:

{
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js"
}

Поле module позволяет сборщикам, включая Rollup, использовать версию с ES-модулями, что обеспечивает максимальную оптимизацию.

Итоговая логика зависимости от ESM

Tree-shaking в Rollup возможен только при соблюдении трёх условий:

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

ES-модули удовлетворяют этим требованиям полностью, тогда как CommonJS — нет, что делает ESM фундаментальной основой всей системы tree-shaking в Rollup.