preface

If you are not familiar with Vite, please go to the official documentation to get a feel for it before continuing to read this article.

Hot Module Replacement (HMR) is one of the essential skills of modern front-end development and construction tools. As its name implies, it can help developers to re-render specific modules instead of directly refreshing the entire page, greatly improving the development efficiency. However, for bundle-based build tools, the execution process of hot update also includes compilation and packaging. Therefore, with the expansion of the project scale, the execution efficiency of hot update will gradually decrease, and even in some large front-end projects, the time of hot update will exceed 3s.

One of the most attractive advantages of Vite as a new generation of front-end development and build tools is the ability to always maintain extremely fast module hot reload in Vite projects, regardless of application size. Vite’s official explanation is that its HMR is based on the native ESM implementation. Let’s take a look at how Vite achieves such efficient hot updates.

Hot update principle analysis

The overview

First, let’s look at what the overall flow of Vite hot updates looks like.

Vite creates a Websocket server customized for the hot update service before starting up, and then listens for project files. At the same time, the HTML of the client side injected @vite/client to cooperate with the server side to achieve hot update. The specific process is shown in the figure below:

Detailed analysis

Below, we will analyze the startup process and execution process of Vite hot update module in detail:

Hot update module startup process analysis

Before Vite Dev Server starts, Vite does some preparatory work for HMR. Vite will create a websocket service for HMR. It will also create a listener object watcher to listen for file changes. Vite then starts file listening after the related object is initialized and executes the HMR logic in the listener callback, completing all the HMR preparations before the service starts. The specific implementation code is as follows:

  // Create a WebSocket service object
  const ws = createWebSocketServer(httpServer, config, httpsOptions)
  // Initialize listeners according to the configuration
  const{ ignored = [], ... watchOptions } = serverConfig.watch || {}const watcher = chokidar.watch(path.resolve(root), {
    ignored: ['**/node_modules/**'.'**/.git/**'. ignored],ignoreInitial: true.ignorePermissionErrors: true.disableGlobbing: true. watchOptions })as FSWatcher
  // Start file listening
  watcher.on('change'.async(file) => {···}) watcher.on('add'.(file) = >Check {...}) watcher.'unlink'.(file) = >{...})Copy the code

Let’s look at the createWebSocketServer method and see what’s in the webSocket object.

