Frontend-разработка Сборка фронтенда: Vite, бандлеры, tree-shaking, code splitting, dev-server
0%

Сборка фронтенда: Vite, бандлеры, tree-shaking, code splitting, dev-server

Сборка фронтенда: 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 — реализует один и тот же конвейер. Названия фаз отличаются, суть нет.

Три наблюдения, которые объясняют почти все дальнейшие решения.

Во-первых, фазы resolveloadtransform — это плагинный 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 работают принципиально по-разному.

Dev-сервер и прод-сборка Vite: два разных конвейера

В режиме разработки Vite вообще не бандлит ваш код. Он поднимает HTTP-сервер, отдаёт index.html, а дальше браузер сам запрашивает модули по одному. Сервер перехватывает каждый запрос, на лету превращает TS в JS (esbuild, десятки микросекунд на файл, типы просто стираются) и переписывает import React from "react" в import React from "/node_modules/.vite/deps/react.js".

Зависимости — отдельная история. Их Vite предбандлит один раз при старте:

  1. CommonJS → ESM. Половина npm до сих пор в CJS, браузер её не съест.
  2. Схлопывание запросов. 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: при изменении файла нужно найти границу принятия (HMR boundary) — ближайший модуль вверх по графу, который умеет обработать обновление своих зависимостей. Если такого нет до самого корня, происходит полная перезагрузка страницы.

Плагины 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 превращает разовую оптимизацию в свойство проекта.

Источники

Что дальше

Мы научились доставлять код в браузер быстро и в правильном порядке. Теперь — про то, что именно этот код делает: компонентная модель, JSX как синтаксис для вызовов функций, состояние, реконсиляция и правила, по которым React решает, что перерисовать.

React: компоненты, JSX, состояние, жизненный цикл, реконсиляция

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов