Сократить initial-бандл на 25%, не развешивая lazy на всё
Системный подход к аудиту бандла: bundle-analyzer и source-map-explorer, где на самом деле прячутся байты, разбиение по роутам против компонентов и ловушка переусердствования с дроблением.
Initial-бандл в файловом менеджере Облака Mail.ru рос так же, как растёт любой бандл: по одному разумному импорту за раз, ни один из которых не выглядел ошибкой. Когда я наконец его профилировал, оболочка приложения отгружала 412 КБ сжатого JS — brotli, те самые байты, что реально качает пользователь, — ещё до того, как на экране появлялся хоть один файл. Я довёл это до 306 КБ, чуть больше четверти. Почти ничего из этого не дал React.lazy. Дали три измеренных изменения, которые я разберу в конце, и одно правило, которого я держался всю дорогу: не трогать то, что предварительно не профилировал.
Сначала измеряем, потом режем
Профилирование идёт первым. Любая история про размер бандла, которая начинается с «я развесил React.lazy повсюду», заканчивается приложением помедленнее и разработчиком, который не может объяснить почему. Прежде чем менять строку, получите картину того, где лежат байты.
Два инструмента, два вопроса. webpack-bundle-analyzer говорит, что лежит в каждом чанке и сколько весит, — читает stats-вывод и рисует treemap, на котором 90-килобайтную библиотеку дат уже не получится не заметить. source-map-explorer отвечает на вопрос поострее: что реально уехало. Он работает по продакшн-сорсмапам, после минификации и tree-shaking, поэтому считает байты, которые дожили, а не те, что вы импортировали.
# treemap из настоящей продакшн-сборки, а не из dev
npx webpack --config webpack.prod.js --profile --json > stats.json
npx webpack-bundle-analyzer stats.json -m static -r report.html
# что реально уехало, по модулям, из эмитнутых сорсмап
npx source-map-explorer dist/assets/*.js
Оба инструмента показывают сырой и gzip-размер, но ваш CDN почти наверняка отдаёт brotli, а он заметно меньше, — поэтому считайте число из анализатора сигналом ранжирования, какие модули жирные, а реальную базовую линию снимайте из вкладки Network на продакшн-сборке. В файловом менеджере эта база была 412 КБ brotli, и львиную долю держали четыре строки: PDF-стек рендеринга, встроенный редактор изображений, библиотека дат со всеми локалями и полифил, попавший в граф дважды.
Где на самом деле прячутся байты
Treemap почти всегда рассказывает три типа истории, и в файловом менеджере нашлось по одной каждого типа.
Дублированные зависимости. Две копии core-js приехали через разные транзитивные диапазоны: одну подставлял Babel-пресет, другую прибила зависимость UI-кита, ни разу не обновившая свой диапазон. Полифилы отгружаются дважды, исполняются один раз. Фиксация одной версии через overrides не потребовала правок кода и вернула 28 КБ.
Одна тяжёлая библиотека ради мелкой задачи. Та самая библиотека дат тащила все локали, что в ней есть, ради ровно одной фичи — подписей «изменено 2 минуты назад» в списках файлов. Замена на date-fns плюс платформенный Intl.RelativeTimeFormat закрыла ту же фичу и сняла с entry 47 КБ. Самые крупные единичные выигрыши обычно живут именно здесь, и это, как правило, замена или более узкий импорт, а не дробление.
Модули, которые грузятся жадно, а срабатывают редко. PDF-вьюер и редактор изображений оба были импортами верхнего уровня в оболочке, хотя их открывала дай бог десятая часть сессий. Им было нечего делать в байтах, которые блокируют первую отрисовку.
- Сначала дедупликация —
npm ls <pkg>находит конфликтующие диапазоны, а override или resolution схлопывает их в одну копию бесплатно. - Замените тяжёлое-не-по-задаче ещё до того, как что-то дробить, — там самые крупные и дешёвые выигрыши.
- И только потом дробите — причём редко срабатывающие модули, а не то, что просто оказалось наверху treemap.
Дробим сначала по роутам, потом по компонентам
У code splitting два естественных шва, и они не равноценны.
Разбиение по роутам — высокорычажное. Каждый роут верхнего уровня становится отдельным чанком, который грузится при переходе, так что entry-бандл несёт только оболочку плюс стартовый роут. С роутером это механика:
// по роутам: настройки и их зависимости не грузятся, пока туда не зайдёшь
const Settings = lazy(() => import('./routes/Settings'));
const PdfViewer = lazy(() => import('./routes/PdfViewer'));
<Suspense fallback={<RouteSkeleton />}>
<Routes>
<Route path="/settings/*" element={<Settings />} />
<Route path="/file/:id/pdf" element={<PdfViewer />} />
</Routes>
</Suspense>
Разбиение по компонентам — это скальпель: для тяжёлой штуки внутри в остальном лёгкого роута — модалки, богатого редактора, графика ниже первого экрана. Правило, которого я держусь: компонент заслуживает дробления, только когда он одновременно тяжёлый и вне критического пути. Редактор на 120 КБ, который открывается по кнопке, — да. Дропдаун на 4 КБ — никогда: накладные на чанк и мелькание загрузки обойдутся дороже того, что вы сэкономите.
React.lazy плюс Suspense закрывает React-дерево; для некомпонентного кода — парсера, форматтера, payload для воркера — голый динамический import() делает то же самое и резолвится в модуль:
// откладываем тяжёлый валидатор до реального сабмита
async function validateUpload(file: File) {
const { scan } = await import('./heavy/scan');
return scan(file);
}
Каждой lazy-границе нужен настоящий fallback и error boundary вокруг. Чанк может не догрузиться на нестабильной связи, и дробление, которое отдаёт белый экран на сетевом сбое, обходится дороже, чем сэкономленные им байты.
Настраиваем splitChunks, чтобы кэши выживали
Дробление на уровне импортов решает, что грузится когда. splitChunks решает, как эти байты группируются в файлы, и хорошая группировка по большей части про время жизни кэша. Код приложения меняется на каждом деплое; сторонний код — раз в месяц. Смешаете их в одном файле — и каждый деплой без причины сбрасывает кэш вендора.
// webpack.prod.js — отделяем вендор от приложения, изолируем часто меняющихся гигантов
optimization: {
runtimeChunk: 'single', // держим webpack-runtime вне каждого чанка
splitChunks: {
chunks: 'all',
cacheGroups: {
// большие, версионируемые отдельно либы — в свой долгоживущий файл
react: { test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/, name: 'react', priority: 20 },
// всё остальное из node_modules, общее для роутов
vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor', priority: 10 },
// код, импортируемый 2+ async-чанками, поднят, чтобы грузиться один раз
shared: { minChunks: 2, name: 'shared', priority: 5, reuseExistingChunk: true },
},
},
},
Группа react сделана намеренно: она почти никогда не меняется, поэтому получает отдельный файл со стабильным хэшем, переживающим большинство деплоев. Группа shared ловит утилиты, которые тянут к себе сразу два lazy-роута, чтобы они скачивались один раз, а не ехали в каждом чанке. Имена файлов с content-hash делают всё это безопасным для кэша на год вперёд.
Ловушка переусердствования с дроблением
Вот режим отказа, о котором не предупреждают, пока вы сами его не устроите. Дробите слишком агрессивно — и открытие одного роута запускает водопад зависимых запросов: чанк роута грузится, браузер парсит его и только тогда обнаруживает, что тот импортирует shared-чанк, парсит его и только тогда добирается до vendor-чанка. Каждый шаг — round trip, который не может начаться, пока не закончился предыдущий.
Хочется свалить вину на накладные соединения, но этот диагноз устарел лет на десять. На HTTP/2 и HTTP/3 браузер переиспользует одно мультиплексированное соединение, так что установка — не цена. Цена — порядок обнаружения: чанк глубоко в графе импортов нельзя запросить, пока не скачался и не распарсился его родитель, поэтому глубокое дерево чанков превращается в глубокий последовательный водопад, сколько бы запросов соединение ни тянуло параллельно. Вы обменяли одну загрузку на 200 КБ на шесть сцепленных по 30 КБ, и на среднем телефоне роут стал медленнее, чем до вашей «оптимизации».
- Смотрите на водопад запросов в DevTools, а не только на размеры чанков, — длинная диагональная лесенка и есть сцепленное обнаружение, которое надо убирать.
- Держите граф неглубоким: несколько осмысленных чанков лучше глубокого дерева крошечных взаимозависимых.
- Расплющивайте цепочку предзагрузкой.
<link rel="modulepreload">даёт браузеру скачать весь набор чанков роута параллельно, а не обнаруживать их уровень за уровнем. Загвоздка — имя файла с content-hash: рукамиvendor.a1b2c3.jsне пропишешь, потому что хэш появляется только после сборки. На практике вы читаете маппинг «чанк → файл» из манифеста, который эмитит сборщик, и вставляете теги по роутам — либо даёте роутеру запустить динамическийimport()по ховеру или намерению перейти, чтобы запрос уже летел к моменту перехода. - Поставьте порог через
splitChunks.minSize, чтобы тривиальные модули никогда не получали отдельный файл.
А реально ли стало быстрее?
Размер бандла — это прокси. На самом деле вы двигаете то, как быстро страница становится пригодной к работе, а это живёт в Core Web Vitals и в загрузочных вехах вокруг них.
- FCP (First Contentful Paint) сдвигается первым, когда вы ужимаете entry-чанк, — меньше JS на скачивание и парсинг до первой отрисовки.
- LCP (Largest Contentful Paint) подтягивается следом, когда блокирующий скрипт, задерживавший основной контент, выносится с критического пути.
- TTI / TBT — Time to Interactive и Total Blocking Time — там отложенный JS окупается сильнее всего: меньше скрипта в главном потоке на старте означает, что страница перестаёт быть красивым замороженным скриншотом раньше.
Меряйте их так же, как мерили байты: продакшн-сборка, throttling-профиль среднего устройства, три прогона, берём медиану. Лабораторные числа из Lighthouse — для дельты до/после; полевые данные из CrUX или RUM — чтобы подтвердить, что выигрыш удержался на реальных пользователях. Entry-бандл на 25% меньше, который не двигает FCP на реальном устройстве, никому не помог.
Что я проверяю, прежде чем назвать бандл «отаудированным»
Прежде чем доверять результату по бандлу, я прохожусь по списку:
- есть записанная базовая линия — реальный размер по сети и главные вкладчики — с продакшн-сборки, снятая до любых изменений;
- дубли убраны, а тяжёлые-не-по-задаче библиотеки заменены или сужены, ещё до того как добавлен хоть один
lazy; - дробление идёт сначала по роутам, потом по компонентам, и каждое компонентное дробление одновременно тяжёлое и вне критического пути;
splitChunksотделяет код приложения от вендора, чтобы деплой без нужды не сбрасывал кэш стороннего кода;- водопад запросов на throttling-связи неглубокий — никакой диагональной лесенки зависимых чанков;
- FCP, LCP и TBT сдвинулись в нужную сторону на профиле среднего устройства, а не только число в анализаторе.
Итак, числа. 412 КБ brotli до 306 на entry-бандле Облака Mail.ru — около четверти. Дедупликация вернула 28 КБ, замена библиотеки дат — ещё 47, а вынос PDF-вьюера и редактора изображений в чанки роутов снял 31 с пути до первой отрисовки. Ни один отдельный lazy() тут ничего не решал — решали три измеренных изменения, каждое сверено с базовой линией до и после. И ни одному я не верил, пока FCP реально не сдвинулся на придушенном среднем телефоне, потому что меньшее число в анализаторе, которого пользователь не чувствует, — это просто меньшее число.