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

Чтобы в финальную сборку попадал только тот код, который реально используется, придумали tree shaking — буквально «встряхивание дерева». Представь зависимости приложения как дерево: ветки, за которые никто не держится, при встряске опадают. Разберёмся, как сборщик понимает, какие ветки мёртвые, почему это получается не всегда и что сделать, чтобы получалось чаще.

Что такое tree shaking и почему он вообще возможен

Tree shaking — это модное имя для давно известного приёма: удаления мёртвого кода (dead-code elimination). Сборщик проходит по всем импортам и выбрасывает из итогового файла то, что нигде не вызывается и ни на что не влияет.

Ключевое слово здесь — статический анализ. Сборщик (webpack, Rollup, esbuild) не запускает твой код. Он читает его как текст и по тексту строит граф: какой модуль что экспортирует и кто это потом импортирует. Если до конкретного экспорта по графу не дотянуться — он помечается как недостижимый и удаляется на этапе минификации.

Отсюда сразу следует главное ограничение: чтобы построить такой граф, импорты и экспорты должны быть предсказуемы на момент сборки — без условий, без вычисляемых имён, без «подгружу, что попросят в рантайме». Именно поэтому встряска тесно связана с тем, в каком формате модулей написан код.

Почему ES-модули трясутся, а CommonJS — нет

В JavaScript исторически уживаются две системы модулей. ES-модули (ESM) со словами import и export — статические по дизайну. Имена экспортов и путь импорта фиксированы и стоят на верхнем уровне файла; их нельзя задать выражением. Сборщику этого достаточно, чтобы точно сказать, что используется, а что нет. Заодно ES-модули по умолчанию работают в строгом режиме, что тоже убирает часть неоднозначностей.

// ES-модуль: имена известны на этапе сборки
export function formatPrice(value) { /* ... */ }
export function formatDate(value) { /* ... */ }

// в другом файле берём только одно
import { formatPrice } from "./format.js";
// formatDate никем не импортирован — уедет в мусор

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

// CommonJS: какой модуль загрузится — решается в рантайме
let parser;
if (useFastPath) {
  parser = require("./fast-parser");
} else {
  parser = require("./safe-parser");
}

// и даже ключ экспорта можно вычислить
module.exports[["fmt", "Price"].join("")] = formatPrice;

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

Что делать со старыми CommonJS-библиотеками

В реальном проекте часть зависимостей всё равно окажется в CommonJS — не всё успело перейти на ESM. Встряска их не разберёт, но размер всё равно можно сбить, импортируя не пакет целиком, а конкретную точку входа.

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

// плохо: импорт «корня» затащит весь пакет
import { sortBy, capitalize } from "lodash";

// хорошо: каждый хелпер — отдельный модуль-файл
import sortBy from "lodash/sortBy";
import capitalize from "lodash/capitalize";

Ещё лучше — поискать у библиотеки сборку в формате ESM: их часто публикуют отдельным пакетом или в подпапке (у того же lodash это lodash-es). С ESM-сборкой обычный именованный импорт уже трясётся сам, и подбирать пути вручную не нужно.

И отдельная ловушка, на которую напарываются почти все, — barrel-файлы: «бочки» вида index.js, которые ре-экспортируют всё подряд из соседних модулей.

// components/index.js — удобно импортировать, но опасно
export * from "./Button";
export * from "./Modal";
export * from "./Calendar"; // тянет тяжёлый календарь...

// где-то в коде
import { Button } from "./components"; // ...даже если нужна только кнопка

Хорошо написанный сборщик такую бочку протрясёт, но любой побочный эффект внутри ре-экспортируемого модуля (о них — ниже) ломает анализ, и в бандл уезжает всё, что перечислено в index.js. Поэтому в местах, где размер критичен, импортируй из конкретного файла, а не из общей бочки.

Побочные эффекты ломают встряску

Даже на чистом ESM сборщик иногда оставляет, казалось бы, неиспользуемый код. Причина — побочные эффекты (side effects): действия, которые модуль выполняет в момент импорта, а не в ответ на вызов конкретного экспорта.