function createWebSocketServer(
  server: Server | null, config: ResolvedConfig, httpsOptions? : HttpsServerOptions) :WebSocketServer {
  let wss: WebSocket.Server
  let httpsServer: Server | undefined = undefined
  // Read the hot update configuration
  const hmr = typeof config.server.hmr === 'object' && config.server.hmr
  const wsServer = (hmr && hmr.server) || server

  if (wsServer) {
    // Common mode
    wss = new WebSocket.Server({ noServer: true })
    wsServer.on('upgrade'.(req, socket, head) = > {
      // Listen for websocket messages sent via the Vite client, distinguished by HMR_HEADER
      if (req.headers['sec-websocket-protocol'] === HMR_HEADER) {
        wss.handleUpgrade(req, socket, head, (ws) = > {
          // Send a connection message to establish a connection with the client
          wss.emit('connection', ws, req)
        })
      }
    })
  } else {
    // Middleware mode...// vite dev server in middleware mode
    wss = new WebSocket.Server(websocketServerOptions)
  }
  // The binding listens for WS events
  wss.on('connection'.(socket) = > {
    socket.send(JSON.stringify({ type: 'connected'...}})))// Error handling
  wss.on('error'.(e: Error & { code: string }) = >{...})// Return an object with send and close methods
  return {
    send(payload: HMRPayload) {
      if (payload.type === 'error' && !wss.clients.size) {
        bufferedError = payload
        return
      }

      const stringified = JSON.stringify(payload)
      // Traversal sends messages to all connected clients
      wss.clients.forEach((client) = > {
        if (client.readyState === WebSocket.OPEN) {
          client.send(stringified)
        }
      })
    },

    close() {
      // Close the service logic}}}Copy the code

It can be seen from the above code that Vite mainly captures some errors and formats the payload when creating the WebSocketServer. Finally, it returns encapsulated send and close methods for the server to push messages and shut down the service.

Hot updates perform process parsing

Let’s take a look at the hot update process for Vite.

1. Generate and push change file information

As you can see from the startup process above, Vite’s callback method in the file listener is the first step in the hot update execution process. Here’s what the callback method looks like:

  watcher.on('change'.async (file) => {
    file = normalizePath(file)
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)
    // Whether to enable hot update
    if(serverConfig.hmr ! = =false) {
      try {
        await handleHMRUpdate(file, server)
      } catch (err) {
        ws.send({
          type: 'error'.err: prepareError(err)
        })
      }
    }
  })
Copy the code

Two core operations are performed here, one is to update the moduleGraph to invalidate the cache of the modified file, and the other is to execute the hot update method handleHMRUpdate.

moduleGraph

First, let’s analyze the moduleGraph in Vite and its specific functions:

During the startup phase, we will process the moduleGraphconsole and see its structure as follows:

ModuleGraph {
  urlToModuleMap: Map {},
  idToModuleMap: Map {},
  fileToModulesMap: Map {},
  safeModulesPath: Set {},
  container: {
    options: { acorn: [Object].acornInjectPlugins: []},buildStart: [AsyncFunction: buildStart],
    resolveId: [AsyncFunction: resolveId],
    load: [AsyncFunction: load],
    transform: [AsyncFunction: transform],
    watchChange: [Function: watchChange],
    close: [AsyncFunction: close]
  }
}
Copy the code

Its core is composed of a series of maps, which are respectively the mapping of URL, ID, file and ModuleNode. ModuleNode is the smallest module unit defined in Vite, and its composition is as follows:

export class ModuleNode {
  /** * Public served url path, starts with / */
  url: string
  /** * Resolved file system path + query */
  id: string | null = null
  file: string | null = null
  type: 'js' | 'css'
  importers = new Set<ModuleNode>()
  importedModules = new Set<ModuleNode>()
  acceptedHmrDeps = new Set<ModuleNode>()
  isSelfAccepting = false
  transformResult: TransformResult | null = null
  ssrTransformResult: TransformResult | null = null
  ssrModule: Record<string.any> | null = null
  lastHMRTimestamp = 0

  constructor(url: string) {
    this.url = url
    this.type = isDirectCSSRequest(url) ? 'css' : 'js'}}Copy the code

Finally let’s see what the modulegraph.onFilechange () method does:

onFileChange(file: string) :void {
    // Search through the file address module
    const mods = this.getModulesByFile(file)
    if (mods) {
      const seen = new Set<ModuleNode>()
      // Iterate over the found module and clear the converted result
      mods.forEach((mod) = > {
        this.invalidateModule(mod, seen)
      })
    }
  }

  invalidateModule(mod: ModuleNode, seen: Set<ModuleNode> = new Set()) :void {
    mod.transformResult = null
    mod.ssrTransformResult = null
    invalidateSSRModule(mod, seen)
  }
Copy the code

Obviously, this method is used to clear the transformResult field in the module, invalidating the conversion cache of the previous module. Vite implements strong caching of dependencies and negotiation caching of project logic code. See the official website for an overview of this, so I won’t go into details.

handleHMRUpdate

Next, let’s look at a key method of Vite hot update handleHMRUpdate:

export async function handleHMRUpdate(
  file: string,
  server: ViteDevServer
) :Promise<any> {...// If the configuration file is updated, restart the service
  if (isConfig || isConfigDependency || isEnv) {
    // auto restart server
    debugHmr(`[config change] ${chalk.dim(shortFile)}`)
    config.logger.info(
      chalk.green(
        `${path.relative(process.cwd(), file)}changed, restarting server... `
      ),
      { clear: true.timestamp: true})await restartServer(server)
    return
  }

  debugHmr(`[file change] ${chalk.dim(shortFile)}`)

  // (dev only) the client itself cannot be hot updated.
  if (file.startsWith(normalizedClientDir)) {
    ws.send({
      type: 'full-reload'.path: The '*'
    })
    return
  }
  // Get the module associated with this change file from moduleGraph
  const mods = moduleGraph.getModulesByFile(file)

  // check if any plugin wants to perform custom HMR handling
  const timestamp = Date.now()
  // Declare a hot update context
  const hmrContext: HmrContext = {
    file,
    timestamp,
    modules: mods ? [...mods] : [],
    read: () = > readModifiedFile(file),
    server
  }
  // Iterate over the plugin to execute the plugin's handleHotUpdate hook
  for (const plugin of config.plugins) {
    if (plugin.handleHotUpdate) {
      const filteredModules = await plugin.handleHotUpdate(hmrContext)
      if (filteredModules) {
        hmrContext.modules = filteredModules
      }
    }
  }
  // If there are no module changes, return directly
  if(! hmrContext.modules.length) {// html file cannot be hot updated
    if (file.endsWith('.html')) {
      config.logger.info(chalk.green(`page reload `) + chalk.dim(shortFile), {
        clear: true.timestamp: true
      })
      ws.send({
        type: 'full-reload'.path: config.server.middlewareMode
          ? The '*'
          : '/' + normalizePath(path.relative(config.root, file))
      })
    } else {
      // loaded but not in the module graph, probably not js
      debugHmr(`[no modules matched] ${chalk.dim(shortFile)}`)}return
  }
  // Push hot update module information to the client
  updateModules(shortFile, hmrContext.modules, timestamp, server)
}
Copy the code

As you can see from the code above, this method handles certain types of file changes, such as HTML file changes and config changes, by refreshing the page directly and restarting the service, respectively, and then executing the plugin’s specific hook, handleHotUpdate. You can refer to this documentation for details about what this hook does. After the above processing, an hmrContext will be declared, the module involved in the file change will be found from the moduleGraph, and the updateModules method will be called to push the module information for the hot update to the client.

2. The client parses the hot update information and sends a request to obtain the latest module and render it.

At the end of the previous step, the server pushes a message to the client, as follows:

{
    "type": "update"."updates": [{"type": "js-update".// Hot update type
            "timestamp": 1626850668126.// This hot update timestamp
            "path": "/src/pages/Home/index.tsx"."acceptedPath": "/src/pages/Home/index.tsx"}}]Copy the code

After obtaining the preceding information, the client performs operations based on its type. For example, if the type is js-update, the client performs the following operations:

async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
  const mod = hotModulesMap.get(path)
  if(! mod) {// In a code-splitting project,
    // it is common that the hot-updating module is not loaded yet.
    // https://github.com/vitejs/vite/issues/721
    return
  }
  / /...
  await Promise.all(
    Array.from(modulesToUpdate).map(async (dep) => {
      const disposer = disposeMap.get(dep)
      if (disposer) await disposer(dataMap.get(dep))
      const [path, query] = dep.split(`? `)
      try {
        // Request a new module
        const newMod = await import(
          /* @vite-ignore */
          base +
            path.slice(1) +
            `? import&t=${timestamp}${query ? ` &${query}` : ' '}`
        )
        moduleMap.set(dep, newMod)
      } catch (e) {
        warnFailedFetch(e, dep)
      }
    })
  )

  return () = > {
    / /...}}Copy the code

As you can see from the above code, Vite uses dynamic import to request the updated module and re-cache the new module in the process. Finally, Vite takes a new module, puts it in the moduleMap, and re-renders the React module using the plugin-react-refresh plugin, thus ending the hot update process.

conclusion

From the above hot update process, it can be clearly seen that the whole hot update of Vite does not involve any packaging operation, but directly requests to obtain the content of the module to be updated and completes the replacement of the module. This is the secret of the Vite project’s ability to maintain extremely fast module hot reload regardless of the size of the application, truly enabling on-demand loading.