В проект подключили библиотеку дат ради одной функции форматирования, утилитарный пакет ради пары хелперов, иконки — ради трёх штук из тысячи. А в бандл уехало всё целиком. Лишний 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 для редактора — дописывает прямо у строки импорта, сколько килобайт он добавит, так что цену зависимости видно ещё в момент её подключения.

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