Перейти к содержимому

12 минут чтения

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 на размер продакшен-бандла не влияет вообще. Ниже таблицы, механизм каждого случая и способ проверить свой проект.

Как я мерил

Для каждого случая – отдельный файл с одним импортом. Чтобы сборщик не выкинул импорт как неиспользуемый, значение присваивается глобальной переменной:

JavaScript
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-resolve 16 с полем browser, @rollup/plugin-commonjs 29, минификация @rollup/plugin-terser 1.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

import { debounce } from 'lodash'

26,2

24,6

25,1

24,6

import debounce from 'lodash/debounce'

1,6

1,3

1,4

1,4

import { debounce } from 'lodash-es'

1,4

1,0

1,0

1,0

lodash-es целиком

35,1

28,4

30,8

30,1

import { addDays } from 'date-fns'

0,3

0,2

0,2

0,2

import { format } from 'date-fns'

5,6

5,4

5,4

5,3

date-fns целиком

18,0

16,9

17,5

17,2

dayjs

3,3

3,0

3,2

3,1

moment

20,3

19,8

19,7

77,6

import { useDebounceFn } from '@vueuse/core'

0,8

0,5

0,5

0,6

import { map } from 'rxjs'

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

import { Camera } from 'lucide-react'

1,9

1,7

1,6

1,6

lucide-react целиком

206,7

187,7

197,0

177,5

import { FaBeer } from 'react-icons/fa'

1,4

1,3

1,3

1,3

import { Delete } from '@mui/icons-material'

29,9

26,4

26,6

26,1

import Delete from '@mui/icons-material/Delete'

29,8

26,4

26,6

26,1

import { Button } from '@mui/material'

37,5

34,6

34,8

34,4

import Button from '@mui/material/Button'

37,6

34,8

35,0

34,4

import { Button } from 'antd'

56,4

36,4

38,3

37,8

import { ElButton } from 'element-plus'

321,2

271,7

9,7

9,8

element-plus/es/components/button/index.mjs

14,6

9,7

9,7

9,8

import { NButton } from 'naive-ui'

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 библиотек в замер не входит.

Один ElButton из Element Plus в четырёх сборщикахРазмер JS после gzip: импорт из корня пакета и импорт по пути к компоненту
Один ElButton из Element Plus в четырёх сборщиках
импорт из корня пакетаимпорт по пути к компоненту
esbuild 0.28321,2 КБ14,6 КБ
Rollup 4.66271,7 КБ9,7 КБ
Vite 8 (Rolldown)9,7 КБ9,7 КБ
webpack 5.1129,8 КБ9,8 КБ

Источник: собственный замер, Element Plus 2.14.7, Vue вынесен во внешние зависимости

Что видно:

  • Иконки по одной стоят 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

только объявление функции

удалён

удалён

удалён

удалён

вызов функции на верхнем уровне

остался

удалён

остался

остался

то же + "sideEffects": false

удалён

удалён

удалён

удалён

то же + /*#__PURE__*/ перед вызовом

удалён

удалён

удалён

удалён

Comp.Item = Item

остался

удалён

удалён

удалён

Object.defineProperty(Comp, ...)

остался

удалён

остался

остался

static defaults = { ... } в классе

удалён

удалён

удалён

удалён

static defaults = mk() в классе

остался

удалён

остался

удалён

CommonJS (exports.x = ...)

остался

остался

остался

удалён

Функция 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

без четырёх строк в index.mjs

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-сервера и тестов. В продакшен-бандле, по моему замеру, разницы нет.


Как проверить свой проект

  1. Посмотрите, из чего состоит бандл. Для Vite, Rollup и Rolldown (последний экспериментально) подходит vite-bundle-analyzer, для Vite 8 есть анализ сборки в Vite DevTools, для webpack – webpack-bundle-analyzer. Ищите пакеты, которые представлены целиком, хотя вы импортируете из них пару функций.
  2. Изолируйте подозрительный импорт. Создайте файл с одним импортом и window.x = ..., как в моём эксперименте, и соберите его тем же сборщиком и с теми же настройками, что и проект. Сравните с импортом по пути или из ESM-версии пакета.
  3. Загляните в package.json библиотеки. Есть ли ESM-версия (module или условие import в exports) и поле sideEffects. Без ESM tree shaking не будет, без sideEffects сборщику придётся доказывать чистоту каждого модуля самому.
  4. Найдите цепочку импортов до лишнего модуля. 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-файлы бьют прежде всего по скорости разработки.
  • Проверяйте не по документации библиотеки, а изолированной сборкой одного импорта вашим сборщиком.

Источники

  1. 01Tree Shakingwebpack
  2. 02Webpack 5 releasewebpack · 2020
  3. 03Dead Code EliminationRolldown
  4. 04Vite 8.0 is out!vite.dev · 2026
  5. 05API: Platform (tree shaking и CommonJS)esbuild
  6. 06Minimizing bundle sizeMUIдля Material UI v9
  7. 07Quick StartElement Plus
  8. 08How we optimized package imports in Next.jsVercel · 2023
  9. 09Please Stop Using Barrel FilesTkDodo · tkdodo.eu · 2024
  10. 10Nuxt 4.5Nuxt
  11. 11IgnorePluginwebpack