Оптимизация JS-бандла: что реально влияет на скорость
Минификация урезала бандл на 15%, а Time to Interactive не сдвинулся. Разбираем, что в размере бандла реально бьёт по скорости, а что — оптимизация ради строчки в отчёте.

Типичная картина: бандл на 800 КБ ужали до 680 КБ через минификацию и tree-shaking, отчёт webpack-bundle-analyzer радует глаз, а метрики в Lighthouse не сдвинулись. Time to Interactive как был 4,2 секунды на среднем Android, так и остался. Причина в том, что размер в килобайтах и время до интерактивности связаны не так прямо, как кажется. Разбираем, какие оптимизации бандла двигают реальные метрики, а какие — только цифру в отчёте.
Материал про то, что влияет на скорость загрузки и исполнения JS в браузере. Сборку (Webpack 5, Vite, esbuild) и CI-пайплайн не рассматриваем — это отдельная тема. Речь про то, что происходит после того, как файл ушёл к клиенту.
Три разных «размера», которые путают
Когда говорят «размер бандла», обычно имеют в виду одно число. На деле важны три, и оптимизируются они по-разному:
- Размер по сети — сколько байт реально едет по проводу. Это gzip- или brotli-сжатый размер, а не то, что показывает
ls -la dist/. Влияет на время загрузки, критично на медленных сетях. - Размер после распаковки — сколько парсер получает на вход. Влияет на время парсинга и компиляции.
- Стоимость исполнения — сколько CPU-времени уходит на то, чтобы код отработал при старте. С размером связана слабо: 50 КБ кода, крутящего тяжёлый цикл при инициализации, могут блокировать поток дольше, чем 300 КБ, которые просто регистрируют обработчики.
Минификация уменьшает размер после распаковки, но почти не трогает сетевой размер: gzip и так хорошо жмёт повторяющиеся имена. И совсем не трогает стоимость исполнения — код делает то же самое.
Отсюда правило: прежде чем оптимизировать, поймите, во что упираетесь. Долгая загрузка на 3G — работайте с сетевым размером. Фриз главного потока на старте у людей с быстрым интернетом — работайте со стоимостью исполнения.
Что реально двигает метрики
Разделение кода по маршрутам
Самый крупный рычаг для многостраничных SPA. Если пользователь на странице логина тянет код дашборда, аналитики и админки — он платит за то, что не откроет. Динамический импорт разбивает бандл на чанки, которые грузятся по требованию:
// React Router 6.x + React.lazy
import { lazy, Suspense } from 'react';
// Код дашборда попадёт в отдельный чанк
const Dashboard = lazy(() => import('./pages/Dashboard'));
function AppRoutes() {
return (
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
}Эффект измеряется не в общем размере (он даже вырастет из-за накладных расходов на чанки), а в размере начального бандла — того, что грузится до первого рендера. Именно он определяет FCP и TTI.
Отложенная загрузка тяжёлых зависимостей
Библиотеки для графиков, редакторы кода, обработка изображений на клиенте — их часто импортируют статически, хотя нужны они по клику. Один редактор вроде CodeMirror или monaco-editor легко весит больше, чем весь остальной начальный бандл вместе взятый.
// Грузим редактор только когда пользователь открыл модалку
async function openEditor(container) {
const { EditorView, basicSetup } = await import('codemirror');
new EditorView({
doc: '',
extensions: [basicSetup],
parent: container,
});
}Замена тяжёлых зависимостей на лёгкие
Классический пример — moment.js: он тянет локали и весит в разы больше современных альтернатив вроде date-fns или встроенного Intl.DateTimeFormat. Проверьте на bundlephobia.com размер каждой крупной зависимости перед тем, как её добавлять. Иногда функция, ради которой подключили библиотеку на 40 КБ, пишется в десять строк.
Tree-shaking, который действительно работает
Tree-shaking удаляет неиспользуемый экспорт, но только если код это позволяет. Импорт всей библиотеки одной строкой ломает его:
// Плохо: подтягивает всю библиотеку, tree-shaking бессилен
import _ from 'lodash';
_.debounce(fn, 300);
// Хорошо: импорт по имени из ESM-пакета
import { debounce } from 'lodash-es';
debounce(fn, 300);Работает при двух условиях: пакет поставляется в формате ES-модулей (поле "module" или "exports" в его package.json) и в нём проставлен sideEffects: false. Если пакет — CommonJS, tree-shaking по нему не сработает вообще, вырезать мёртвый код бандлер не сможет.
Что почти не влияет, а кажется важным
| Оптимизация | На что влияет | Стоит ли времени |
|---|---|---|
| Минификация имён переменных | Размер после распаковки; сетевой почти нет (gzip компенсирует) | Делает сборщик по умолчанию, руками трогать не нужно |
| Удаление комментариев | Размер после распаковки, минимально | Автоматически, отдельного внимания не требует |
| Разбиение vendor-чанка «чтобы кешировался» | Повторные визиты; на первую загрузку не влияет | Полезно, но не для скорости первого визита |
| Замена длинных имён классов CSS-in-JS | Стоимость исполнения при рантайм-генерации стилей | Только если профайлер показал, что стили — узкое место |
Как измерять, а не гадать
Оптимизировать вслепую — терять время. Порядок такой:
- Соберите продакшн-сборку и посмотрите состав бандла. Для Webpack — webpack-bundle-analyzer, для Vite — плагин rollup-plugin-visualizer. Ищите неожиданно крупные зависимости в начальном чанке.
- Снимите метрики на реальном профиле. Lighthouse в Chrome DevTools с эмуляцией «Slow 4G» и «4x CPU slowdown» ближе к среднему пользователю, чем ваш ноутбук на оптоволокне. Смотрите FCP, LCP, TBT (Total Blocking Time).
- Откройте вкладку Performance, запишите загрузку. Жёлтые блоки Scripting — это парсинг и исполнение. Если там долгие непрерывные задачи (long tasks >50 мс) — проблема в стоимости исполнения, размер тут ни при чём.
- Сделайте одну оптимизацию. Замерьте снова на том же профиле. Изменение метрики — единственное доказательство, что оптимизация сработала.
Правило одного изменения: меняете за раз что-то одно и перемеряете. Иначе разделение кода и замену библиотеки применили вместе, метрика улучшилась, а какая из двух правок сработала — неизвестно.
Где подход ломается
Разделение кода не бесплатно. Каждый динамический импорт — это отдельный сетевой запрос и точка, где интерфейс покажет спиннер. Если раздробить приложение на полсотни микрочанков, пользователь на медленной сети получит водопад запросов и рваную загрузку вместо одной паузы. На HTTP/1.1 это особенно больно из-за лимита параллельных соединений; на HTTP/2 и HTTP/3 мультиплексирование смягчает, но не отменяет накладные расходы.
Второй край — преждевременная ленивая загрузка. Если вынести в отдельный чанк компонент, который открывается в 90% сессий сразу после старта, вы добавили задержку и запрос там, где выгоднее было держать код в начальном бандле. Ленивую загрузку применяют к тому, что нужно редко или не сразу.
Третий — кеш и хеши в именах файлов. Разбили vendor-чанк ради кеширования, но обновление одной мелкой зависимости меняет хеш всего чанка — и пользователи качают его заново. Группируйте зависимости по частоте изменений, а не по алфавиту.
Что делать дальше
Начните с состава начального бандла: откройте анализатор и найдите, что в нём не должно быть. Вынесите по маршрутам то, что не нужно на первом экране. Затем снимите Performance-профиль на замедленном CPU и проверьте Total Blocking Time — если он высокий при небольшом бандле, дело в исполнении, и следующий шаг не в размере, а в том, чтобы не блокировать главный поток на старте. Настройте бюджет размера в CI (в Webpack — performance.maxAssetSize), чтобы бандл не пополз обратно после следующего десятка коммитов.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: web.dev — Reduce JavaScript payloads with code splitting, webpack — Tree Shaking
Частые вопросы
Я минифицировал бандл, но Lighthouse не изменился. Что не так?
У меня Webpack 5, а примеры на динамический импорт универсальные?
Стоит ли выносить каждую страницу в отдельный чанк?
lodash-es не уменьшил бандл — почему?
Как понять, что проблема в загрузке, а не в исполнении?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.