Vite dev mode overview

At the very basic level, developing using Vite is not that different from using a static file server. However, Vite provides many enhancements over native ESM imports to support various features that are typically seen in bundler-based setups.

Vite guide, Features

Vite dev mode leverages ES modules widespread support, augments it with a Rollup-like plugin system, and implements a similar hook triggering system and a module graph structure. The result is an environment that offers short feedback cycles for developers to test-drive their code.

The library starts a development server and a web socket server when we run vite from the command line.

The dev server is a primitive Nodejs server that hosts the application:

const { createSecureServer } = await import('node:http2')
return createSecureServer(httpsOptions, app)

Vite creates an instance of connect and attaches it to the web server. That allows the library to inject middlewares to edit HTML pages before returning them to the browser.

Static, non-HTML, files are handled by a sirv-based middleware.

HTML pages are handled by a middleware that executes hook handlers from the defined plugins.

The web socket server is an instance of ws. It handles the messaging between the dev server and the browser:

import { WebSocketServer } from 'ws'
import type { WebSocket } from 'ws'
// ...
const customListeners = new Map<string, Set<WebSocketCustomListener<any>>>()
const clientsMap = new WeakMap<WebSocket, WebSocketClient>()
wss = new WebSocketServer({ noServer: true })

This is the basis of Vite Hot Module Replacement. customListeners maps each event to a set of its handlers. clientsMap contains the connected sockets. One of the clients is the opened HTML page inside the browser.

HTML transformation

If you try to inspect an HTML page built by Vite, you will see a timestamp at the end of script URLs. You might also find different URLs than those you have in the source file. And you’ll find the source code responsible for HMR injected in the beginning.

Vite keeps track of what can be changed without a page reload.

It captures:

  • Javascript <script> elements
  • Inline CSS code inside style attributes
  • <style> elements
  • Links to CSS files
  • References to Javascript modules.

Vite maintains a module graph quite similar to Rollup module graph and handles each of these as a separate module, that is, a distinct node.

The main attributes of ModuleGraph are:

  urlToModuleMap = new Map<string, ModuleNode>()
  idToModuleMap = new Map<string, ModuleNode>()
  // a single file may correspond to multiple modules with different queries
  fileToModulesMap = new Map<string, Set<ModuleNode>>()

ModuleNode is a graph node.

urlToModuleMap and idToModuleMap have the same values.

The first map keys are the URLs used for importing.

The second map keys are the resolved ids for these URLs. These are usually files on the file system.

This is how an id returned from ResolveId hook is transformed into a file name:

url.replace(/[?#].*$/s, '')

fileToModulesMap maps a file name, the result of this latter expression, to the modules inside it.

From a certain point of view, Vite inserts a layer between the HTML and the modules. In some cases, the layer is transparent. The initial code is kept but slightly modified. In other cases, a proxy is inserted between the import and the imported.

To identify the modules, Vite creates a MagicString instance with the initial HTML string. It traverses the HTML with a depth-first approach using parse5. It looks for <script> or <style> elements, elements with an inline style attribute that contains url() or image-set(), and elements with some attributes that contain URLs, that is, elements with href and src attributes.

Let’s start with styles modules.

The id of an inline style module is:

const url = `${proxyModulePath}?html-proxy&inline-css&style-attr&index=${index}.css`

For a <style> element, it’s:

const url = `${proxyModulePath}?html-proxy&direct&index=${index}.css`

proxyModulePath is the host HTML file. index is the order of the URL inside that file.

The module content is returned by the styles plugins. CssPlugin and CssPostPlugin implement transform hook handlers for modules whose id ends with:

'css',  'less',  'sass',  'scss',  'styl',  'stylus',  'pcss',  'postcss',

We can manually add plugins and define hook handlers that handle such modules as well.

Vite runs, first, CssPlugin to compile the styles into CSS.

CssPostPlugin then executes a list of postcss plugins to interpret CSS modules.

It uses postcss-import to inline @import calls and keeps track of the imported modules and their transitive dependencies in the module graph.

Other than this, CSS code is mostly intact in the HTML source.

Handling <script> elements is more-or-less the same.

Three types of scripts exist:

  • Scripts with type = 'module' and a Javascript body
  • Scripts with an src attribute
  • Scripts with a Javascript body

In all cases, a module node is added to the module graph.

In the first case though, Vite replaces the script with a script/src element:

const modulePath = `${proxyModuleUrl}?html-proxy&index=${inlineModuleIndex}.js`
s.update(start, end, `<script type="module" src="${modulePath}"></script>`)

proxyModuleUrl is url of the container HTML file. inlineModuleIndex is the order of the inline-module in the file.

The identification of Javascript dependencies and their transitive dependencies will be handled at the end, just before sending the Javascript source to the browser, by an analysis plugin. This latter uses es-module-lexer to identify imports, analyze them, and rewrite them to their respective modules ids.

Hot Module Replacement

Vite instantiates a chokidar instance to watch file system changes.

In the browser, it inserts a client script inside the <head> element of the HTML file:

<script type="module" src="/@vite/client"></script>

This script keeps track of import.meta.hot.accept() calls (with deps and callback, or with only a callback) inside a map named hotModulesMap:

interface HotModule {
  id: string
  callbacks: {
    deps: string[]
    fn: (modules: Array<ModuleNamespace | undefined>) => void
  }[]
}

If the source accesses HMR API, that is, if the code contains import.meta.hot, Vite adds HMR initialization:

str().prepend(
  `import { createHotContext as __vite__createHotContext } from "${clientPublicPath}";` +
  `import.meta.hot = __vite__createHotContext(${JSON.stringify(normalizeHmrUrl(importerModule.url))});`,
)

createHotContext defines the accept() method.

A module A “accepts” another module B when A can apply any update to B in the web page, without reloading.

A module is “accepted” when it’s marked as to be replaced. A module is “self-accepting” when it accepts its own updates.

A module calls import.meta.hot.accept(callback) to handle its updates. The callback will be called with the updated code each time the module changes.

It calls import.meta.hot.accept(deps, callback) to handle the updates of some or all its dependencies.

Each module node in the modules graph keeps track of its importers, the modules it accepts, and whether it’s self-accepting.

When a file changes, Vite locates the affected modules and identifies the update boundary. This lookup is called “Update propagation”. Vite iterates over the module importers and adds a boundary when an importer has the child in its accepted HMR dependencies.

This minimizes and focuses the updates. Only updates for a boundary are sent to the browser.

The two main types of messages the web socket server sends to the client script are 'update' and 'full-reload'.

After getting a message of the latter type, the frontend handler simply calls location.reload().

When it gets an update message, it identifies the target module and updates it.

The update message interface is:

interface Update {
  type: 'js-update' | 'css-update'
  path: string
  acceptedPath: string
  timestamp: number
  explicitImportRequired?: boolean | undefined
}

path is the id of the target module that’ll get the update. acceptedPath is the id of the actual changed module.

A CSS update is sent “when a CSS file referenced with <link> is updated”. The client script then searches for a <link> element whose href is the update path and rewrites it.

Here’s how the link tag is replaced:

const newLinkTag = el.cloneNode()
newLinkTag.href = new URL(newPath, el.href).href
newLinkTag.addEventListener('load', () => el.remove())
el.after(newLinkTag)

If an “accepted” Javascript module is modified. A 'js-update' message is sent. The script then imports the updated module with a timestamp attribute at the end of the URL:

fetchedModule = await import(update.acceptedPath + `?t=${update.timestamp}`)

Then, it passes the updated code to the callbacks associated with the module inside hotModulesMap.

  • Say Hi
  • If you want to get a notification when add a post, join my newsletter: