Почему часть кода не удаляется: типичные причины

При работе с esbuild механизм удаления неиспользуемого кода (tree shaking) основан на статическом анализе модулей ECMAScript. Поведение оптимизатора определяется не только фактом отсутствия импортов, но и множеством косвенных факторов: наличием побочных эффектов, формой экспорта, типом модульной системы, а также структурой зависимостей.

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

Официальное описание поведения и ограничений сборщика отражено в документации esbuild documentation.


Побочные эффекты как основной фактор сохранения кода

Самая частая причина, по которой код не удаляется, — наличие потенциальных побочных эффектов (side effects).

Фрагмент считается «опасным для удаления», если он может изменить состояние среды вне текущего модуля:

  • изменение глобальных объектов (window, globalThis)
  • регистрация обработчиков событий
  • модификация прототипов
  • выполнение кода на верхнем уровне модуля

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

Пример:

// module.js
window.analytics = window.analytics || [];
window.analytics.push("init");
export const unused = 42;

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


Поле sideEffects в package.json

Система упаковки ориентируется на поле sideEffects в package.json. Оно управляет тем, можно ли безопасно удалять неиспользуемые файлы целиком.

{
  "sideEffects": false
}

При значении false предполагается, что все модули «чистые», и esbuild может агрессивно удалять неиспользуемые экспорты и даже целые файлы.

Если поле отсутствует или установлено в true, сборщик вынужден считать каждый файл потенциально значимым.

Особая форма:

{
  "sideEffects": ["*.css", "*.scss"]
}

В этом случае удаление кода ограничивается, и стили всегда сохраняются.


Различие ESM и CommonJS

Tree shaking в esbuild максимально эффективен при использовании ES Modules:

export const a = 1;
export const b = 2;

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

Однако в CommonJS ситуация иная:

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

Здесь экспорт представляет собой динамический объект, и статически определить неиспользуемые части сложнее. В результате esbuild часто сохраняет больше кода, чем необходимо.

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

  • require() с переменными аргументами
  • условные экспорты
  • мутация module.exports после объявления

Динамические импорты и неопределяемые зависимости

Любая динамика в структуре импорта блокирует удаление кода:

const mod = await import(someVariable);

Поскольку значение someVariable неизвестно на этапе сборки, esbuild обязан сохранить весь потенциально доступный код.

Аналогично:

require(path.join(base, name));

Такие конструкции полностью отключают точечную оптимизацию зависимостей.


Реэкспорты и цепочки модулей

Реэкспорт увеличивает сложность анализа:

export { a } from "./moduleA";
export { b } from "./moduleB";

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

Особенно проблемны цепочки:

index.js → re-export.js → core.js → utils.js

Любая неоднозначность в середине цепочки приводит к сохранению лишнего кода.


Верхнеуровневое выполнение кода

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

const start = Date.now();
console.log("module loaded");

Даже если переменная start не используется, сама операция console.log является побочным эффектом.

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


Аннотация /* @**PURE** */ и ручное управление удалением

Для функций и вызовов без побочных эффектов используется специальная аннотация:

const x = /*#__PURE__*/ createConfig();

Она сообщает оптимизатору, что вызов может быть удалён, если результат не используется.

Без этой аннотации функция рассматривается как потенциально опасная, особенно если она импортирована из внешнего модуля.


Экспорт, используемый косвенно

Удаление может не происходить, если экспорт используется неявно:

export const config = {
  value: compute()
};

Если compute() имеет побочные эффекты, весь объект сохраняется, даже если config не импортируется.

Также косвенные ссылки:

globalThis.config = config;

Такие конструкции полностью исключают возможность tree shaking.


Влияние режима minify и bundle

Оптимизация удаления кода зависит от включённых режимов:

  • bundle: объединяет модули, но не всегда агрессивно удаляет код
  • minify: усиливает удаление, но ограничен сохранением семантики

Пример:

esbuild input.js --bundle --minify

Даже в этом режиме код с побочными эффектами сохраняется.


Внешние модули (external)

Если модуль объявлен как external:

external: ["react"]

esbuild не анализирует его содержимое. Всё, что зависит от него, может быть сохранено в исходном виде, без оптимизации.


Ограничения статического анализа

Несмотря на высокую скорость, esbuild не выполняет глубокую интерпретацию кода. Это приводит к сохранению фрагментов в следующих случаях:

  • сложные условия с побочными эффектами
  • использование прокси и динамических объектов
  • запутанные цепочки импортов
  • смешение ESM и CJS в одном проекте

Любая неопределённость трактуется в пользу сохранения кода.


Условные конструкции и невозможность доказательства удаления

if (process.env.NODE_ENV !== "production") {
  debugLog();
}

Даже если сборка выполняется в production-режиме, без явной подстановки define, esbuild может сохранить ветку.

Оптимизация становится возможной только при явной фиксации:

--define:process.env.NODE_ENV='"production"'

Косвенное влияние глобального состояния

Любое обращение к глобальным объектам усложняет удаление:

window.myLib = factory();

или

globalThis.state = init();

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


Заключительные наблюдения по поведению оптимизации

Причины сохранения кода в esbuild почти всегда сводятся к одной из категорий:

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

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