← назад к разделу

Браузер понимает HTML, CSS и JavaScript — но современный frontend-код пишется на TypeScript и JSX, разбивается на сотни модулей и зависит от десятков библиотек. Чтобы это стало тем, что понимает браузер, нужна сборка.

Зачем нужна сборка

Без неё несколько проблем сразу:

  • TypeScript и JSX браузер не понимает — нужна трансформация в обычный JavaScript.
  • Сотни модулей с import/export — без объединения браузер делает сотни отдельных HTTP-запросов, что медленно.
  • Оптимизации — минификация, tree shaking (удаление неиспользуемого кода), хэширование имён файлов для долгосрочного кеша — вручную не масштабируется.

Бандлер делает всё это: принимает точку входа, строит граф зависимостей, трансформирует и объединяет модули, отдаёт готовые файлы.

Что происходит внутри бандлера

Бандлер начинает с точки входа — обычно src/main.tsx. Читает её импорты, находит каждый файл, читает его импорты и так далее. Получается граф модулей:

main.tsx
  └─ App.tsx
       ├─ pages/HomePage.tsx
       │    └─ components/Hero.tsx
       └─ pages/ProductPage.tsx
            └─ api/products.ts

Из этого графа бандлер собирает один или несколько выходных файлов. Попутно он трансформирует TypeScript в JS, JSX в вызовы React.createElement, применяет минификацию и прочие оптимизации.

Инструменты сегодня

Vite — стандарт для новых проектов. В режиме разработки он не собирает полный бандл: браузеру отдаются отдельные ESM-модули с трансформацией через esbuild — запуск мгновенный. В продакшене Vite использует Rollup для оптимизированной сборки. Для старта нового проекта — npm create vite@latest.

webpack — был стандартом годами; встречается в существующих проектах и в Create React App. Медленнее Vite, но экосистема плагинов зрелая. Новый проект на webpack начинать не стоит.

esbuild и Rollup работают как движки под другими инструментами: esbuild — в Vite для dev-режима и трансформации, Rollup — для prod-сборки. Напрямую их конфигурируют редко.

Rspack (Rust-порт webpack) и Turbopack (Vercel) — альтернативы с фокусом на скорость. Актуальны для очень больших монорепозиториев; для среднего проекта разница незаметна.

Dev-режим vs продакшен-сборка

Это два принципиально разных режима, которые решают разные задачи.

Dev-режим — для скорости итерации:

  • HMR (Hot Module Replacement) обновляет изменённый модуль в браузере без перезагрузки страницы; состояние компонента сохраняется.
  • Нет минификации — код читаемый, отладка в DevTools работает нормально.
  • Source maps включены по умолчанию.

Продакшен-сборка — для пользователя:

  • Минификация уменьшает объём в 3–5 раз: убираются пробелы, переименовываются переменные.
  • Хэшированные имена файлов (app.a8f3c2.js) позволяют браузеру кешировать файл бессрочно — при изменении хэш меняется и браузер загружает новую версию.
  • Tree shaking и code splitting активны.
vite              # dev-сервер
vite build        # продакшен-сборка в dist/
vite preview      # локальный просмотр dist/ перед деплоем

Code splitting и динамический import

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

Code splitting разбивает бандл на части. Самая естественная граница — маршруты:

import { lazy, Suspense } from 'react';

const SettingsPage = lazy(() => import('./pages/SettingsPage'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/settings" element={<SettingsPage />} />
      </Routes>
    </Suspense>
  );
}

React.lazy + динамический import() — бандлер создаёт отдельный чанк для SettingsPage. Он загружается только когда пользователь переходит на /settings. Начальный файл стал меньше — страница открывается быстрее.

Suspense здесь — декларативная граница загрузки: пока чанк не загрузился, пользователь видит <Spinner />. Подробнее о его роли в обработке состояний — в статье «Обработка ошибок и Suspense».

Tree shaking

Если импортировать одну функцию из большой библиотеки, tree shaking гарантирует, что в бандл попадёт только она, а не вся библиотека:

import { format } from 'date-fns'; // в бандл попадёт только format

Tree shaking работает только с ESM (import/export). CommonJS (require) непрозрачен для статического анализа — бандлер не может знать, что из модуля используется, и включает его целиком. Это главная причина переехать на ESM-версии библиотек.

Вторая ловушка — сайд-эффекты. Если модуль делает что-то при импорте (регистрирует глобальные полифиллы, добавляет плагины), бандлер обязан его включить. Библиотека должна объявить "sideEffects": false в package.json, иначе tree shaking её не тронет.

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

Если начальная загрузка медленная — смотрите, что в бандле. Для Vite есть плагин rollup-plugin-visualizer:

npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [visualizer({ open: true })],
};

После vite build откроется карта модулей с визуальным распределением размеров. Типичные причины тяжёлого бандла:

  • Тяжёлые зависимости — moment.js с локалями (~2 МБ), lodash целиком. Заменить на date-fns и отдельные функции lodash-es.
  • Дубликаты — одна библиотека затянула другую версию той же зависимости. Решается через npm dedupe или resolve.alias в конфигурации Vite.
  • Отсутствие code splitting — весь код в одном файле.

Переменные окружения и режимы

Vite читает .env-файлы и предоставляет переменные через import.meta.env. В бандл попадают только переменные с префиксом VITE_:

# .env.production
VITE_API_URL=https://api.example.com
const apiUrl = import.meta.env.VITE_API_URL;

Переменные без VITE_ недоступны в клиентском коде. Это защита от случайного попадания секретов в браузерный бандл.

Чек-лист здоровой сборки

  • Продакшен-сборка проходит без предупреждений — предупреждения Rollup и TypeScript это сигналы, не шум.
  • Размер начального чанка — до 200 КБ gzip для типового приложения; больше — смотрите в visualizer.
  • Code splitting по маршрутам — каждый крупный маршрут в отдельном чанке через React.lazy.
  • Source maps загружены в мониторинг — без них стек ошибки в продакшене нечитаем.
  • Зависимости ESM, а не CommonJS — tree shaking работает только на ESM.
  • .env-файлы в .gitignore, только VITE_-переменные в коде — секреты не попадают в репозиторий.

Коротко

  • Бандлер трансформирует TypeScript/JSX, строит граф модулей и выдаёт оптимизированные файлы для браузера.
  • Vite — стандарт для новых проектов: мгновенный dev-сервер на ESM + оптимизированная prod-сборка через Rollup.
  • Dev-режим и продакшен решают разные задачи: HMR и читаемый код vs минификация и хэширование файлов.
  • React.lazy + динамический import() — code splitting по маршрутам без лишней настройки.
  • Tree shaking удаляет неиспользуемый код, но только из ESM-библиотек; CommonJS включается целиком.
  • Visualizer покажет, что занимает место; частые виновники — тяжёлые зависимости и дубликаты.
  • Только VITE_-переменные попадают в клиентский код — секреты без этого префикса не утекут.

Что почитать дальше

  • Производительность рендеринга — code splitting с React.lazy и оптимизация компонентов.
  • Модули и npm — как устроены ES-модули и package.json.
  • Инструменты TypeScript и Node — tsconfig, линтеры и окружение разработчика.
  • Обработка ошибок и Suspense — Suspense как граница загрузки при code splitting.