- Регистрация
- 23 Авг 2023
- Сообщения
- 4,107
- Реакции
- 0
- Баллы
- 36
Ofline
Как всё началось
В августе 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 file
export { 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.ts
export 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.ts
export { 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 (
[B]import { X } from './constants/a'[/B]) — единственный способ, который гарантирует 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}})})();
Чисто. Только нужные данные.
Важный момент: отключи
[B]minimize: false[/B] — и 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 — пишет в Map
import { testMap } from './shared-state'
testMap.set('page', 'page1')
// page2.ts — читает из Map
import { 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 chunk
const 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, где никакого [B]'use client'[/B] нет, картина ровно такая же.Я полез в исходники Next.js и повесил отладочные хуки на webpack-конфиг. Вот что выяснилось:
Next.js вообще не включает
[B]ModuleConcatenationPlugin[/B]. В файле 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.js
module.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 с мёртвым кодом?
Если вы работаете над бандлерами или глубоко копали эту тему — буду рад услышать ваше мнение в комментариях.