Размер бандла и анализ

Учимся смотреть, из чего на самом деле состоит ваш JS-бандл, и выкидывать оттуда всё, за что пользователь платит временем, но ничего не получает.

Бандл (bundle) — один или несколько JS-файлов, которые сборщик (Vite, webpack, Rollup, esbuild) склеивает из вашего кода и всех подключённых библиотек и отдаёт браузеру.

Почему 2 МБ JavaScript — приговор на мобильном

Начинающие разработчики меряют бандл в мегабайтах и думают про скачивание: «ну, лишняя секунда на загрузку, переживём». Проблема в том, что скачивание — самая дешёвая часть истории. Картинка на 2 МБ и скрипт на 2 МБ стоят одинаково по трафику, но совершенно по-разному по времени. Картинку браузер декодирует в отдельном потоке и показывает. Скрипт нужно распарсить, скомпилировать и выполнить — и всё это происходит в главном потоке, том самом, который рисует страницу и отвечает на нажатия.

Пока движок разбирает ваш мегабайт, страница не реагирует ни на что: тапы копятся в очереди, спиннер замирает, поле ввода не принимает символы. Отсюда правило: JavaScript — единственный ресурс, который блокирует пользователя дважды — сначала сетью, потом процессором.

И вот здесь важно, на чём вы это тестируете. Разработчик открывает сайт на MacBook по офисному Wi-Fi — всё летает. Реальный пользователь сидит на среднем Android за 15 тысяч рублей, где процессор в 4–6 раз медленнее вашего, и на нестабильном 4G. То, что у вас занимает 200 мс, у него займёт секунду и больше. Прикиньте по порядку величины:

Что происходит с 1 МБ JSНоутбук разработчикаСредний Android
Передача по сети (после сжатия ~300 КБ)~0,1 с~1–2 с на медленном 4G
Парсинг + компиляция~100–150 мс~500–700 мс
Исполнение (инициализация фреймворка, стора, роутера)~150 мс~700–1000 мс

Обратите внимание на важную деталь: по сети едет сжатый размер (gzip или brotli, обычно в 3–4 раза меньше), а парсит и исполняет браузер распакованный. Поэтому смотреть надо на обе цифры: gzip отвечает за сеть, исходный размер — за нагрузку на процессор. «У нас всего 300 КБ гзипом» звучит успокаивающе ровно до того момента, пока вы не вспомните, что это миллион с лишним символов кода, который телефон обязан разобрать.

Шаг первый: посмотреть, что внутри

Оптимизировать бандл вслепую бессмысленно. Сначала — карта: какие библиотеки сколько занимают. Все современные сборщики умеют это показывать, обычно одной командой.

# сначала честная прод-сборка — только она показывает реальные размеры
npm run build

# Vite / Rollup — интерактивная карта чанков в браузере
npx vite-bundle-visualizer

# webpack (в т.ч. Next.js) — классический анализатор
npx webpack-bundle-analyzer dist/stats.json

# универсальный вариант: работает с любым сборщиком, читает source maps
npx source-map-explorer 'dist/assets/*.js'

На выходе — «плитка», где площадь прямоугольника пропорциональна вкладу модуля в итоговый файл. Читать её надо так: ищите не мелочь, а крупные плитки, о существовании которых вы не подозревали. Классика жанра — библиотека дат со всеми локалями мира, полный набор иконок, из которого вы используете три штуки, тяжёлая библиотека графиков ради одного спарклайна, случайно затянутый в бандл polyfill.

Полезно закрепить анализ в скриптах, чтобы его запускал не только тот, кто про него помнит:

{
  "scripts": {
    "build": "vite build",
    "analyze": "vite build && vite-bundle-visualizer"
  }
}

Шаг второй: выкинуть тяжёлые зависимости

Самая быстрая победа в этой игре — замена одной библиотеки на другую, с тем же смыслом, но в десять раз легче. Хрестоматийный пример — moment: она давно в режиме поддержки, тянет за собой все локали и принципиально не поддаётся tree shaking, потому что написана в старом стиле.

БиблиотекаПорядок размера (min+gzip)Комментарий
moment~70 КБмонолит, локали тянутся целиком
dayjs~3 КБпочти тот же API, плагины по необходимости
date-fns1–3 КБ за функциючестный tree shaking при точечных импортах
lodash целиком~25 КБобычно ради двух-трёх функций

Переезд с moment на dayjs в типичном приложении выглядит почти как замена одной строчки импорта — API специально сделан похожим:

// БЫЛО: 70 КБ gzip ради форматирования даты
import moment from 'moment';
import 'moment/locale/ru';

export const formatDate = (iso) => moment(iso).locale('ru').format('D MMMM YYYY');

// СТАЛО: ~3 КБ gzip, тот же результат
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
import localizedFormat from 'dayjs/plugin/localizedFormat';

dayjs.extend(localizedFormat);

export const formatDate = (iso) => dayjs(iso).locale('ru').format('D MMMM YYYY');

И честный вопрос, который стоит задавать первым: а нужна ли библиотека вообще? Для форматирования даты в браузере уже сто лет есть встроенный Intl.DateTimeFormat, который весит ноль байт, потому что живёт в самом браузере.

Шаг третий: tree shaking

