Как всё началось
По крайней мере, так должно работать. На практике результат зависит от бандлера и способа импорта — и именно это я проверял.
В августе 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/