Сборка фронтенда: Vite, бандлеры, tree-shaking, code splitting, dev-server
В прошлой статье мы работали с DOM напрямую — открыли index.html, подключили скрипт, всё поехало. Реальный проект так не живёт: у него сотни модулей, TypeScript, JSX, CSS-модули, картинки, переменные окружения, три окружения деплоя и требование уложиться в 2.5 секунды LCP на мобильном интернете. Между «файлами, которые вы пишете» и «байтами, которые получает браузер» стоит слой сборки. Он невидим, пока работает, и съедает недели, когда сломан.
Эта статья — про то, что именно делает этот слой, почему он делает это именно так и какие рычаги у вас есть. Про язык TypeScript здесь не будет ничего — он разобран в отдельном треке; нас интересует платформа: модули, граф, чанки, кэш и сеть.
Зачем вообще бандлер, если браузер умеет модули
С 2018 года все браузеры понимают <script type="module"> и нативный import. Логичный вопрос: почему бы не отдавать исходники как есть?
Попробуйте — и упрётесь в пять стен.
Bare-импорты не существуют для браузера. Строка import React from "react" для него не значит ничего: это не URL и не относительный путь. Спецификация требует либо путь, либо запись в import map. Разрешение имени в файл — работа сборщика (или, в рантайме, import map, который тоже кто-то должен сгенерировать).
Водопад запросов. Модуль A импортирует B, B импортирует C. Браузер узнаёт о C только после того, как скачал и распарсил B. Глубина графа превращается в глубину сетевых волн, каждая стоит один RTT. На мобильной сети с RTT 200 мс граф глубины 5 — это лишняя секунда до первой отрисовки.
Сжатие работает хуже. Gzip и Brotli строят словарь по потоку: один файл на 100 КБ сжимается заметно лучше, чем сто файлов по килобайту, где каждый начинает словарь заново. Плюс на каждый запрос — заголовки, проверка кэша, приоритизация.
Мёртвый код никто не удалит. Браузер обязан выполнить всё, что вы ему прислали. Удаление неиспользуемого возможно только при статическом анализе всего графа, а это ровно то, что делает бандлер.
Браузер не понимает ничего, кроме JS, CSS и HTML. TypeScript, JSX, Vue SFC, SCSS, импорт SVG как компонента, ?raw, ?url, WASM — всё это надо во что-то превратить.
Отсюда честная формулировка: бандлер — это компилятор из вашего дерева исходников в оптимальную для сети и для движка форму. И, как у любого компилятора, у него есть фазы.
Конвейер: что происходит между src/ и dist/
Любой сборщик — webpack, Rollup, esbuild, Rolldown, Parcel — реализует один и тот же конвейер. Названия фаз отличаются, суть нет.
index.html, main.tsx"] --> R R["resolve: имя → конкретный файл
node_modules, alias, exports, extensions"] --> L L["load: прочитать содержимое
с диска, из виртуального модуля, из сети"] --> T T["transform: TS→JS, JSX→вызовы,
CSS→модуль, SVG→компонент"] --> P P["parse: построить AST,
найти import и export"] --> G G{"Есть неразобранные
импорты?"} G -- да --> R G -- нет --> S["Граф модулей построен"] S --> TS["tree-shaking:
пометить достижимые экспорты"] TS --> C["chunking: разложить модули
по выходным файлам"] C --> M["minify: сжать имена,
выкинуть пробелы и мёртвые ветки"] M --> H["hash: имя файла
от контента"] H --> O["emit: dist/ + манифест
+ sourcemap"]
Три наблюдения, которые объясняют почти все дальнейшие решения.
Во-первых, фазы resolve → load → transform — это плагинный API. Именно сюда встраиваются @vitejs/plugin-react, vite-plugin-svgr, обработка ?worker. Rollup задал этот интерфейс, Vite его расширил, Rolldown и Rspack его повторяют — поэтому экосистема плагинов переносима.
Во-вторых, tree-shaking и chunking требуют полного графа. Нельзя пошейкать половину проекта. Это фундаментальная причина, почему прод-сборка не бывает мгновенной и почему инкрементальность здесь сложна.
В-третьих, hash в конце — это контракт с HTTP-кэшем: имя файла зависит от содержимого, поэтому файл можно отдавать с Cache-Control: max-age=31536000, immutable.
Модули: ESM, CommonJS и почему это до сих пор больно
Сборщик работает с графом импортов, а в JS-экосистеме их исторически два.
// CommonJS — динамический, рантаймовый
const { debounce } = require('lodash'); // вычисляется при исполнении
if (process.env.NODE_ENV === 'test') {
module.exports = require('./mock'); // экспорт может измениться на ходу
}
// ESM — статический, анализируемый до исполнения
import { debounce } from 'lodash-es'; // связывание на этапе разбора
export { debounce }; // набор экспортов известен заранее
Разница не косметическая. require — обычный вызов функции: его аргумент может быть выражением, результат — присвоен куда угодно, module.exports — переприсвоен в цикле. Статически доказать, что lodash.debounce не используется, нельзя. import — синтаксическая конструкция: имена фиксированы, порядок вычисления определён, связывание происходит до исполнения тела. Tree-shaking возможен только для ESM. Это единственная причина, по которой стоит выбирать lodash-es вместо lodash, а date-fns — вместо moment.
Пакет объявляет свою природу в package.json:
{
"name": "@acme/ui",
"type": "module",
"sideEffects": ["*.css", "./src/polyfills.ts"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
},
"files": ["dist"],
"peerDependencies": { "react": ">=18" }
}
Что здесь важно:
"type": "module"— все.jsв пакете трактуются как ESM. Файлы CommonJS придётся назвать.cjs."exports"закрывает пакет: импортировать@acme/ui/dist/internal/foo.jsстанет нельзя. Это фича — так задаётся публичный контракт. Порядок ключей значим, они проверяются сверху вниз;typesвсегда первым."sideEffects"— обещание сборщику: «файлы, кроме перечисленных, можно выкинуть целиком, если из них ничего не импортируют». Об этом ниже, это главный рычаг tree-shaking.- Dual package hazard: если один и тот же пакет попадёт в бандл и как ESM, и как CJS, вы получите два экземпляра модуля с двумя разными состояниями. Классический симптом — «мой React-контекст пуст» или «instanceof возвращает false». Проверяйте
npm ls react, а для библиотек — publint и Are the types wrong?.
Короткая история: откуда взялась текущая расстановка
Логика этой эволюции простая: сначала научились строить граф (webpack), потом научились его оптимизировать (Rollup, tree-shaking), потом обнаружили, что JS-инструменты слишком медленны для больших проектов, и переписали горячие фазы на Go и Rust. Vite стал стандартом не потому, что он быстрее всех в проде, а потому что он первым разделил две задачи: быстрый цикл разработки и оптимальный прод-артефакт.
Vite: два инструмента под одним именем
Это главный источник непонимания. vite и vite build работают принципиально по-разному.
В режиме разработки Vite вообще не бандлит ваш код. Он поднимает HTTP-сервер, отдаёт index.html, а дальше браузер сам запрашивает модули по одному. Сервер перехватывает каждый запрос, на лету превращает TS в JS (esbuild, десятки микросекунд на файл, типы просто стираются) и переписывает import React from "react" в import React from "/node_modules/.vite/deps/react.js".
Зависимости — отдельная история. Их Vite предбандлит один раз при старте:
- CommonJS → ESM. Половина npm до сих пор в CJS, браузер её не съест.
- Схлопывание запросов.
lodash-es— это 640 отдельных файлов. Без предбандла первыйimport { debounce }порождает 640 HTTP-запросов, и вкладка Network умирает.
Результат кладётся в node_modules/.vite/deps и кэшируется по хэшу от package.json и конфига. Отсюда народный рецепт «удали node_modules/.vite и перезапусти» — он чинит именно рассинхрон этого кэша.
В режиме сборки Vite отдаёт всё Rollup: полный граф, tree-shaking, чанки, минификация, хэши. С 2025 года есть rolldown-vite — drop-in замена, где Rollup заменён на Rolldown (Rust, API-совместимый). Ставится одной строкой в package.json через overrides, и на крупных проектах даёт кратное ускорение сборки.
Практическое следствие: dev и prod идут разными кодовыми путями, поэтому баг «воспроизводится только в проде» — норма, а не аномалия. Перед мержем ветки полезно прогонять vite build && vite preview хотя бы в CI.
Dev-сервер: что происходит при сохранении файла
вверх по графу до HMR-границы V-->>B: WS: {type:"update", path:"/src/App.tsx", timestamp} B->>V: GET /src/App.tsx?t=1738000000 V-->>B: новая версия модуля B->>B: вызывает accept-колбэк границы,
состояние остальных компонентов живо
Ключевая идея HMR: при изменении файла нужно найти границу принятия (HMR boundary) — ближайший модуль вверх по графу, который умеет обработать обновление своих зависимостей. Если такого нет до самого корня, происходит полная перезагрузка страницы.
и его импортёров ПоискГраницы --> ЕстьГраница: нашли import.meta.hot.accept ПоискГраницы --> НетГраницы: дошли до корня графа ЕстьГраница --> Применение: запрос новой версии по HTTP Применение --> Dispose: старый модуль отдаёт
своё состояние через hot.dispose Dispose --> Актуален: accept-колбэк подставляет новый модуль НетГраницы --> FullReload: location.reload FullReload --> [*] Применение --> FullReload: колбэк бросил исключение
Плагины React и Vue ставят границу на каждый компонент автоматически — поэтому вы правите JSX и не теряете состояние формы. Для своего кода граница пишется руками:
// src/store/settings.ts — модуль с состоянием, которое хочется пережить обновление
export const settings = { theme: 'dark', locale: 'ru' };
if (import.meta.hot) {
// принимаем обновление самих себя
import.meta.hot.accept((newModule) => {
if (newModule) Object.assign(settings, newModule.settings);
});
// отдаём состояние преемнику перед выгрузкой
import.meta.hot.dispose((data) => {
data.settings = { ...settings };
});
// подхватываем состояние от предшественника
if (import.meta.hot.data.settings) {
Object.assign(settings, import.meta.hot.data.settings);
}
}
Типичная ловушка: HMR «не работает» у модуля с побочными эффектами вроде подписки на глобальное событие. Каждое обновление добавляет ещё один слушатель, приложение начинает вести себя странно, разработчик винит Vite. Лечение — снимать эффект в hot.dispose, ровно как в useEffect cleanup.
Рабочий конфиг
Ниже конфигурация, которую можно скопировать в проект и запустить. Это не «минимальный пример», а то, что обычно нужно продукту.
// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig(({ mode, command }) => {
// третий аргумент '' — грузить все переменные, не только с префиксом VITE_
const env = loadEnv(mode, process.cwd(), '');
const isProd = command === 'build';
return {
plugins: [
react(),
// отчёт о составе бандла: dist/stats.html, treemap по чанкам
isProd && visualizer({ filename: 'dist/stats.html', gzipSize: true, brotliSize: true }),
].filter(Boolean),
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
// прокси убирает CORS и повторяет прод-схему путей
proxy: {
'/api': {
target: env.API_URL ?? 'http://localhost:8080',
changeOrigin: true,
// ws: true — если нужен проброс вебсокетов
},
},
// предпрогрев горячих файлов: убирает задержку первого открытия
warmup: { clientFiles: ['./src/main.tsx', './src/App.tsx'] },
},
build: {
// современный таргет: без лишних полифиллов и трансформаций
target: 'baseline-widely-available',
sourcemap: 'hidden', // карты генерим, но не ссылаемся на них из бандла
cssCodeSplit: true,
chunkSizeWarningLimit: 400, // КБ, до минификации — сигнал «пора резать»
assetsInlineLimit: 4096, // мельче 4 КБ → base64 прямо в JS/CSS
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
// отдельные чанки для крупных редко меняющихся библиотек
if (id.includes('react') || id.includes('scheduler')) return 'vendor-react';
if (id.includes('echarts') || id.includes('zrender')) return 'vendor-charts';
// всё остальное Rollup разложит сам — это обычно лучше ручного «vendor-all»
},
},
},
},
// управление предбандлом зависимостей в dev-режиме
optimizeDeps: {
include: ['react', 'react-dom/client'], // заранее, чтобы не было перезапуска
exclude: ['@acme/ui'], // локальный пакет монорепо — пусть идёт через HMR
},
define: {
__APP_VERSION__: JSON.stringify(env.npm_package_version),
},
};
});
Два поля заслуживают отдельного комментария.
build.target определяет, до какого синтаксиса даунлевелить код. baseline-widely-available (значение по умолчанию с Vite 7) означает «фичи, доступные во всех основных браузерах уже больше 30 месяцев». Понижение таргета до ES2015 добавляет к бандлу десятки процентов кода на трансформацию async/await в генераторы и классов в функции — ради аудитории, которой у вас, вероятно, нет. Решать по данным аналитики, а не по привычке.
sourcemap: 'hidden' — карты собираются, но комментарий //# sourceMappingURL в бандл не пишется. Карты загружаются в Sentry на этапе деплоя и удаляются из публичной раздачи. Иначе вы фактически публикуете исходники.
Tree-shaking: как он работает и почему у вас не работает
Формально tree-shaking — это анализ достижимости в графе экспортов. Сборщик начинает с точки входа, помечает использованные экспорты, рекурсивно идёт по их зависимостям, а всё непомеченное удаляет. Сложность — линейная по числу узлов и рёбер графа, O(V + E), память — тоже O(V + E), поскольку граф целиком держится в памяти. Это, кстати, объясняет, почему прод-сборка большого монорепо требует пары гигабайт heap и почему в CI вылетает JavaScript heap out of memory.
Проблема в другом: удаление кода корректно, только если код не имеет побочных эффектов. Рассмотрим:
// utils/analytics.ts
console.log('модуль загружен'); // побочный эффект!
window.__analyticsReady = true; // и ещё один
export function track(event: string) { /* ... */ }
export function identify(id: string) { /* ... */ }
Если вы импортируете только track, сборщик обязан всё равно выполнить тело модуля — иначе поведение изменится. Он выкинет identify, но сам модуль оставит.
Пять реальных причин, почему шейкинг не срабатывает:
1. CommonJS в зависимости. require не анализируется. Проверка: grep -r "require(" node_modules/пакет/dist | head. Лечение: искать ESM-сборку пакета (поле module или exports.import).
2. Отсутствие sideEffects. Без этого поля сборщик обязан считать, что эффекты есть везде. Одна строка в package.json библиотеки часто режет её вклад в бандл вдвое:
{ "sideEffects": false }
или, если CSS-импорты реальны:
{ "sideEffects": ["**/*.css", "**/*.scss"] }
3. Barrel-файлы. Самая частая беда больших кодовых баз:
// src/components/index.ts — «удобный» barrel
export * from './Button';
export * from './Modal';
export * from './DataGrid'; // тянет за собой ag-grid на 400 КБ
Импорт import { Button } from '@/components' формально шейкается, но: (а) сборщик обязан загрузить и разобрать все файлы barrel-а, что бьёт по времени сборки и по холодному старту dev-сервера; (б) достаточно одному из них иметь побочный эффект, чтобы шейкинг сломался. В DataGrid.tsx наверняка есть import 'ag-grid/styles.css' — и всё, 400 КБ приехали. Практика: импортируйте по прямому пути, barrel держите только для публичного API пакета.
4. Классы и методы. Классовые методы не шейкаются: сборщик не может доказать, что метод не вызовут через obj[name](). Если библиотека — один большой класс class SDK { methodA(){} ... methodZ(){} }, вы получите весь SDK. Функции с именованными экспортами шейкаются, классы — нет. Именно поэтому Firebase v9 переехал с firebase.auth().signIn() на signIn(auth).
5. Вызовы верхнего уровня. const client = createClient(config) на верхнем уровне модуля — побочный эффект с точки зрения анализатора. Если вы уверены, что вызов чистый, помечайте:
export const icons = /*#__PURE__*/ buildIconRegistry();
// аннотация разрешает удалить весь вызов, если icons не используется
Проверять результат надо не по ощущениям, а по цифрам:
# treemap по чанкам: что и сколько занимает
npx vite build && open dist/stats.html
# альтернатива, работает по sourcemap любого бандла
npx source-map-explorer 'dist/assets/*.js'
# что реально исполнилось на странице — DevTools → Coverage → Record
Code splitting: резать по маршрутам, а не по файлам
Разбиение на чанки решает одну задачу: не отдавать пользователю код, который ему сейчас не нужен. Инструмент один — динамический import(), который сборщик воспринимает как точку разреза графа.
// src/router.tsx — разрез по маршрутам, базовый и самый выгодный случай
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
// каждый lazy() создаёт отдельный чанк
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Admin = lazy(() => import('./pages/Admin'));
export const router = createBrowserRouter([
{ path: '/', element: <Home /> }, // в основном бандле — открывают все
{
path: '/dashboard',
element: <Suspense fallback={<Skeleton />}><Dashboard /></Suspense>,
// loader стартует загрузку данных параллельно с чанком, а не после него
loader: ({ request }) => fetchDashboard(request.signal),
},
{ path: '/settings', element: <Suspense fallback={<Skeleton />}><Settings /></Suspense> },
{ path: '/admin', element: <Suspense fallback={<Skeleton />}><Admin /></Suspense> },
]);
Дальше — разрез по «тяжёлым и редким» кускам внутри страницы:
// Библиотека графиков на 300 КБ нужна только тем, кто дошёл до вкладки «Аналитика»
function AnalyticsTab({ data }: { data: Series[] }) {
const [Chart, setChart] = useState<ComponentType<ChartProps> | null>(null);
useEffect(() => {
let alive = true;
import('./HeavyChart').then((m) => { if (alive) setChart(() => m.default); });
return () => { alive = false; };
}, []);
if (!Chart) return <ChartSkeleton height={320} />;
return <Chart data={data} />;
}
// Ещё лучше — начинать загрузку заранее, на намерение пользователя
export function AnalyticsLink() {
return (
<a
href="/analytics"
onMouseEnter={() => import('./pages/Analytics')} // 200–300 мс форы
onFocus={() => import('./pages/Analytics')} // и то же самое для клавиатуры
>
Аналитика
</a>
);
}
Что резать, а что нет:
| Кандидат | Резать? | Почему |
|---|---|---|
| Маршруты | Всегда | Пользователь видит один экран за раз |
| Модалки, дровера, тултипы | Да, если тяжёлые | Открывает малая доля сессий |
| Редакторы кода, карты, графики, PDF | Обязательно | 200–800 КБ на одну фичу |
| Локали i18n | Да, по одной | Иначе везёте 30 языков ради одного |
| Полифиллы для старых браузеров | Да, условно | Современные браузеры не должны платить |
| Дизайн-система, роутер, React | Нет | Нужны сразу, разрез добавит RTT |
| Модуль на 3 КБ | Нет | Отдельный запрос дороже экономии |
Порог здравого смысла: чанк меньше ~15 КБ после сжатия обычно не стоит отдельного запроса — сеть съест выигрыш. Rollup умеет схлопывать такие мелочи сам, за это отвечают output.experimentalMinChunkSize и его эвристики.
Водопады: почему маленький бандл всё равно грузится медленно
Самая коварная проблема code splitting в том, что он добавляет сетевые волны. Вы честно нарезали приложение, суммарные байты упали вдвое, а LCP вырос.
Механика: HTML → entry-чанк → (исполнили, узнали про маршрут) → чанк маршрута → (исполнили, узнали про библиотеку графиков) → чанк библиотеки → (смонтировали компонент) → запрос данных. Четыре последовательные волны, каждая — минимум один RTT.
Три рычага против водопадов:
modulepreload в HTML. Vite сам вставляет <link rel="modulepreload"> для статических зависимостей входа — они уходят в сеть параллельно, а не после разбора entry. Для чанков, о которых известно заранее (например, чанк маршрута, куда попадает 80 % входов), ссылку стоит добавить руками или плагином.
<!-- в index.html, после сборки — обычно генерируется плагином из манифеста -->
<link rel="modulepreload" href="/assets/vendor-react-D9qL1x.js" crossorigin />
<link rel="prefetch" href="/assets/dashboard-Kf3Za8.js" />
Разница принципиальна: preload/modulepreload — «нужно сейчас, высокий приоритет»; prefetch — «понадобится потом, качай в простое, низкий приоритет». Путать их вредно: preload на то, что не пригодится, конкурирует за полосу с критическим путём.
Подъём импортов. Если чанк маршрута всегда тянет библиотеку графиков, import('charts') надо начинать в момент старта загрузки маршрута, а не после его исполнения. Проще всего — вернуть Promise.all из loader-а роутера.
Данные — параллельно с кодом. Запрос из useEffect стартует после монтирования, то есть после всех волн загрузки. Data-loader роутера или серверный рендеринг с данными в HTML убирают целую волну. Подробно — в статьях про работу с данными и стратегии рендеринга.
Кэширование: хэш в имени и цена ошибки
Прод-артефакт должен раздаваться с Cache-Control: max-age=31536000, immutable, а HTML — с no-cache. Тогда повторный визит стоит ровно один запрос за HTML, всё остальное берётся из кэша.
Работает это, пока хэш файла зависит только от его содержимого. И здесь есть неочевидная ловушка: чанк содержит не только модули, но и импорты соседних чанков по именам с хэшами. Изменили модуль в vendor-charts → поменялся его хэш → поменялось содержимое чанка, который его импортирует → поменялся и его хэш. Каскад инвалидации.
Отсюда правила разбиения на vendor-чанки:
// ПЛОХО: любое обновление любой зависимости выбивает 500 КБ кэша у всех пользователей
manualChunks: { vendor: ['react', 'react-dom', 'lodash-es', 'echarts', 'dayjs', /* ...30 пакетов */] }
// ЛУЧШЕ: группировать по частоте изменений и по маршрутам, где они нужны
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) return 'vendor-react';
if (/[\\/]node_modules[\\/](echarts|zrender)[\\/]/.test(id)) return 'vendor-charts';
if (/[\\/]node_modules[\\/]@sentry[\\/]/.test(id)) return 'vendor-obs';
// остальное не трогаем: автоматическая эвристика Rollup обычно точнее ручной
}
Ещё две вещи, которые ломают кэш незаметно:
defineс версией сборки.__APP_VERSION__попадает в код, значит хэш меняется при каждом релизе даже без изменений. Держите такие константы в одном крошечном чанке или читайте из<meta>.- Порядок модулей. Смена алгоритма чанкинга при обновлении сборщика перетасует всё. Это нормальная разовая цена, но не стоит делать её в день большого релиза.
Почему тормозит и как это измерять
Ключевое заблуждение: «размер бандла» — это про сеть. На самом деле байты стоят дважды.
Сеть. 200 КБ Brotli на медленном 4G — примерно 1–1.5 секунды.
CPU. Тот же файл после распаковки — под мегабайт исходного JS, который надо разобрать, скомпилировать и выполнить. На медианном Android-телефоне это сотни миллисекунд в главном потоке, где в это время не выполняются ни ваши обработчики, ни отрисовка. Байты JS дороже байтов картинок в разы: картинку декодирует отдельный поток, JS — нет.
Что именно мерить:
| Метрика | Порог «хорошо» (p75) | Что чинит сборка |
|---|---|---|
| LCP — отрисовка главного элемента | ≤ 2.5 с | размер критического JS/CSS, водопады, preload |
| INP — отклик на взаимодействие | ≤ 200 мс | длинные задачи, гидратация, тяжёлые чанки |
| CLS — сдвиги макета | ≤ 0.1 | порядок загрузки CSS и шрифтов |
| TBT в лаборатории | ≤ 200 мс | суммарная работа JS при старте |
| Вес JS на первый экран | ≤ 150–200 КБ сжато | tree-shaking, splitting |
Инструменты по порядку применения:
# 1. Лаборатория: что вообще происходит при загрузке
npx lighthouse https://example.com --preset=desktop --view
# мобильный профиль — по умолчанию, он же самый честный
# 2. Состав бандла: кто съел мегабайт
npx vite build && open dist/stats.html
# 3. Регрессия в CI: не даём бандлу расти незаметно
npx size-limit
// .size-limit.json — бюджет как код, падает в CI при превышении
[
{ "name": "entry", "path": "dist/assets/index-*.js", "limit": "60 kB" },
{ "name": "react vendor", "path": "dist/assets/vendor-react-*.js", "limit": "50 kB" },
{ "name": "критический CSS", "path": "dist/assets/index-*.css", "limit": "20 kB" },
{ "name": "первый экран целиком",
"path": ["dist/assets/index-*.js", "dist/assets/vendor-react-*.js"],
"limit": "120 kB" }
]
В DevTools нужны четыре панели:
- Network с throttling «Slow 4G» и CPU 4× — без этого вы измеряете свой ноутбук, а не пользователя. Колонка Waterfall показывает именно волны.
- Coverage (Cmd+Shift+P → Show Coverage) — сколько процентов загруженного кода реально исполнилось. 70 % неиспользованного JS на первом экране — обычная картина до оптимизации.
- Performance — вкладка Main, длинные задачи с красным углом. Всё, что дольше 50 мс, блокирует отклик.
- Performance insights / Lighthouse — готовые подсказки вида «удалите неиспользуемый JS» с указанием файлов.
И самое важное: лаборатория врёт. Реальные пороги проверяются на полевых данных — CrUX через PageSpeed Insights или собственный сбор библиотекой web-vitals:
// src/monitoring/vitals.ts — отправка реальных метрик пользователей
import { onLCP, onINP, onCLS, type Metric } from 'web-vitals';
function report(metric: Metric) {
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating, // good | needs-improvement | poor
path: location.pathname,
build: __APP_VERSION__, // связываем метрику с конкретным релизом
});
// sendBeacon переживает закрытие вкладки, в отличие от fetch
navigator.sendBeacon('/api/vitals', body);
}
onLCP(report);
onINP(report);
onCLS(report);
Дальше метрики агрегируются по p75 и рисуются на дашборде рядом с версией сборки — тогда регрессия видна в день релиза, а не в квартальном отчёте. Подробнее про бюджеты и оптимизацию — в статье про производительность фронтенда.
Трансформация: esbuild, SWC, Babel — что и когда
Три инструмента решают пересекающиеся задачи с разной ценой:
| Инструмент | Язык | Скорость | Что умеет | Когда нужен |
|---|---|---|---|---|
| esbuild | Go | ~100× Babel | TS→JS, JSX, минификация, таргет | dev-трансформация, минификация в Vite |
| SWC | Rust | ~20–70× Babel | то же + плагины на Rust, декораторы | Next.js, Rspack, замена Babel в проде |
| Babel | JS | базовая | всё, огромная экосистема плагинов | нестандартные трансформации, старые таргеты |
Главное, что нужно понимать про esbuild и SWC: они не проверяют типы. Они стирают их пофайлово, не имея представления о проекте целиком. Отсюда два следствия.
Во-первых, tsc --noEmit обязан быть в CI и в pre-commit — иначе типы просто перестанут проверяться. Локально удобен vite-plugin-checker, показывающий ошибки типов прямо в оверлее браузера.
Во-вторых, в tsconfig.json нужны настройки, обещающие пофайловую компилируемость:
{
"compilerOptions": {
"isolatedModules": true, // запрещает конструкции, некомпилируемые пофайлово
"verbatimModuleSyntax": true, // требует явный import type, иначе импорт останется в рантайме
"moduleResolution": "bundler", // резолвинг как у Vite/webpack: exports, без расширений
"noEmit": true, // эмитит сборщик, tsc только проверяет
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}
Без verbatimModuleSyntax легко получить импорт, который существует только ради типа, но остаётся в бандле и тянет за собой целый модуль.
Про старые браузеры. Если аналитика показывает заметную долю legacy, добавляется отдельная ветка сборки:
npm i -D @vitejs/plugin-legacy terser
import legacy from '@vitejs/plugin-legacy';
plugins: [
react(),
legacy({
targets: ['defaults', 'not IE 11'],
// современные браузеры получат modern-бандл, старые — nomodule + SystemJS + core-js
renderLegacyChunks: true,
}),
]
Механика — трюк module/nomodule: браузер с поддержкой ESM игнорирует <script nomodule>, старый — не понимает type="module". Так каждый получает свою версию. Цена — двойная сборка и +50–100 КБ полифиллов для legacy-аудитории; современная аудитория не платит ничего.
Ассеты, CSS и переменные окружения
Сборщик работает не только с JS. Импорт не-JS файла — это тоже узел графа:
import logoUrl from './logo.svg'; // → строка с URL и хэшем
import logoRaw from './logo.svg?raw'; // → содержимое файла строкой
import LogoIcon from './logo.svg?react'; // → React-компонент (vite-plugin-svgr)
import workerUrl from './heavy.ts?worker&url';
import styles from './Card.module.css'; // → объект { card: 'Card_card_a1b2' }
import './global.css'; // → побочный эффект: стили попадут в бандл
// динамический импорт всех локалей: Vite развернёт glob в статическую карту
const locales = import.meta.glob('./locales/*.json');
const ru = await locales['./locales/ru.json']();
import.meta.glob — важная деталь: он раскрывается на этапе сборки в объект с ленивыми импортами, поэтому каждая локаль становится своим чанком, а не тянется целиком.
CSS собирается параллельно с JS. При cssCodeSplit: true каждый асинхронный чанк получает свой CSS-файл, который Vite подгружает автоматически перед выполнением чанка — иначе был бы кадр с неоформленным контентом. Про архитектуру самих стилей — в статье о CSS-архитектуре.
Шрифты — частая причина плохого CLS и LCP, и решается это в сборке лишь наполовину:
<!-- preload только для шрифта, участвующего в первом экране -->
<link rel="preload" href="/assets/Inter-Var.woff2" as="font" type="font/woff2" crossorigin />
@font-face {
font-family: 'Inter';
src: url('/assets/Inter-Var.woff2') format('woff2-variations');
font-display: swap; /* показываем запасной шрифт сразу, не держим текст невидимым */
size-adjust: 107%; /* подгоняем метрики фолбэка, чтобы не было сдвига при замене */
}
Переменные окружения — источник регулярных утечек:
# .env.production
VITE_API_URL=https://api.example.com # попадёт в бандл, виден всем
DATABASE_PASSWORD=secret # НЕ попадёт: нет префикса VITE_
const api = import.meta.env.VITE_API_URL; // подставляется текстом на этапе сборки
if (import.meta.env.DEV) { /* эта ветка целиком вырезается в проде */ }
Префикс VITE_ — намеренный барьер: всё, что попадает в клиентский бандл, публично. Ключ, который вы «спрятали» в переменную окружения фронтенда, находится поиском по dist/. Секреты живут только на сервере.
Сборка библиотеки — отдельный режим
Приложение и библиотека собираются по-разному. Библиотека не должна включать в себя React, не должна минифицировать в ноль имена в публичном API и обязана отдавать типы.
// vite.config.ts пакета @acme/ui
export default defineConfig({
plugins: [react(), dts({ rollupTypes: true })], // vite-plugin-dts собирает .d.ts
build: {
lib: {
entry: { index: 'src/index.ts', icons: 'src/icons/index.ts' },
formats: ['es', 'cjs'],
fileName: (format, name) => `${name}.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
// peer-зависимости НЕ бандлим: иначе у потребителя окажется два React
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: { preserveModules: true }, // сохраняем структуру → потребитель шейкает точнее
},
sourcemap: true,
minify: false, // минифицирует потребитель, у него свой таргет
},
});
Чек-лист перед публикацией: npx publint (валидность exports и форматов), npx @arethetypeswrong/cli --pack (типы резолвятся во всех режимах), npm pack --dry-run (что реально уедет в реестр).
Сравнение сборщиков без фанатизма
| Инструмент | Сильная сторона | Слабая сторона | Когда брать |
|---|---|---|---|
| Vite | Мгновенный dev, вменяемые дефолты, огромная экосистема | dev и prod — разные конвейеры; прод-сборка на JS-Rollup небыстрая | Дефолт для SPA и почти всего нового |
| webpack | Максимум контроля, Module Federation, 10 лет решений на любой случай | Медленный, конфиг требует эксперта | Легаси, микрофронтенды на MF, экзотические требования |
| Rspack | Совместим с конфигом и лоадерами webpack, на порядок быстрее | Молодой, часть плагинов не поддержана | Миграция большого webpack-проекта без переписывания |
| Rollup | Эталонный tree-shaking, чистый вывод | Не приложенческий инструмент, нет dev-сервера | Сборка библиотек |
| Rolldown | Rust-Rollup, API-совместим, будущий движок Vite | Ещё стабилизируется | Через rolldown-vite, когда прод-сборка стала узким местом |
| esbuild | Абсолютная скорость | Ограниченный tree-shaking, нет полноценного splitting-контроля | Как трансформер и минификатор внутри других инструментов |
| Turbopack | Инкрементальный граф, встроен в Next.js | Практически неотделим от Next | Если вы уже на Next.js |
| Parcel | Реально нулевой конфиг | Меньше сообщества, сложнее нестандартные случаи | Прототипы, небольшие сайты |
Честный вывод: для нового приложения — Vite, и это не спорно. Для существующего webpack-проекта на 200 тысяч строк переезд на Vite обычно дороже, чем перевод на Rspack, где конфиг и лоадеры переиспользуются. Внутри Next.js вопрос сборщика вам не принадлежит. А выбор фреймворка — тема отдельной статьи трека; сборка от него зависит меньше, чем принято думать.
Прод-нюансы, о которых узнают в худший момент
Сборка должна быть детерминированной. npm ci вместо npm install, лок-файл в репозитории, зафиксированная версия Node в .nvmrc и в образе CI. Иначе «у меня собиралось» превращается в еженедельный ритуал. Как это встраивается в пайплайн — в статье про основы CI.
Кэш CI — половина времени сборки. Кэшируйте node_modules (по хэшу лок-файла) и node_modules/.vite. На больших монорепо сверху ставится Turborepo или Nx с кэшем по хэшу входов: неизменившийся пакет не пересобирается вообще.
Строгий CSP ломает сборку по умолчанию. Vite инлайнит небольшой полифилл modulepreload прямо в HTML, а это inline-скрипт. При script-src 'self' страница молча падает. Варианты: build.modulePreload.polyfill: false, либо nonce/hash в политике. Проверяйте CSP на превью-стенде, а не в проде.
Sourcemaps — не для публики. sourcemap: 'hidden', загрузка в Sentry на деплое, удаление из раздачи. Без них стектрейсы в мониторинге бесполезны, с публичным доступом — вы раздаёте исходники.
Проверка бандла на секреты. Простая грепалка в CI ловит больше, чем кажется:
grep -rIE "(sk_live|AKIA|-----BEGIN|password\":\s*\")" dist/ && exit 1 || true
Обновление зависимостей — это изменение бандла. Минорная версия библиотеки может добавить 80 КБ. Бюджет size-limit в CI ловит такое в PR, а не в проде.
Типичные ошибки
import { Button } from '@/components'через barrel — тянет соседей, замедляет сборку и ломает шейкинг. Импортируйте прямым путём.manualChunksс одним огромнымvendor— любое обновление зависимости выбивает кэш у всех пользователей.- Резать на чанки по 3 КБ — стоимость запроса выше экономии байтов.
import()внутри условия, которое всегда истинно — вы добавили сетевую волну, ничего не сэкономив.- Проверять производительность на своём ноутбуке — без throttling измеряется ваш CPU и ваш Wi-Fi.
lodashвместоlodash-es— CommonJS не шейкается, приезжает целиком.- Полагаться на esbuild как на проверку типов — типы стираются, ошибки не находятся.
tsc --noEmitв CI обязателен. - Секрет в переменной с префиксом
VITE_— он публичный по определению. - Понижать
build.target«на всякий случай» — десятки процентов лишнего кода ради несуществующей аудитории. - Не проверять прод-сборку локально —
vite previewперед мержем стоит 30 секунд и ловит половину «загадочных» багов. - Отсутствие бюджета в CI — бандл растёт по 5 КБ в неделю, через год это полмегабайта, и виноватых нет.
Мини-итог
Сборка — это компилятор из вашего дерева исходников в артефакт, оптимальный для сети и для движка. Он проходит фазы resolve → load → transform → граф → tree-shaking → чанки → минификация → хэш, и почти каждая ваша проблема локализуется в конкретной фазе.
Vite разделил две несовместимые цели: в разработке он не бандлит вовсе и потому стартует за секунду независимо от размера проекта, а в проде отдаёт всё Rollup ради tree-shaking и чанков. Ценой служит расхождение путей dev и prod — проверяйте прод-сборку.
Tree-shaking работает только на ESM и только там, где доказано отсутствие побочных эффектов; главные его убийцы — CommonJS, barrel-файлы и отсутствующее поле sideEffects. Code splitting уменьшает байты, но добавляет сетевые волны, поэтому режут по маршрутам и тяжёлым редким фичам, а глубину графа лечат modulepreload, prefetch на намерение и загрузкой данных параллельно с кодом.
И главное: всё вышеперечисленное — гипотезы, пока не измерено. Bundle visualizer скажет, что весит, Coverage — что не исполнилось, Network с throttling — где водопад, полевые Core Web Vitals — стало ли лучше живым пользователям. Бюджет в CI превращает разовую оптимизацию в свойство проекта.
Источники
- Документация Vite — особенно разделы Dependency Pre-Bundling, HMR API и Build Options
- Rollup: tree-shaking и чанки, Rolldown, Rspack, esbuild
- webpack: Code Splitting и Caching
- Node.js: package entry points и
exports, publint, Are the types wrong? - web.dev: Core Web Vitals, Reduce JavaScript payloads with code splitting, Preload critical assets
- Chrome DevTools: Coverage и Performance
- web-vitals, size-limit, rollup-plugin-visualizer
- Alex Russell, The Performance Inequality Gap — о том, на каких устройствах реально живут пользователи
Что дальше
Мы научились доставлять код в браузер быстро и в правильном порядке. Теперь — про то, что именно этот код делает: компонентная модель, JSX как синтаксис для вызовов функций, состояние, реконсиляция и правила, по которым React решает, что перерисовать.
React: компоненты, JSX, состояние, жизненный цикл, реконсиляция