// 1) дописываем метод в глобальный объект — полифилл
if (!Array.prototype.at) {
  Array.prototype.at = function (i) { /* ... */ };
}

// 2) пишем в window
window.__APP_VERSION__ = "1.0";

// 3) сам факт импорта что-то регистрирует
import { register } from "heavy-plugin";
register("analytics", config);

Удалить такой модуль за «ненадобностью» нельзя: даже если ни один его экспорт не используется, при импорте он успел поменять что-то снаружи. Сборщик не умеет надёжно отличить безобидный код от важного, поэтому на всякий случай оставляет всё.

Чтобы развязать ему руки, в package.json объявляют, что в пакете побочных эффектов нет:

{
  "name": "my-lib",
  "sideEffects": false
}

Это обещание сборщику: «любой мой модуль можно выкинуть, если его экспорты не используются». Если эффекты всё-таки есть, но точечные, перечисляют конкретные файлы — остальное остаётся трясущимся:

{
  "sideEffects": [
    "./src/polyfills.js",
    "*.css"
  ]
}

Строка *.css здесь — не формальность, а частый источник пропавших стилей. Импорт вида import "./button.css" — это ровно побочный эффект: ни одного именованного экспорта у него нет, ценность — в самом факте подключения. Объявишь у пакета "sideEffects": false, забыв про стили, — и сборщик честно вырежет CSS-импорты как мёртвый код, а компонент приедет без оформления. Поэтому файлы стилей почти всегда добавляют в список исключений.

Как включить встряску и проверить результат

В webpack tree shaking не отдельная кнопка, а следствие продакшен-режима. В этом режиме сборщик и помечает неиспользуемые экспорты (usedExports), и подключает минификатор, который физически выбрасывает помеченное.

// webpack.config.js
module.exports = {
  mode: "production", // включает оптимизации, включая удаление мёртвого кода
};

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

Дальше полезно не верить на слово, а посмотреть, из чего реально состоит бандл. Несколько инструментов на каждый день:

  • webpack-bundle-analyzer — рисует интерактивную карту бандла: какой модуль сколько весит, что затесалось внутрь и что стоит вынести в ленивую подгрузку.
  • Bundlephobia — до установки пакета показывает его вес, трясётся ли он и есть ли у него побочные эффекты; заодно подсказывает более лёгкие альтернативы.
  • Расширение Import Cost для редактора — дописывает прямо у строки импорта, сколько килобайт он добавит, так что цену зависимости видно ещё в момент её подключения.

карта бандла в webpack-bundle-analyzer

Когда об этом стоит думать, а когда — нет

Tree shaking почти всегда работает молча: пишешь код на ES-модулях, собираешь в продакшен-режиме — и мёртвый код уходит сам. Специально оптимизировать тут нечего, пока бандл не начал распухать.

Повод присмотреться появляется, когда совпадает несколько вещей: приложение большое (полноценная SPA, а не лендинг), в зависимостях есть тяжёлые библиотеки, и метрики загрузки на мобильных проседают. Тогда имеет смысл открыть анализатор бандла, найти самые жирные модули и проверить три вещи: формат модулей (ESM или CommonJS), честность поля sideEffects у своих пакетов и нет ли импортов через тяжёлые barrel-файлы. Для маленького статического сайта эта возня обычно не окупается — выигрыш в пару килобайт не стоит потраченного времени.

Итог

Tree shaking держится на одном условии: сборщик должен суметь предсказать связи модулей, не запуская код. Поэтому всё, что повышает предсказуемость — ES-модули вместо CommonJS, импорт конкретных функций вместо целых пакетов, честно объявленные побочные эффекты — помогает встряске, а всё динамическое и неявное ей мешает. Большую часть работы сборщик сделает сам; от тебя нужно лишь не ставить ему палки в колёса и иногда заглядывать в анализатор бандла, чтобы убедиться, что дерево действительно облетело.