Почему ваш бандл тяжелее чем должен быть — тестирую tree shaking на 7 бандлерах

от автора

Как всё началось

По крайней мере, так должно работать. На практике результат зависит от бандлера и способа импорта — и именно это я проверял.

В августе 2024 я наткнулся на проблему в рабочем проекте на Next.js. Несколько страниц импортировали константы из общего файла через barrel (index.ts с реэкспортами). Каждая страница использовала 2-3 значения, но в бандл попадало всё — десятки неиспользуемых экспортов. Разница оказалась колоссальной: когда я добавил sideEffects: false и перешёл на direct export — бандл уменьшился в два раза.

Я завёл issue на GitHub, покопался, нашёл обходной путь и закрыл. Но вопрос не отпускал: это Next.js виноват? Webpack? Или barrel файлы сами по себе проблема? Спустя полтора года решил разобраться основательно — собрал исследование, прогнал одни и те же тесты на 7 бандлерах и посмотрел что реально попадает в выходные файлы.

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

Что бы продолжать рассуждать о теме, нужно небольшое понимание о чём конкретно мы будем говорить, и что означают те или иные термины в данной статье, по этому давайте начнём с небольшой базы, и перейдем к обсуждению, что бы каждый читающий мог уловить суть.

Мёртвый код и tree shaking

Мёртвый код — код, который есть в исходниках, но никогда не используется. Tree shaking — когда бандлер вырезает такой код из финальной сборки. Пример:

// utils.ts — 4 функцииexport function formatDate() { /* ... */ }export function formatCurrency() { /* ... */ }export function parseQuery() { /* ... */ }export function debounce() { /* ... */ }
// app.ts — используем только однуimport { formatDate } from './utils'

С tree shaking в бандл попадёт только formatDate. Без него — все четыре функции, даже если они нигде больше не вызываются.

То же самое в масштабе: импортируешь одну иконку из @mui/icons-material (3000+ экспортов) или pick из lodash-es (300+ функций) — tree shaking должен оставить только то, что используется, а остальное выкинуть.

Что такое barrel file и почему с ним проблемы

Barrel file — это index.ts, который реэкспортирует всё из нескольких файлов в одном месте:

// shared/constants/index.ts — barrel fileexport { CONSTANT_A, CONFIG_A } from './a'export { CONSTANT_B, CONFIG_B } from './b'export { CONSTANT_C, CONFIG_C } from './c'

Удобно — вместо трёх импортов пишешь один:

import { CONSTANT_A, CONFIG_A } from '../shared/constants'

Проблема в том, что бандлер видит импорт из index.ts и может затянуть весь граф зависимостей — включая b.ts и c.ts, которые этой странице не нужны. Неиспользуемый код, который бандлер должен был вырезать (это и есть tree shaking — удаление мёртвого кода при сборке), остаётся в финальном файле и едет в продакшен.

Исследование: 7 бандлеров, 3 кейса

Задумка простая — взять одни и те же данные, одну и ту же структуру импортов, и посмотреть как каждый бандлер справляется с удалением неиспользуемого кода. Без фреймворков, без сложной логики, минимальный воспроизводимый пример.

6 экспортов — 3 простые строки и 3 объекта конфигурации. Три страницы, каждая использует только 2 из 6. Если tree shaking работает — в финальном файле страницы будут только её 2 значения. Если нет — потащит все 6.

Одни и те же данные, три варианта импорта:

Single file — все 6 экспортов в одном файле:

// shared/constants-single-file.tsexport const CONSTANT_A = 'value_a_from_single_file'export const CONFIG_A = { name: 'config_a', value: 1, nested: { deep: true } }// ... и ещё B, C

Barrel — экспорты разнесены по файлам, импорт через index.ts:

// shared/constants-separate/index.tsexport { CONSTANT_A, CONFIG_A } from './a'export { CONSTANT_B, CONFIG_B } from './b'export { CONSTANT_C, CONFIG_C } from './c'

Direct — те же отдельные файлы, но импорт напрямую, без barrel:

import { CONSTANT_A, CONFIG_A } from '../shared/constants-separate/a'

