Tree shaking на практике: что реально попадает в бандл
Tree shaking в 2026 году: замерил 22 импорта из lodash, date-fns, MUI, antd, Element Plus и других в esbuild, Rollup, Vite 8 и webpack. Где один импорт весит 1 КБ, где 300 КБ и какие строки в коде библиотеки ломают tree shaking.
Tree shaking – удаление неиспользуемого кода при сборке – работает, но не так, как обещают документации библиотек. Я собрал 22 импорта из популярных пакетов четырьмя сборщиками: esbuild 0.28, Rollup 4.66, Vite 8.3 на Rolldown и webpack 5.112. import { debounce } from 'lodash' во всех четырёх стоит около 25 КБ gzip, а import { ElButton } from 'element-plus' – 10 КБ в Vite 8 и webpack, но 272 КБ в Rollup и 321 КБ в esbuild, потому что в индексном файле библиотеки есть четыре строки собственного кода. Импорт по пути в MUI на размер продакшен-бандла не влияет вообще. Ниже таблицы, механизм каждого случая и способ проверить свой проект.
Как я мерил
Для каждого случая – отдельный файл с одним импортом. Чтобы сборщик не выкинул импорт как неиспользуемый, значение присваивается глобальной переменной:
import { debounce } from 'lodash-es'
window.x = debounceУсловия эксперимента
- Node.js 22.23.4, сборка в режиме production с
process.env.NODE_ENV = "production", формат ESM. - esbuild 0.28.2:
bundle,minify, платформаbrowser. - Rollup 4.66.0:
@rollup/plugin-node-resolve16 с полемbrowser,@rollup/plugin-commonjs29, минификация@rollup/plugin-terser1.0.2 (terser 5.51). - Vite 8.3.5: обычная
vite buildс настройками по умолчанию, то есть Rolldown 1.2.16 и минификатор Oxc. - webpack 5.112.2:
mode: 'production', минификация terser-webpack-plugin по умолчанию. - React 19.3.1 и Vue 3.5.44 вынесены во внешние зависимости во всех сборщиках: в таблицах только код самих библиотек.
- Размер – сумма всех JS-файлов результата; gzip уровня 9 средствами
zlibв Node.js. Сборка детерминирована, повторные прогоны дают те же байты. - Результат – это работа всей цепочки «сборщик плюс его минификатор по умолчанию». Кто именно удалил код, сборщик или минификатор, я отдельно не разделял.
Почему четыре сборщика. Vite 7 и более ранние версии собирали продакшен через Rollup, а в Vite 8, вышедшем 12 марта 2026 года, Rollup и esbuild заменил Rolldown. Nuxt перешёл на Vite 8 в версии 4.5. esbuild нужен как самостоятельный сборщик, на котором построено много инструментов, webpack – как сборщик огромного числа существующих проектов. Turbopack из Next.js я не мерил.
Сколько весит один импорт: утилиты
Размеры в КБ после gzip. Минифицированный объём без сжатия обычно в 2,5–4 раза больше.
Импорт | esbuild | Rollup | Vite 8 | webpack |
|---|---|---|---|---|
| 26,2 | 24,6 | 25,1 | 24,6 |
| 1,6 | 1,3 | 1,4 | 1,4 |
| 1,4 | 1,0 | 1,0 | 1,0 |
lodash-es целиком | 35,1 | 28,4 | 30,8 | 30,1 |
| 0,3 | 0,2 | 0,2 | 0,2 |
| 5,6 | 5,4 | 5,4 | 5,3 |
date-fns целиком | 18,0 | 16,9 | 17,5 | 17,2 |
| 3,3 | 3,0 | 3,2 | 3,1 |
| 20,3 | 19,8 | 19,7 | 77,6 |
| 0,8 | 0,5 | 0,5 | 0,6 |
| 2,5 | 2,1 | 2,3 | 2,2 |
Версии: lodash и lodash-es 4.18.1, date-fns 4.4.0, dayjs 1.11.23, moment 2.31.0, @vueuse/core 15.0.0, rxjs 7.8.2.
Что видно:
- lodash в виде CommonJS не вытряхивается ни одним сборщиком: одна функция стоит больше трёх четвертей lodash-es целиком.
lodash-esи импорт по путиlodash/debounceдают одинаковый результат. - date-fns вытряхивается почти идеально, но
formatтяжелееaddDaysбольше чем в 20 раз: он тянет за собой английскую локаль по умолчанию и разбор токенов формата. Вся date-fns в одном бандле – 17–18 КБ. - dayjs не нуждается в tree shaking, это одна небольшая функция. README обещает около 2 КБ, по моему замеру с версией 1.11.23 выходит 3,0–3,3 КБ gzip.
- moment в webpack весит в четыре раза больше, чем в остальных: моменту нужны локали, и он подгружает их динамическим
require('./locale/' + name). Документация webpack прямо приводит moment как пример: с версии 2.18 все локали попадают в сборку вместе с ядром, и отрезать их предлагается черезIgnorePlugin. Остальные три сборщика такой динамический путь в бандл не включили.
Сколько весит один импорт: иконки и UI-киты
Импорт | esbuild | Rollup | Vite 8 | webpack |
|---|---|---|---|---|
| 1,9 | 1,7 | 1,6 | 1,6 |
lucide-react целиком | 206,7 | 187,7 | 197,0 | 177,5 |
| 1,4 | 1,3 | 1,3 | 1,3 |
| 29,9 | 26,4 | 26,6 | 26,1 |
| 29,8 | 26,4 | 26,6 | 26,1 |
| 37,5 | 34,6 | 34,8 | 34,4 |
| 37,6 | 34,8 | 35,0 | 34,4 |
| 56,4 | 36,4 | 38,3 | 37,8 |
| 321,2 | 271,7 | 9,7 | 9,8 |
| 14,6 | 9,7 | 9,7 | 9,8 |
| 19,7 | 17,6 | 18,2 | 17,6 |
Версии: lucide-react 1.64.0, react-icons 5.7.0, @mui/material и @mui/icons-material 9.5.0, antd 6.6.8, element-plus 2.14.7, naive-ui 2.45.3. CSS библиотек в замер не входит.
| импорт из корня пакета | импорт по пути к компоненту | |
|---|---|---|
| esbuild 0.28 | 321,2 КБ | 14,6 КБ |
| Rollup 4.66 | 271,7 КБ | 9,7 КБ |
| Vite 8 (Rolldown) | 9,7 КБ | 9,7 КБ |
| webpack 5.112 | 9,8 КБ | 9,8 КБ |
Что видно:
- Иконки по одной стоят 1–2 КБ. Целая lucide-react – около 190 КБ gzip. Для масштаба: весь начальный JS главной страницы этого сайта – 172 КБ gzip. Ошибка вида
import * as iconsстоит больше, чем всё остальное приложение. - Первая иконка MUI стоит 26 КБ, потому что вместе с ней приходит база:
SvgIcon, система стилей и Emotion. Вторая иконка добавила к бандлу меньше 0,1 КБ gzip. База общая и для компонентов: иконка вместе с Button весит 35,1–35,7 КБ, то есть меньше чем на 1 КБ больше одной кнопки. - Импорт по пути в MUI на продакшен-размер не влияет. Разница между
{ Delete }из корня и/Delete– в пределах 0,2 КБ в любом сборщике. Это совпадает с актуальным руководством MUI для v9: в продакшене современные сборщики и так вытряхивают лишнее, а импорт по пути нужен ради скорости разработки (об этом ниже). - antd в esbuild почти на 20 КБ тяжелее, чем в остальных трёх. Если проект на Ant Design, как ERP для сети кофеен из моих кейсов, выбор сборщика сам по себе меняет размер на треть.
- Element Plus – самый сильный разброс в эксперименте: в 33 раза между сборщиками. Документация Element Plus говорит, что ручной импорт вида
import { ElButton } from 'element-plus'поддерживает tree shaking, потому что библиотека использует ES-модули. В Vite 8 и webpack это правда, в Rollup и esbuild – нет. Ниже разбор, почему.
Почему tree shaking не срабатывает
Сборщик может удалить код только тогда, когда уверен в двух вещах: результат кода никто не использует, и сам код при выполнении ничего не меняет снаружи. Если хотя бы в одном он не уверен, код остаётся. Почти все провалы в таблицах выше сводятся к одной из четырёх причин.
1. CommonJS вместо ES-модулей
У ES-модулей import и export статичны: по тексту файла видно, что он экспортирует и что из него взяли. В CommonJS экспорт – это обычное присваивание свойств объекта module.exports, которое может зависеть от условий во время выполнения. Документация esbuild прямо пишет, что tree shaking работает с ES-модулями, а с CommonJS – нет.
webpack с пятой версии умеет вытряхивать часть CommonJS: в анонсе webpack 5 перечислены поддерживаемые конструкции вроде exports.xxx = ... и Object.defineProperty(exports, "xxx", ...). Если модуль написан иначе, webpack отказывается от анализа. lodash как раз написан иначе: весь пакет – одна большая функция-обёртка, поэтому { debounce } из него ни в одном сборщике не вытряхивается.
2. Код на верхнем уровне модуля
Любой вызов функции на верхнем уровне – const table = buildTable() – сборщик должен либо доказать безвредным, либо сохранить. Доказывать сборщики умеют по-разному, и это хорошо видно на синтетическом тесте.
Я сделал маленькую библиотеку из индексного файла с двумя реэкспортами: small используется, модуль heavy нет. В heavy по очереди помещал разные конструкции и проверял, остался ли он в бандле.
Что в неиспользуемом модуле | esbuild | Rollup | Vite 8 | webpack |
|---|---|---|---|---|
только объявление функции | удалён | удалён | удалён | удалён |
вызов функции на верхнем уровне | остался | удалён | остался | остался |
то же + | удалён | удалён | удалён | удалён |
то же + | удалён | удалён | удалён | удалён |
| остался | удалён | удалён | удалён |
| остался | удалён | остался | остался |
| удалён | удалён | удалён | удалён |
| остался | удалён | остался | удалён |
CommonJS ( | остался | остался | остался | удалён |
Функция buildTable в тесте заполняет локальный массив и возвращает его. Rollup, судя по результату, заглядывает в тело функции и видит, что она не трогает ничего снаружи. Остальные считают такой вызов потенциально опасным. Rolldown в документации описывает тот же подход: при сомнении код сохраняется.
Присваивание свойства компоненту (Comp.Item = Item, Comp.displayName = ...) – типичный приём UI-библиотек для составных компонентов вроде Form.Item. esbuild такое присваивание не удаляет, и именно это объясняет лишние 20 КБ antd: цепочка antd/es/index.js → config-provider/index.js → tooltip/UniqueProvider → @rc-component/trigger. В config-provider/index.js на верхнем уровне стоят присваивания ConfigProvider.ConfigContext = ... и вызов Object.defineProperty(ConfigProvider, ...), поэтому esbuild сохраняет весь ConfigProvider и всё, что тот импортирует. Rollup эту цепочку отбрасывает.
3. Флаг sideEffects в package.json: ускоритель и мина
Поле "sideEffects" в package.json – обещание автора пакета: модули не делают ничего при импорте, кроме объявления экспортов. Документация webpack называет его более эффективной оптимизацией, чем обычный tree shaking: сборщик может пропустить модуль целиком вместе с его зависимостями, не разбирая код. Значение false относится ко всем файлам пакета, массив путей перечисляет файлы, которые трогать нельзя, обычно CSS. По документации Rolldown логика та же.
В тесте флаг спасает все случаи с неиспользуемым модулем. Обратная сторона – последняя строка эксперимента: модуль-полифил, который дописывает метод в String.prototype, с флагом "sideEffects": false удалили все четыре сборщика, без предупреждений. Документация webpack предупреждает о том же: полифилы, глобальные обработчики, регистрация компонентов и импортированный CSS при неверном флаге молча пропадают, а в development-сборке это не всегда видно. Флаг стоит ставить только на пакет, в котором нет ни одного такого модуля, или перечислять такие модули явно.
4. Индексный файл с собственным кодом
Barrel-файл, или «бочка», – индексный модуль, который реэкспортирует всё из папки или пакета: export * from './button'. Пока в нём только реэкспорты, сборщик с флагом sideEffects может его «перепрыгнуть»: webpack в документации так и описывает, что модуль без побочных эффектов, из которого берут только реэкспорты, пропускается, а анализ продолжается сразу в исходных модулях.
Element Plus показывает, что бывает, если в бочке есть что-то ещё. В конце element-plus/es/index.mjs стоят четыре строки: импорт объекта-установщика из defaults.mjs и три строки, которые читают его свойства install и version. Сам установщик создаётся вызовом makeInstaller([...все компоненты, ...все плагины]), а каждый компонент на верхнем уровне обёрнут в withInstall(...) без пометки /*#__PURE__*/.
Я скопировал пакет и удалил из индексного файла только эти четыре строки. Результат для ElButton:
Вариант | esbuild | Rollup | Vite 8 | webpack |
|---|---|---|---|---|
Element Plus как есть | 321,2 | 271,7 | 9,7 | 9,8 |
без четырёх строк в | 18,0 | 12,5 | 9,7 | 9,8 |
Судя по документации, Rolldown и webpack перепрыгивают индексный файл благодаря sideEffects и до этих строк не доходят. esbuild и Rollup включают его собственный код, через установщик доходят до массива всех компонентов и тащат в бандл всю библиотеку. Точную причину внутри esbuild и Rollup я до конца не локализовал: на упрощённой копии этой схемы оба сборщика код удаляют, значит, срабатывает комбинация с чем-то ещё в пакете. Но практический вывод от этого не меняется: несколько строк кода в индексном файле стоят до 300 КБ.
Barrel-файлы в dev-режиме: проблема не в бандле
Обсуждение barrel-файлов в англоязычной выдаче чаще всего не про размер бандла, а про скорость разработки. В dev-режиме tree shaking не работает: dev-сервер загружает модули как есть, и импорт одной иконки из корня пакета означает загрузку всех модулей, которые реэкспортирует бочка.
- Vercel при добавлении
optimizePackageImportsв Next.js 13.5 измерил, что импорт из@material-ui/iconsв dev загружал 11 738 модулей за 10,2 с, а после переписывания импортов – 632 модуля за 2,9 с. Дляlucide-react: 1583 модуля и 5,8 с против 333 модулей и 3 с. - Доминик, автор блога TkDodo, описывает проект на Next.js, где страницы загружали больше 11 000 модулей и стартовали 5–10 секунд. После удаления внутренних бочек осталось около 3500 модулей.
- Руководство MUI предупреждает, что именованные импорты из
@mui/icons-materialв разработке могут быть до шести раз медленнее импорта по пути.
Так что импорт по пути в MUI полезен, но ради dev-сервера и тестов. В продакшен-бандле, по моему замеру, разницы нет.
Как проверить свой проект
- Посмотрите, из чего состоит бандл. Для Vite, Rollup и Rolldown (последний экспериментально) подходит vite-bundle-analyzer, для Vite 8 есть анализ сборки в Vite DevTools, для webpack – webpack-bundle-analyzer. Ищите пакеты, которые представлены целиком, хотя вы импортируете из них пару функций.
- Изолируйте подозрительный импорт. Создайте файл с одним импортом и
window.x = ..., как в моём эксперименте, и соберите его тем же сборщиком и с теми же настройками, что и проект. Сравните с импортом по пути или из ESM-версии пакета. - Загляните в package.json библиотеки. Есть ли ESM-версия (
moduleили условиеimportвexports) и полеsideEffects. Без ESM tree shaking не будет, безsideEffectsсборщику придётся доказывать чистоту каждого модуля самому. - Найдите цепочку импортов до лишнего модуля. esbuild с опцией
metafileпишет для каждого файла список его импортов, по нему легко найти путь от точки входа до тяжёлого модуля. Откройте каждый файл на этом пути и ищите код верхнего уровня, кромеimportиexport: вызовы, присваивания свойств,Object.defineProperty.
Чем лечить, по убыванию простоты:
- импорт по пути к модулю (
lodash/debounce,element-plus/es/components/button/index.mjs) или ESM-версия пакета (lodash-es); - автоимпорт, который рекомендует сама библиотека: для Element Plus это
unplugin-vue-componentsс резолвером; - в Next.js –
optimizePackageImportsдля пакетов, которых нет в списке по умолчанию; - замена библиотеки: moment на dayjs или date-fns экономит 17 КБ даже без учёта локалей;
- переход на другой сборщик или его новую версию. Vite 8 собирает через Rolldown вместо Rollup, и в моём замере для Element Plus это разница между 272 и 10 КБ.
Коротко
- Tree shaking работает только для ES-модулей. lodash из CommonJS стоит 25 КБ gzip за одну функцию в любом сборщике,
lodash-es– 1 КБ. - Один и тот же импорт в разных сборщиках может отличаться в десятки раз:
ElButtonиз Element Plus – 10 КБ в Vite 8 и webpack против 272–321 КБ в Rollup и esbuild. - Неиспользуемый модуль остаётся в бандле из-за любого кода на верхнем уровне, который сборщик не смог доказать безвредным: вызовов,
Comp.Item = ...,Object.defineProperty. Лечится/*#__PURE__*/и полемsideEffects. "sideEffects": falseбез разбора опасен: полифилы, CSS и регистрация компонентов удаляются молча.- Импорт по пути в MUI не уменьшает продакшен-бандл, но ускоряет dev-сервер. Barrel-файлы бьют прежде всего по скорости разработки.
- Проверяйте не по документации библиотеки, а изолированной сборкой одного импорта вашим сборщиком.
Источники
- 01Tree Shakingwebpack
- 02Webpack 5 releasewebpack · 2020
- 03Dead Code EliminationRolldown
- 04Vite 8.0 is out!vite.dev · 2026
- 05API: Platform (tree shaking и CommonJS)esbuild
- 06Minimizing bundle sizeMUIдля Material UI v9
- 07Quick StartElement Plus
- 08How we optimized package imports in Next.jsVercel · 2023
- 09Please Stop Using Barrel FilesTkDodo · tkdodo.eu · 2024
- 10Nuxt 4.5Nuxt
- 11IgnorePluginwebpack