Как работают сборщики: Vite

Как работают сборщики: 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».

Читать также

Как работают сборщики: Webpack

Report Page