Прогнал на:

  • webpack 5 — scope hoisting + terser

  • rspack 1 — webpack на Rust, тот же API

  • rollup 4 — заточен под ES-модули и tree shaking

  • vite 8 — rolldown под капотом для production-сборки

  • esbuild 0.28 — написан на Go, самый быстрый

  • Next.js 15 (webpack) — Next.js с webpack под капотом

  • Next.js 16 (Turbopack) — Next.js с Turbopack по умолчанию

Всё автоматизировано — node analyze.js ставит зависимости, собирает каждый бандлер и анализирует выходные файлы, проверяя какие маркеры попали в бандл.

Репозиторий с исследованием: github.com/lykianovsky/tree-shaking-barrel-test

Результаты

Кейс

webpack

rspack

rollup

vite

esbuild

next-webpack

next-turbopack

Single file

Barrel

Direct

Главный вывод: direct import (import { X } from './constants/a') — единственный способ, который гарантирует tree shaking у всех 7 бандлеров. Только webpack и rspack справляются со всеми тремя кейсами. Остальные ломаются на single file, barrel, или на обоих.

Дальше — разбираю почему каждый бандлер ведёт себя именно так, и что с этим делать.


Как webpack делает tree shaking — и почему это два шага, а не один

Webpack не вырезает мёртвый код напрямую. Он работает в два этапа:

webpack:  [модули] → scope hoisting (concat) → всё в одном скоупе → terser → мёртвый код удалён ✅rollup:   [модули] → анализ графа → включает только используемое → минификация (опционально) ✅Next.js:  [модули] → shared chunks (multi-entry) → concat невозможен → terser не видит мёртвое ❌

Шаг 1 — scope hoisting. ModuleConcatenationPlugin объединяет все модули в один скоуп. Вместо того чтобы оборачивать каждый файл в отдельную функцию, webpack складывает весь код в одно место. На этом этапе мёртвый код ещё никуда не делся — все 6 экспортов на месте:

// webpack dist/separate/page1.js — minimize: false(() => {  "use strict";  // ../shared/constants-separate/a.ts  const CONSTANT_A = 'value_a_from_separate_file';  const CONFIG_A = { name: 'config_a', value: 1, nested: { deep: true } };  // ../shared/constants-separate/b.ts        ← мёртвый код  const CONSTANT_B = 'value_b_from_separate_file';  const CONFIG_B = { name: 'config_b', value: 2, nested: { deep: false } };  // ../shared/constants-separate/c.ts        ← мёртвый код  const CONSTANT_C = 'value_c_from_separate_file';  const CONFIG_C = { name: 'config_c', value: 3, nested: { deep: true } };  // ./src/separate-files/page1.ts  console.log('Page 1:', CONSTANT_A, CONFIG_A);})();

Шаг 2 — terser. Минификатор видит что CONSTANT_B, CONFIG_B, CONSTANT_C, CONFIG_C нигде не используются — и вырезает:

// webpack dist/separate/page1.js — minimize: true (по умолчанию)(()=>{"use strict";console.log("Page 1:","value_a_from_separate_file",{name:"config_a",value:1,nested:{deep:!0}})})();

Чисто. Только нужные данные.

Важный момент: отключи minimize: false — и tree shaking ломается. Terser не запускается, все 6 экспортов остаются. Scope hoisting сам по себе не удаляет код — он только создаёт условия, чтобы terser мог увидеть неиспользуемые переменные. Без минификации webpack не лучше остальных.

В rollup и vite такой зависимости нет — tree shaking у них работает на этапе построения графа модулей, ещё до минификации. Rollup анализирует какие экспорты реально используются и просто не включает остальные в выходной файл.

Побочный эффект scope hoisting — дублирование кода

Scope hoisting дублирует модули в каждый entry. Это даёт чистый tree shaking, но один и тот же код физически присутствует в нескольких файлах. Возникает вопрос: а не ломает ли это синглтоны и общее состояние?

Разбор: как webpack сохраняет синглтоны при дублировании

Допустим, три страницы используют общий shared-state.ts:

// shared-state.ts                          — общий модульexport const testMap = new Map<string, string>()// page1.ts — пишет в Mapimport { testMap } from './shared-state'testMap.set('page', 'page1')// page2.ts — читает из Mapimport { testMap } from './shared-state'console.log('Page 2:', testMap.get('page'))  // 'page1' — если синглтон работает

