Почему размер JavaScript-бандла — это проблема номер один для производительности
Можно идеально оптимизировать картинки, выкатить CDN, врубить Brotli — но если ваш JavaScript-бандл тянет на 500 КБ, пользователь всё равно будет сидеть и ждать. И дело тут не в скорости скачивания. Проблема глубже: браузер должен скачать, распарсить, скомпилировать и выполнить весь этот JavaScript, прежде чем страница станет хоть как-то интерактивной. Каждый лишний килобайт — это миллисекунды задержки INP и TTI, а на мобильных устройствах с их небыстрыми процессорами всё ещё печальнее.
Вот немного цифр для контекста. На начало 2026 года средний размер JavaScript на веб-странице — около 500 КБ. А у SPA-приложений на React или Vue? Легко за мегабайт. При этом 43% сайтов проваливают порог INP в 200 мс — и главная причина именно в тяжёлом JavaScript, блокирующем основной поток. Google рекомендует стремиться к 170 КБ gzip-сжатого JavaScript при начальной загрузке для нормального Time-To-Interactive.
Дальше разберём три ключевых техники, которые реально помогают сбросить вес бандла — tree shaking, code splitting и анализ размера. Всё с рабочими примерами для Vite и Webpack, которые можно скопировать в проект прямо сейчас.
Tree shaking: как работает удаление неиспользуемого кода
Tree shaking — механизм, при котором сборщик анализирует граф зависимостей приложения и выбрасывает из итогового бандла экспорты, которые нигде не импортируются. Название — метафора: «трясёте дерево» модулей, и неиспользуемые «листья» падают на землю.
Критически важный момент, который стоит запомнить раз и навсегда: tree shaking работает только с ES-модулями (синтаксис import/export). CommonJS (require/module.exports) не поддаётся статическому анализу — сборщик просто не может понять, какие экспорты реально используются.
Пример: правильный и неправильный импорт
Иногда разница в одной строчке кода стоит сотен килобайт:
// ❌ ПЛОХО: подключает всю библиотеку Lodash (~70 КБ gzip)
import _ from 'lodash';
const result = _.debounce(handler, 300);
// ✅ ХОРОШО: подключает только debounce (~1.5 КБ gzip)
import { debounce } from 'lodash-es';
const result = debounce(handler, 300);
// ✅ ЕЩЁ ЛУЧШЕ: подключает конкретный модуль
import debounce from 'lodash-es/debounce';
const result = debounce(handler, 300);
Обратите внимание — lodash-es, а не lodash. Это ESM-версия, которая поддерживает tree shaking. Обычный lodash — CommonJS, и даже при именованном импорте вся библиотека целиком попадёт в бандл. Честно говоря, эта ошибка встречается настолько часто, что стоило бы линтер под неё написать.
Настройка sideEffects: деталь, о которой почти все забывают
Даже с ES-модулями tree shaking может не сработать, если сборщик считает модуль «с побочными эффектами». Побочный эффект — это когда код делает что-то при импорте помимо экспорта значений: меняет глобальные переменные, регистрирует полифилы, тянет CSS.
Свойство "sideEffects" в package.json подсказывает сборщику, какие файлы содержат побочные эффекты:
// package.json
{
"name": "my-app",
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js"
]
}
Если поставить "sideEffects": false, сборщик будет считать все файлы чистыми и сможет удалять неиспользуемые модули целиком. Но тут есть подводный камень: если CSS-файлы не добавлены в исключения, стили молча исчезнут из бандла. Компонент отрисуется голым, без оформления — причём заметите вы это только на production. Приятного мало.
Ловушка с Babel: трансформация модулей
Если используете Babel с @babel/preset-env, обязательно проверьте настройку modules. По умолчанию Babel может трансформировать ES-модули в CommonJS, полностью убивая tree shaking:
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
// ❌ По умолчанию может быть 'commonjs' — tree shaking сломан
// ✅ Установите false, чтобы сохранить ES-модули
modules: false
}]
]
};
Я лично потратил пару часов на дебаг, пока не понял, что именно Babel ломал мне tree shaking в одном проекте. Так что проверьте — это буквально одна строчка.
Аннотация /*#__PURE__*/ для HOC и вызовов функций
Ещё одна неочевидная штука — React Higher Order Components и функции-обёртки. Сборщик не знает, есть ли у вызова функции побочные эффекты, поэтому оставляет код на месте. Аннотация /*#__PURE__*/ явно говорит минификатору, что вызов можно безопасно выбросить:
// Без аннотации — Terser не удалит даже при отсутствии использования
const EnhancedComponent = connect(mapState)(MyComponent);
// С аннотацией — Terser удалит, если EnhancedComponent нигде не используется
const EnhancedComponent = /*#__PURE__*/ connect(mapState)(MyComponent);
Code splitting: загружайте только то, что нужно прямо сейчас
Tree shaking убирает мёртвый код. Code splitting решает другую задачу — разделяет живой код на части, которые подтягиваются по мере необходимости. Вместо одного монолитного бандла в 800 КБ пользователь скачивает начальный чанк на 150 КБ, а остальное подгружается при навигации, скролле или взаимодействии.
Динамические импорты — основа code splitting
Ключевой примитив — функция import(), возвращающая Promise. Любой современный сборщик (Vite, Webpack, Rollup) автоматически вырезает динамически импортированный модуль в отдельный чанк:
// Статический импорт — попадает в основной бандл
import { HeavyChart } from './components/HeavyChart';
// Динамический импорт — создаёт отдельный чанк
const HeavyChart = () => import('./components/HeavyChart');
Одна строчка — и тяжёлый компонент больше не блокирует загрузку.
Code splitting в React: React.lazy + Suspense
React даёт встроенные инструменты для ленивой загрузки компонентов. Вот типичная настройка для роутинга:
import { lazy, Suspense } from 'react';
// Каждый роут создаёт отдельный чанк
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<div>Загрузка...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
Каждая страница грузится только при переходе на соответствующий маршрут. Не забудьте обернуть ленивые компоненты в ErrorBoundary — если сеть оборвётся при загрузке чанка, пользователь должен увидеть нормальное сообщение об ошибке, а не белый экран смерти.
Code splitting в Vue: ленивые роуты из коробки
Vue Router поддерживает ленивую загрузку нативно — достаточно заменить статический импорт на функцию:
// router.js
const routes = [
{
path: '/dashboard',
// Динамический импорт — отдельный чанк
component: () => import('./views/Dashboard.vue')
},
{
path: '/settings',
component: () => import('./views/Settings.vue')
}
];
Ещё в Vue можно лениво подтягивать тяжёлые компоненты прямо внутри страницы, комбинируя динамический импорт с v-if:
<template>
<button @click="showChart = true">Показать график</button>
<HeavyChart v-if="showChart" />
</template>
<script setup>
import { defineAsyncComponent, ref } from 'vue';
const showChart = ref(false);
const HeavyChart = defineAsyncComponent(
() => import('./components/HeavyChart.vue')
);
</script>
Компонент HeavyChart не будет загружен, пока пользователь не кликнет кнопку. Идеальный паттерн для графиков, карт, WYSIWYG-редакторов и прочих тяжеловесов.
Vendor splitting: разделяем зависимости
Отдельная полезная стратегия — разделение кода приложения и сторонних библиотек. Логика простая: библиотеки обновляются реже, значит их чанк можно закэшировать надольше.
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// React-экосистема — отдельный чанк
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
// UI-библиотека — отдельный чанк
'vendor-ui': ['@mui/material', '@mui/icons-material'],
}
}
}
}
});
В Webpack то же самое делается через SplitChunksPlugin:
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
}
}
}
}
};
Анализ размера бандла: найдите проблемы до того, как они уедут в production
Оптимизация без измерения — это стрельба с закрытыми глазами. Прежде чем что-то чинить, нужно понять, что конкретно жрёт место в бандле. К счастью, инструментов для этого хватает.
webpack-bundle-analyzer
Самый популярный инструмент для Webpack — генерирует интерактивную карту (treemap) всех модулей в бандле:
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin =
require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static', // HTML-файл вместо локального сервера
openAnalyzer: false, // не открывать автоматически
reportFilename: 'bundle-report.html'
})
]
};
rollup-plugin-visualizer для Vite
Аналог для Vite (который использует Rollup / Rolldown под капотом):
npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: 'bundle-report.html',
gzipSize: true, // показывать gzip-размер
brotliSize: true, // показывать brotli-размер
})
]
});
На что смотреть в отчёте
Когда откроете интерактивную карту, ищите вот что:
- Здоровенные блоки от одной библиотеки — скорее всего, вы импортируете всю библиотеку вместо отдельных функций (классика: Lodash, Moment.js, Material UI целиком)
- Дублирование — одна и та же библиотека включена в несколько чанков
- Тяжёлые полифилы — если вы давно не поддерживаете IE11, многие полифилы пора убрать через сужение
browserslist - Модули, которым место в lazy-чанке — проверьте, не залетели ли редко используемые компоненты в основной бандл
Bundlephobia и Import Cost: проверяйте ДО установки
Хорошая привычка — смотреть вес npm-пакета до того, как вы его поставите. Сервис Bundlephobia показывает minified и gzip-размер любого пакета, включая все транзитивные зависимости. А расширение Import Cost для VS Code отображает размер импорта прямо в редакторе — рядом со строкой кода. Очень отрезвляет, когда видишь «+230 КБ» после невинного на вид импорта.
Performance budgets: автоматический контроль размера
Чтобы бандл не разрастался потихоньку (а он будет, поверьте), задайте лимиты — performance budgets. Webpack поддерживает это из коробки:
// webpack.config.js
module.exports = {
performance: {
maxEntrypointSize: 250000, // 250 КБ на точку входа
maxAssetSize: 200000, // 200 КБ на ассет
hints: 'error' // сборка упадёт при превышении
}
};
В Vite аналогичный контроль реализуется через плагин vite-plugin-bundle-budget или скриптом в CI/CD пайплайне, который проверяет размер файлов в dist/ после сборки.
Vite vs. Webpack в 2026: что лучше для оптимизации бандла
Оба сборщика поддерживают tree shaking и code splitting, но подходят для разных ситуаций. Вот краткое сравнение:
| Критерий | Vite (Rolldown) | Webpack 5 |
|---|---|---|
| Скорость сборки | Быстрее в 5–10 раз (Rust-ядро) | Зрелый, но медленнее на больших проектах |
| HMR (горячая замена) | 10–20 мс | 500–1600 мс |
| Средний размер бандла | ~130 КБ (тест) | ~150 КБ (тест) |
| Tree shaking | Rollup/Rolldown — отличный | Хороший, но требует настройки |
| Module Federation | Нет нативной поддержки | Поддержка из коробки |
| Lazy Barrel Optimization | Есть (92% меньше модулей) | Нет |
| Экосистема плагинов | Растёт, совместима с Rollup | Огромная, зрелая |
Моя рекомендация на 2026 год: для новых проектов берите Vite — быстрее сборка, компактнее бандлы, встроенная Lazy Barrel Optimization (сокращает количество компилируемых модулей из barrel-файлов на 92%). Для существующих Webpack-проектов с Module Federation или сложными монорепо — оставайтесь на Webpack 5, но обязательно пройдитесь по настройкам tree shaking и code splitting. Миграция ради миграции того не стоит.
Практический чек-лист: 10 шагов к лёгкому бандлу
- Проанализируйте текущий бандл — запустите webpack-bundle-analyzer или rollup-plugin-visualizer и посмотрите, что вообще происходит
- Проверьте импорты — замените
import _ from 'lodash'на именованные импорты изlodash-es - Настройте
sideEffects— добавьте свойство вpackage.json, не забыв исключить CSS - Убедитесь, что Babel не ломает ESM — поставьте
modules: falseв@babel/preset-env - Разделите роуты —
React.lazyили динамические импорты в Vue Router - Вынесите vendor-чанки — React, UI-библиотеки должны жить в отдельных файлах
- Сузьте browserslist — уберите поддержку старых браузеров и избавьтесь от ненужных полифилов
- Замените тяжёлые библиотеки —
moment.js(330 КБ) →date-fns(tree-shakeable),lodash(70 КБ) →lodash-es - Задайте performance budgets — пусть CI падает при превышении лимита, иначе бандл будет расти
- Мониторьте размер на каждом PR — Bundlephobia или скрипт в CI для отслеживания дельты
Реальные результаты: что даёт оптимизация бандла
Это не просто теория. Вот конкретные кейсы:
- Dropbox перешёл на Rollup и сократил JavaScript-бандл на 33%, а количество скриптов — на 15%
- redBus оптимизировал code splitting и загрузку сторонних скриптов, получив 80–100% рост мобильных конверсий на глобальных рынках
- Сайты, которые проходят пороги всех трёх Core Web Vitals, показывают на 24% ниже показатель отказов
Отдельно стоит упомянуть Vite 7+ с Rolldown и функцию Lazy Barrel Optimization: если проект импортирует один компонент из barrel-файла (например, import { Button } from 'antd'), Rolldown скомпилирует только нужный модуль. В тестах это даёт двукратное ускорение сборки при 92% меньшем количестве обработанных модулей. Впечатляющие цифры, и на практике разница очень ощутима.
Часто задаваемые вопросы
Какой размер JavaScript-бандла считается нормальным?
Google рекомендует держать начальную загрузку JavaScript в пределах 170 КБ gzip. Для SPA общий размер всех чанков может быть и больше, но критически важно, чтобы первый чанк — тот, что блокирует рендеринг — укладывался в лимит. Превышение бьёт напрямую по INP и Time-To-Interactive.
Tree shaking работает только в production-сборке?
В основном да. В Webpack tree shaking активируется при mode: 'production', который включает usedExports: true и минификацию через Terser. В development неиспользуемый код остаётся — для удобства отладки. В Vite аналогично: tree shaking применяется только при vite build, а dev-сервер раздаёт модули без бандлинга.
Может ли code splitting ухудшить производительность?
Может, если переусердствовать. Каждый чанк — отдельный HTTP-запрос. Разбить приложение на 200 крошечных файлов — плохая идея: накладные расходы на установку соединений съедят весь выигрыш от меньшего размера. Оптимальный баланс — разделение по роутам и крупным функциональным блокам. Для тонкой настройки используйте manualChunks в Vite или SplitChunksPlugin в Webpack.
Как проверить, работает ли tree shaking в моём проекте?
Запустите production-сборку и откройте отчёт webpack-bundle-analyzer или rollup-plugin-visualizer. Видите целиком включённые Lodash, Moment.js, весь Material UI? Значит, tree shaking либо не работает, либо библиотека не поддерживает ESM. Ещё полезно заглянуть в sideEffects в package.json самих библиотек — если свойства нет, сборщик будет считать все модули «с побочными эффектами» и не тронет их.
Стоит ли мигрировать с Webpack на Vite ради оптимизации бандла?
Если ваш проект — обычный SPA без Module Federation и экзотических Webpack-плагинов, миграция на Vite даст ощутимый результат: сборка быстрее в 5–10 раз, бандл чуть компактнее (благодаря лучшему tree shaking в Rollup), и HMR за 10–20 мс вместо 500+. Но если вы активно используете Module Federation для микрофронтенда — у Vite пока нет нативного аналога, так что спешить с переездом не стоит.