Оптимизация JavaScript-бандла в 2026: tree shaking, code splitting и анализ размера

Как уменьшить JavaScript-бандл в Vite и Webpack: tree shaking, code splitting, vendor splitting и анализ размера. Рабочие примеры конфигов, чек-лист из 10 шагов и реальные кейсы Dropbox и redBus.

Почему размер 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 шагов к лёгкому бандлу

  1. Проанализируйте текущий бандл — запустите webpack-bundle-analyzer или rollup-plugin-visualizer и посмотрите, что вообще происходит
  2. Проверьте импорты — замените import _ from 'lodash' на именованные импорты из lodash-es
  3. Настройте sideEffects — добавьте свойство в package.json, не забыв исключить CSS
  4. Убедитесь, что Babel не ломает ESM — поставьте modules: false в @babel/preset-env
  5. Разделите роутыReact.lazy или динамические импорты в Vue Router
  6. Вынесите vendor-чанки — React, UI-библиотеки должны жить в отдельных файлах
  7. Сузьте browserslist — уберите поддержку старых браузеров и избавьтесь от ненужных полифилов
  8. Замените тяжёлые библиотекиmoment.js (330 КБ) → date-fns (tree-shakeable), lodash (70 КБ) → lodash-es
  9. Задайте performance budgets — пусть CI падает при превышении лимита, иначе бандл будет расти
  10. Мониторьте размер на каждом 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 пока нет нативного аналога, так что спешить с переездом не стоит.

Об авторе Editorial Team

Our team of expert writers and editors.