После сборки new Map() появляется внутри каждого чанка — в 503.chunk.js и в 570.chunk.js. Выглядит как два разных экземпляра new Map().

Но webpack при первом вызове require(564) выполняет фабрику модуля и сохраняет результат в __webpack_module_cache__. Когда page2 запрашивает тот же модуль 564 — webpack берёт его из кэша, new Map второй раз не вызывается. Код дублирован физически, но выполняется один раз. Синглтон работает.

Если дублирование не устраивает — splitChunks с minSize: 0 выносит общий код в отдельный чанк, один на всех. Но тогда этот чанк содержит все экспорты и tree shaking на нём не работает — тот же компромисс что у rollup.

Почему rollup и vite ломаются на single file

Если tree shaking у rollup работает на этапе графа — почему single file ломается?

Потому что когда один файл (constants-single-file.ts) импортируется из нескольких entry (page1, page2, page3), rollup выносит его в отдельный shared chunk. Этот shared chunk содержит все 6 экспортов, потому что разные страницы используют разные:

// rollup dist/single/shared-constants-single-file.js — shared chunkconst e = "value_a...", a = { name: "config_a", ... };const n = "value_b...", o = { name: "config_b", ... };  // page1 это не нужноconst s = "value_c...", t = { name: "config_c", ... };  // и это тожеexport { e as C, a, n as b, o as c, s as d, t as e };   // но экспортируется всё

Shared chunk — это отдельный JS-файл, в который бандлер выносит код, общий для нескольких страниц. Каждая страница подключает его и берёт только свои значения, но сам файл грузится целиком — мёртвый код доставляется в браузер.

Webpack поступает иначе — дублирует код в каждый entry через scope hoisting, а terser вычищает лишнее в каждом отдельно. Каждый entry содержит только нужное.

В barrel-кейсе rollup справляется — трейсит через index.ts до конкретных файлов и включает только нужные. Shared chunk не создаётся, потому что файлы маленькие и разные страницы тянут разные файлы, которые не пересекаются.

Почему esbuild ломает и barrel тоже

esbuild — самый быстрый из всех, но агрессивно создаёт shared chunks. И для single file, и для barrel весь общий код уезжает в один файл:

// esbuild dist/separate/shared-chunk-X3DOSE65.js — ВСЕ 6 экспортов в одном файлеvar e="value_a...",_={name:"config_a",...};var o="value_b...",t={name:"config_b",...};  // ← не нужен page1, но всё равно здесьvar r="value_c...",a={name:"config_c",...};  // ← и это тожеexport{e as a,_ as b,o as c,t as d,r as e,a as f};

С direct-импортами shared chunk не создаётся — каждая страница видит только свой файл.

Почему Next.js (webpack) ломает tree shaking — и это не ‘use client’

Это было самое интересное в исследовании. Первая мысль — виноват 'use client', граница клиентского компонента мешает scope hoisting. Но нет — на Pages Router, где никакого 'use client' нет, картина ровно такая же.

Я полез в исходники Next.js и повесил отладочные хуки на webpack-конфиг. Вот что выяснилось:

Next.js вообще не включает ModuleConcatenationPlugin. В файле webpack-config.js нет ни одного упоминания concatenateModules или ModuleConcatenationPlugin. Ноль.

Окей, может достаточно включить руками? Добавил через next.config.js:

webpack(config) {  config.optimization.concatenateModules = true  return config}

Результат — 0 модулей сконкатенировано. Bailout на каждом. Webpack прямо говорит почему:

ModuleConcatenation bailout: Cannot concat with shared/constants-single-file.ts:  Module is referenced from different chunks by these modules:  app/single/page2/page.tsx, app/single/page3/page.tsx

Корневая причина: Next.js создаёт отдельный chunk entry на каждую страницу. В Pages Router это делает next-client-pages-loader, в App Router — next-flight-client-entry-loader. Когда constants-single-file.ts импортируется из page1, page2, page3 — модуль оказывается referenced из нескольких чанков.