Tree shaking — выбрасывание из бандла кода, который никто не импортирует. Работает только тогда, когда сборщик может статически, не запуская код, доказать, что модуль не используется.

Отсюда все ограничения. Tree shaking живёт на ES-модулях (import/export): их структура известна до исполнения. На CommonJS (require) он в общем случае бессилен — что там внутри, выяснится только в рантайме. Поэтому импорты пишутся так:

// ❌ подтягивает всю библиотеку целиком
import _ from 'lodash';
_.debounce(fn, 300);

// ✅ вариант 1: ESM-сборка + именованный импорт
import { debounce } from 'lodash-es';

// ✅ вариант 2: точечный импорт одного модуля
import debounce from 'lodash/debounce';

// ❌ типичная ловушка: «бочка» (barrel file) на весь пакет иконок
import { IconUser } from '@my/icons';        // index.js реэкспортирует 800 иконок

// ✅ импорт напрямую из файла иконки
import IconUser from '@my/icons/IconUser';

Второй ингредиент — поле sideEffects в package.json. Им пакет сообщает сборщику: «мои модули ничего не делают при импорте, если ты их выкинешь — ничего не сломается». Без этой пометки сборщик обязан осторожничать и оставлять код на всякий случай.

{
  "name": "@my/ui-kit",
  "sideEffects": ["*.css", "*.scss"]
}

Здесь мы честно предупреждаем: JS-модули чистые, а вот импорты стилей — побочный эффект, их трогать нельзя. Написать просто "sideEffects": false и забыть про CSS — классический способ получить приложение без стилей в проде.

Шаг четвёртый: бюджет, чтобы не откатиться назад

Бандл худеет один раз, а толстеет каждый спринт: кто-то добавил «маленькую» библиотеку, кто-то импортировал всё из barrel-файла. Лечится это не силой воли, а бюджетом размера, который проверяется в CI и роняет сборку.

{
  "size-limit": [
    { "path": "dist/assets/index-*.js", "limit": "120 kB" },
    { "path": "dist/assets/vendor-*.js", "limit": "100 kB" }
  ]
}
npx size-limit          # локально
npx size-limit --why    # покажет, кто именно раздул бандл

Как это работает

Внутри браузера путь скрипта такой: сеть → парсинг → компиляция → исполнение. Парсер строит AST, движок компилирует байт-код, потом код исполняется, и только исполнение действительно «оживляет» интерфейс (навешивает обработчики, гидрирует разметку). Движки хитрят: они парсят функции лениво, компилируют «горячие» участки заново, стримят парсинг параллельно со скачиванием. Но ни один трюк не отменяет главного — работа делается в главном потоке и её объём пропорционален количеству кода.

Именно поэтому неиспользуемый код так дорог: он не выполняет полезной работы, но его всё равно скачали, распарсили и скомпилировали. В инструментах разработчика Chrome есть вкладка Coverage: она красным подсвечивает строки, которые не выполнились ни разу за сессию. На типичном сайте таких строк 40–70 %.

Частые ошибки

  • Мерить бандл в dev-режиме. В dev-сборке нет минификации, зато есть HMR-рантайм и source maps. Реальные цифры даёт только прод-сборка.
  • Смотреть только на gzip-размер. Он про сеть. Процессор ест распакованный код, и на телефоне это часто больнее.
  • Оптимизировать свой код, не тронув зависимости. В типичном приложении 70–90 % бандла — это node_modules. Ваши компоненты там — тонкая полоска.
  • Верить, что tree shaking «сам всё выкинет». Он не выкинет CommonJS-пакет, не выкинет модуль с побочными эффектами и не спасёт от import * as X.
  • Тянуть весь UI-кит ради трёх компонентов через barrel-импорт. Проверьте, есть ли у пакета точечные пути импорта.
  • Грузить polyfills всем. Если вы поддерживаете современные браузеры, наборы вроде core-js в полном объёме — это сотни лишних килобайт для 100 % пользователей ради 0,5 %.

Итоги

  • JavaScript блокирует пользователя дважды: временем сети и временем главного потока. На среднем Android процессорная часть в разы дороже сетевой.
  • Начинайте с карты бандла: vite-bundle-visualizer, webpack-bundle-analyzer или source-map-explorer по прод-сборке.
  • Самые быстрые победы — замена монолитных библиотек (momentdayjs/Intl) и точечные импорты вместо импорта пакета целиком.
  • Tree shaking требует ESM, именованных импортов и честного sideEffects; на CommonJS он не работает.
  • Зафиксируйте достигнутое бюджетом размера в CI — иначе бандл вернёт вес за пару спринтов.
Проверьте себя
1. Почему 2 МБ JavaScript на мобильном хуже, чем картинка на 2 МБ?
AJavaScript нельзя сжать gzip, а картинку можно
BJS нужно не только скачать, но и распарсить, скомпилировать и выполнить в главном потоке — интерфейс всё это время не отвечает
CБраузер запрещает кешировать большие JS-файлы
DJavaScript всегда загружается последним, после всех остальных ресурсов
2. Какой импорт с наибольшей вероятностью НЕ будет вытрясен tree shaking?
Aimport { debounce } from 'lodash-es';
Bimport debounce from 'lodash/debounce';
Cimport _ from 'lodash';
Dimport { format } from 'date-fns';