Как работают сборщики: Vite
Anastasia KotovaЧем интересен Vite
Про Vite в первую очередь говорят: «он очень быстрый в dev-режиме». Однако хочется понять, почему локальная разработка с ним стала настолько комфортной, особенно если сравнивать с Webpack.
Ключевой сдвиг в Vite в том, что он разделяет dev и prod не просто на уровне конфигов, а архитектурно. В prod режиме это вполне классический бандлинг приложения: используется Rollup, который строит граф модулей, делает tree-shaking, code splitting и кладёт результат в dist. Но для dev-режима Vite отказывается от идеи «собрать всё приложение», поднимает HTTP-сервер, который умеет понимать ESM, и по итогу перекладывает часть работы на сам браузер.
Дальше можно условно считать, что внутри Vite живут два разных инструмента: dev-сервер, который работает на уровне отдельных модулей и запросов, и build-пайплайн на Rollup, который нас здесь будет интересовать минимально.
ESM — важный фундамент
ECMAScript Modules (ESM) — это стандарт модульной системы JavaScript, встроенный в современные браузеры. Основная идея: модули могут экспортировать и импортировать значения через ключевые слова export и import, и браузер сам умеет загружать зависимости через HTTP, если модуль загружен с type="module".
Когда браузер получает HTML с <script type="module" src="...">, он загружает этот файл, парсит его, находит import-директивы, затем делает дополнительные HTTP-запросы к каждому из импортов, разрешая зависимости рекурсивно. Это создаёт граф модулей на стороне клиента, без какого-либо бандлинга.
Такой подход имеет два важных эффекта:
- Нет необходимости в глобальной предварительной сборке кода просто чтобы браузер смог его исполнить. Это позволяет быстрее стартовать dev-сессию и отдавать только действительно нужные части.
- Код остаётся «модульным» на уровне HTTP-запросов, что облегчает lazy-загрузку, динамические импорты, HMR и прочие сценарии, где не нужно или нежелательно грузить весь код сразу.
Для современных приложений, особенно на TypeScript, JSX/TSX, Vue/Svelte и других сборочных форматах, ESM сам по себе — это не всё. Но как каркас, ESM даёт Vite основу, на которой строится весь dev-сервер.
Dev-сервер как прокси над ESM
Когда запускается vite dev, поднимается HTTP-сервер с цепочкой middleware и внутренним графом модулей. Браузер получает обычный index.html, внутри которого есть <script type="module" src="/src/main.ts">. Дальше начинается стандартная для ESM история: браузер запрашивает main.ts, парсит импорты, запрашивает каждый импорт по отдельности, рекурсивно строит свой граф зависимостей и сам решает, что и когда грузить.
Роль Vite в этот момент — не собрать все эти файлы в один бандл, а стоять между браузером и файловой системой и решать несколько задач. Во-первых, переписать «голые» импорты вида import React from 'react' в валидные для браузера URL вроде /node_modules/.vite/deps/react.js?v=<hash>. Во-вторых, применить трансформации к исходникам: TypeScript в JavaScript, JSX в обычный JS и так далее. В-третьих, поддерживать внутренний ModuleGraph, где для каждого URL хранится информация о зависимостях, времени изменения и участии в HMR.
С точки зрения производительности при трансформации модулей критичны два момента. Первый: трансформация делается только на первый запрос модуля в рамках сессии. Результат кладётся в in-memory кеш дев-сервера и повторно используется, пока исходный файл на диске не изменился. Второй: поверх этого работает обычное HTTP-кеширование. Vite выставляет соответствующие заголовки, которые используются браузером при повторном запросе, а сервер отвечает 304 Not Modified, если модуль с момента прошлого запроса не менялся.
Зависимости из node_modules и роль esbuild
Исходники проекта — это только половина картины. Вторая половина — зависимости из node_modules, и именно они чаще всего публикуются либо в CommonJS, либо в виде ESM с большим количеством внутренних модулей. Если пытаться отдавать такой пакет напрямую как набор ESM-файлов, это быстро превращается в сотни запросов и массу несовместимых с браузером конструкций вроде require или динамических экспортов.
Для этого в Vite есть отдельный шаг dependency pre-bundling. При первом запуске проекта dev-сервер сканирует исходники, находит все импорты таких зависимостей (react, lodash, date-fns и так далее) и прогоняет соответствующие пакеты через esbuild. Esbuild одновременно решает две задачи: конвертирует CommonJS/UMD зависимости в ESM и «сплющивает» многомодульные пакеты в один или несколько крупных модулей. То, что раньше жило сотнями мелких файлов, превращается, например, в node_modules/.vite/deps/lodash.js.
Результат этой оптимизации кладётся в файловый кеш внутри node_modules/.vite вместе с метаданными, которые позволяют определить, нужно ли пересобирать зависимости при следующем запуске. Пока package.json и lock-файл не изменились, Vite просто переиспользует уже собранные ESM-версии пакетов, и cold-start ограничивается раздачей HTML и нескольких модулей.
Этот шаг принципиально отличается от обработки src/. Для кода проекта Vite старается ничего лишний раз не бандлить, а работать на уровне отдельных модулей. Для зависимостей — наоборот: агрессивно объединяет, чтобы не плодить сотни запросов и не тянуть в браузер CommonJS.
CommonJS в собственном коде
Архитектура Vite сильно завязана на ESM, поэтому в официальных рекомендациях подчёркивается, что код приложения в src/ желательно писать именно как ES-модули. CommonJS в этой среде выглядит чужеродно: он не вписывается в нативное разрешение зависимостей браузером, хуже связывается с HMR и зачастую требует дополнительных преобразований.
Если в приложении есть один-два модуля на CommonJS, простой трансформации с помощью плагинов Vite обычно достаточно, чтобы такие модули запускались. Но в более сложных случаях — динамические require, вычисляемые пути — поведение становится менее предсказуемым. HMR-обновления могут не доходить до импортёров, а Vite будет чаще переключаться на полный reload страницы, потому что у него не останется надёжной точки применения изменений локально.
Поэтому основная рекомендация выглядит прямолинейно: при миграции на Vite лучше перевести src/ на ESM. Это сильно повышает качество dev-опыта.
Если же CommonJS-кода объективно много и переписывание — отдельный большой проект, можно использовать плагин **vite-plugin-commonjs.** Он расширяет поддержку CommonJS в dev-режиме и даёт возможность постепенно жить с существующим кодом. Однако по сути это обходной путь: работа остаётся менее прозрачной, а часть ограничений по HMR никуда не исчезает, потому что базовая модель Vite всё равно остаётся ESM-центричной.
Hot Module Replacement
Если посмотреть на Vite как на dev-сервер, HMR (Hot Module Replacement) — это просто ещё один протокол поверх HTTP. Сервер следит за файловой системой, поддерживает WebSocket-соединение с браузером и при изменении файла рассылает клиентам сообщения об обновлениях. Браузерная часть HMR инициализируется в специальном модуле /@vite/client, который автоматически подключается к dev-странице.
Цепочка событий выглядит так. Файл на диске изменился — Vite узнает об этом — dev-сервер по графу модулей вычисляет, какие URL соответствуют этому файлу и какие модули считаются HMR-границами. Затем на открытые WebSocket-подключения отправляется сообщение вида:
{
"type": "update",
"updates": [
{
"acceptedPath": "/src/components/HmrDemo.jsx",
"explicitImportRequired": false,
"isWithinCircularImport": false,
"path": "/src/components/HmrDemo.jsx",
"ssrInvalidates": [],
"timestamp": 1765221360484,
"type": "js-update"
}
]
}
Клиентский рантайм получает сообщение, инициирует динамический import обновлённого модуля с дополнительным параметром в URL, чтобы обойти кеш (?t=<timestamp>), и передаёт новую версию модуля в зарегистрированный HMR-обработчик. Сам модуль становится HMR-границей, если в нём явно указан import.meta.hot.accept(...). В этом случае он может либо сам обработать обновление, либо принять обновления от перечисленных зависимостей.
Всё, что не может быть обслужено через такую схему, приводит к полному перезапуску страницы. Если ни сам модуль, ни его импортёры не вызывают import.meta.hot.accept, Vite считает, что безопасного способа применить обновление локально нет. Тогда по WebSocket отправляется команда full reload (type: "full-reload"), и страница перезагружается целиком. Именно так работает, например, с «глобальными конфигами» и прочими модулями, которые инициализируют важные побочные эффекты на уровне всего приложения.
HMR и сохранение состояния
История с сохранением состояния в HMR начинается не в самом Vite, а в интеграциях для конкретных инструментов. Сам Vite обеспечивает доставку обновлений и пере-импорт модулей, но не знает, как и что внутри этих модулей должно быть пересоздано.
Существуют категории обновлений, которые можно применять полностью прозрачно. Наиболее очевидный пример — изменения CSS. Когда изменяется .css-файл, Vite отслеживает обновление и отправляет по WebSocket сообщение об изменении ресурса стилей. Клиентская часть подменяет <link> или <style> элемент, добавляя к URL временной параметр, чтобы обойти кеш браузера. DOM-дерево и JavaScript-состояние при этом не затрагиваются: компоненты продолжают существовать, обработчики событий остаются на месте, внутренние значения не сбрасываются. Визуальная часть обновляется мгновенно, без участия разработчика. Именно поэтому CSS-правки в Vite ощущаются «моментальными» — они не требуют реконфигурации приложения.
Для JavaScript-модулей всё сложнее. В случае React за сохранение состояния отвечает React Fast Refresh, подключаемый через официальный Vite-плагин. Плагин добавляет в компонент служебный код, регистрирует его в рантайме и устанавливает HMR-обработчик через import.meta.hot.accept, внутри которого вызывается performReactRefresh. Когда поступает обновление, изменяется только содержимое компонента, а React-дерево и его state переиспользуются там, где сигнатуры остаются совместимы. Если же изменить модуль, который не объявляет HMR-границу или не может безопасно применить патч локально, Vite переключается на полный reload страницы.
А что с prod-сборкой
Prod-режим в Vite устроен куда более традиционно. При vite build включается Rollup, который строит полный граф модулей приложения, применяет tree-shaking, делает code splitting, оптимизирует CSS и ассеты и выдаёт готовый набор файлов для деплоя. Модули из node_modules в этом режиме обрабатываются уже в контексте Rollup и его плагинов, а не через dev-оптимизацию esbuild, хотя обсуждается и более тесная интеграция esbuild в prod-пайплайн.
Если собрать всё вместе, картина получается достаточно цельной. В dev режиме Vite использует браузер как движок разрешения ESM-зависимостей, обслуживает модули по запросу и кэширует результаты трансформаций, не занимаясь бандлингом проекта. Для сторонних зависимостей он делает одноразовый pre-bundling через esbuild, чтобы избавиться от CommonJS и уменьшить количество запросов. Для HMR он опирается на простой, но эффективный подход поверх WebSocket и явные HMR-границы через import.meta.hot, перекладывая заботу о сохранении состояния на плагины фреймворков.
В prod режиме Vite не изобретает свой бандлер, а использует Rollup. Это различие dev- и prod-режимов по архитектуре и даёт тот эффект, который воспринимается как «Vite сильно быстрее Webpack».