ModuleConcatenationPlugin не может заинлайнить модуль, на который ссылаются из разных чанков — ему пришлось бы дублировать код, а он этого не делает. Без конкатенации модули остаются в отдельных обёртках, terser не видит что экспорты не используются, мёртвый код остаётся.

В чистом webpack те же 3 entry, тот же файл. Но там splitChunks.minSize = 20000 (порог по умолчанию) — файл констант маленький, не дотягивает, webpack не выносит его в shared chunk, а дублирует в каждый entry. Дублированный код конкатенируется → terser вычищает. Next.js так не может — его загрузчики сразу создают отдельные chunk entries для каждой страницы, и модули автоматически шарятся между ними.

Turbopack (Next.js 16) частично решает проблему — barrel обрабатывает, трейсит через index.ts до конкретных файлов, как rollup. Но single file всё равно ломает — та же история с shared модулем.

Можно ли починить tree shaking в Next.js?

Теоретически есть три пути:

Дублировать модули в каждый entry — как делает чистый webpack с маленькими файлами. Конкатенация работает, terser вычищает. Но Next.js специально шарит модули между страницами — при навигации page1 → page2 shared chunk уже в кеше браузера. Дублирование означает: каждая страница тяжелее, при навигации тот же код грузится заново.

Tree shaking на уровне графа — резать неиспользуемые экспорты при построении графа, не зависеть от concat + terser. Turbopack так и делает, поэтому barrel у него работает. Но это переписывание core-логики webpack, и single file всё равно не решается.

Per-entry копии модуля — создавать отдельную версию модуля для каждого entry с только нужными экспортами. Фундаментальное изменение, которого в webpack нет и вряд ли появится.

На практике:

  • Большие barrel-ы из npm (@mui/icons-material, lodash-es) — Next.js решает через optimizePackageImports:

// next.config.jsmodule.exports = {  experimental: {    optimizePackageImports: ['@mui/icons-material', 'lodash-es']  }}// Результат: import { Add } from '@mui/icons-material'//         → import Add from '@mui/icons-material/Add'
  • Свои barrel-ы — direct import или разбивай на мелкие группы по фичам

  • Масштаб проблемы зависит от размера barrel-а. 6 констант — незаметно. Но если в одном файле 100 экспортов с тяжёлыми объектами — каждая страница тащит всё, и это уже ощутимо

Трейдоффы — нет единого лучшего решения

Каждый бандлер делает свой выбор, и у каждого выбора есть цена:

webpack / rspack — дублирует код в каждый entry, concat + terser вычищает мёртвое. Tree shaking работает на всех кейсах. Но есть порог splitChunks.minSize (по умолчанию 20kb) — если общий модуль вырастает больше порога, webpack выносит его в shared chunk и tree shaking на нём ломается. Плюс зависимость от minimize — без минификации ничего не вырезается.

rollup / vite — tree shaking на этапе графа, не зависит от минификации. Но создают shared chunks для файлов, которые импортируются из нескольких entry. Shared chunk содержит все экспорты для всех потребителей — мёртвый код для конкретной страницы.

esbuild — быстрее всех, но агрессивные shared chunks и для single file, и для barrel. Tree shaking работает только с direct imports.

Next.js (webpack) — multi-entry архитектура ради кеширования при навигации между страницами. Shared chunk загрузился один раз — при переходе на другую страницу грузится только новый код. Цена — tree shaking не работает на shared модулях, мёртвый код попадает в бандл.

Вывод

Практический совет: import { X } from './constants/a' вместо import { X } from './constants'. Direct import работает у всех 7 бандлеров — это единственный способ, где tree shaking гарантирован.

Но у меня остался открытый вопрос. Webpack пошёл по пути scope hoisting + terser — дублирует модули, потом вычищает мёртвое. Rollup, vite, esbuild пошли другим путём — tree shaking на уровне графа, shared chunks вместо дублирования. Почему? Есть ли фундаментальная причина, по которой другие бандлеры не пошли по пути webpack? Или это просто разные компромиссы? И можно ли совместить лучшее из обоих подходов — tree shaking на уровне графа без shared chunks с мёртвым кодом?

Если вы работаете над бандлерами или глубоко копали эту тему — буду рад услышать ваше мнение в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1024404/