Why Vite Beats Webpack: Deep Dive into Architecture and HMR

This article examines the limitations of bundle‑based tools like Webpack, introduces Vite’s ESM‑based approach, and provides a detailed analysis of Vite’s architecture, hot‑module‑replacement, module transformation, dependency graph, and plugin system, illustrating how it improves development speed and efficiency.

Sensors Frontend
Sensors Frontend
Sensors Frontend
Why Vite Beats Webpack: Deep Dive into Architecture and HMR

1. Background

Webpack is currently the most popular bundle‑based build tool. As a project grows, build time becomes longer, severely affecting the development experience. The reason is that all modules must be bundled into a single bundle, even dynamically imported ones, so developers must wait for the entire project to be built regardless of how many dependencies the entry page actually uses.

Figure 1: Bundle‑based development server

2. Vite Introduction

To solve the above problems, Vite was created. Vite leverages the browser’s native support for JavaScript modules (ESM) and moves the build process from bundle‑level to module‑level, using ESM as the granularity of the build.

Figure 2: Native ESM development server

2.1 Problems with ESM‑Based Builds

Performance overhead of converting to ESM

Compatibility issues with non‑ESM modules (TS/JSX, etc.)

Node modules incompatibility

Many dependencies (e.g., lodash) cause a large number of requests

2.2 How Vite Addresses These Issues

Uses esbuild to transform TS/JSX

Wraps CJS/UMD files and converts them to ESM

Figure 3: CJS to ESM conversion

Packages Node modules into a single module to reduce request count

2.3 Other Effective Techniques

Cache ESM modules in node_modules/.vite Hot Module Replacement (HMR)

HTTP caching

Runtime dynamic dependency scanning

3. Vite Architecture and Source‑Code Analysis

Vite fully exploits browser features and the Node toolchain, using a Node server that communicates with the browser to map source files to the UI efficiently.

Figure 4: Node server processing flow

Figure 5: Development environment runtime flow

3.2.1 Hot Update Code

function updateModules(
  file: string,
  modules: ModuleNode[],
  timestamp: number,
  { config, ws }: ViteDevServer
) {
  const updates: Update[] = []
  const invalidatedModules = new Set<ModuleNode>()
  let needFullReload = false

  for (const mod of modules) {
    invalidate(mod, timestamp, invalidatedModules)
    if (needFullReload) {
      continue
    }
    const boundaries = new Set<{ boundary: ModuleNode; acceptedVia: ModuleNode }>()
    const hasDeadEnd = propagateUpdate(mod, boundaries)
    if (hasDeadEnd) {
      needFullReload = true
      continue
    }
    updates.push(
      ...[...boundaries].map(({ boundary, acceptedVia }) => ({
        type: `${boundary.type}-update` as Update['type'],
        timestamp,
        path: boundary.url,
        acceptedPath: acceptedVia.url
      }))
    )
  }

  if (needFullReload) {
    config.logger.info(colors.green(`page reload `) + colors.dim(file), {
      clear: true,
      timestamp: true
    })
    ws.send({ type: 'full-reload' })
  } else {
    config.logger.info(
      updates.map(({ path }) => colors.green(`hmr update `) + colors.dim(path)).join('
'),
      { clear: true, timestamp: true }
    )
    ws.send({ type: 'update', updates })
  }
}

3.2.2 Boundary Propagation

function propagateUpdate(
  node: ModuleNode,
  boundaries: Set<{ boundary: ModuleNode; acceptedVia: ModuleNode }>,
  currentChain: ModuleNode[] = [node]
): boolean /* hasDeadEnd */ {
  if (node.isSelfAccepting) {
    boundaries.add({ boundary: node, acceptedVia: node })
    for (const importer of node.importers) {
      if (isCSSRequest(importer.url) && !currentChain.includes(importer)) {
        propagateUpdate(importer, boundaries, currentChain.concat(importer))
      }
    }
    return false
  }
  if (!node.importers.size) {
    return true
  }
  if (!isCSSRequest(node.url) && [...node.importers].every(i => isCSSRequest(i.url))) {
    return true
  }
  for (const importer of node.importers) {
    const subChain = currentChain.concat(importer)
    if (importer.acceptedHmrDeps.has(node)) {
      boundaries.add({ boundary: importer, acceptedVia: node })
      continue
    }
    if (currentChain.includes(importer)) {
      return true
    }
    if (propagateUpdate(importer, boundaries, subChain)) {
      return true
    }
  }
  return false
}

3.2.3 Transform Middleware

// transformMiddleware
if (
  isJSRequest(url) ||
  isImportRequest(url) ||
  isCSSRequest(url) ||
  isHTMLProxy(url)
) {
  // strip ?import
  url = removeImportQuery(url)
  // unwrap virtual ids
  url = unwrapId(url)

  if (isCSSRequest(url) && !isDirectRequest(url) && req.headers.accept?.includes('text/css')) {
    url = injectQuery(url, 'direct')
  }

  const ifNoneMatch = req.headers['if-none-match']
  if (ifNoneMatch && (await moduleGraph.getModuleByUrl(url, false))?.transformResult?.etag === ifNoneMatch) {
    res.statusCode = 304
    return res.end()
  }

  const result = await transformRequest(url, server, {
    html: req.headers.accept?.includes('text/html')
  })
  if (result) {
    const type = isDirectCSSRequest(url) ? 'css' : 'js'
    const isDep = DEP_VERSION_RE.test(url) || (cacheDirPrefix && url.startsWith(cacheDirPrefix))
    return send(req, res, result.code, type, {
      etag: result.etag,
      cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
      headers: server.config.server.headers,
      map: result.map
    })
  }
}

3.2.4 Module Graph Update

/**
 * Update the module graph based on a module's updated imports information
 * If there are dependencies that no longer have any importers, they are
 * returned as a Set.
 */
async function updateModuleInfo(
  mod: ModuleNode,
  importedModules: Set<string | ModuleNode>,
  acceptedModules: Set<string | ModuleNode>,
  isSelfAccepting: boolean,
  ssr?: boolean
): Promise<Set<ModuleNode> | undefined> {
  mod.isSelfAccepting = isSelfAccepting
  const prevImports = mod.importedModules
  const nextImports = (mod.importedModules = new Set())
  let noLongerImported: Set<ModuleNode> | undefined

  for (const imported of importedModules) {
    const dep = typeof imported === 'string' ? await this.ensureEntryFromUrl(imported, ssr) : imported
    dep.importers.add(mod)
    nextImports.add(dep)
  }

  prevImports.forEach(dep => {
    if (!nextImports.has(dep)) {
      dep.importers.delete(mod)
      if (!dep.importers.size) {
        ;(noLongerImported || (noLongerImported = new Set())).add(dep)
      }
    }
  })

  const deps = (mod.acceptedHmrDeps = new Set())
  for (const accepted of acceptedModules) {
    const dep = typeof accepted === 'string' ? await this.ensureEntryFromUrl(accepted, ssr) : accepted
    deps.add(dep)
  }
  return noLongerImported
}

async function ensureEntryFromUrl(rawUrl: string, ssr?: boolean): Promise<ModuleNode> {
  const [url, resolvedId, meta] = await this.resolveUrl(rawUrl, ssr)
  let mod = this.urlToModuleMap.get(url)
  if (!mod) {
    mod = new ModuleNode(url)
    if (meta) mod.meta = meta
    this.urlToModuleMap.set(url, mod)
    mod.id = resolvedId
    this.idToModuleMap.set(resolvedId, mod)
    const file = (mod.file = cleanUrl(resolvedId))
    let fileMappedModules = this.fileToModulesMap.get(file)
    if (!fileMappedModules) {
      fileMappedModules = new Set()
      this.fileToModulesMap.set(file, fileMappedModules)
    }
    fileMappedModules.add(mod)
  }
  return mod
}

3.2.5 Import Analysis Plugin

/**
 * Server‑only plugin that lexes, resolves, rewrites and analyzes url imports.
 * - Imports are resolved to ensure they exist on disk
 * - Lexes HMR accept calls and updates import relationships in the module graph
 * - Bare module imports are resolved (by @rollup‑plugin/node‑resolve) to absolute file paths
 * - CSS imports are appended with `.js` so that both the JS module and the actual CSS go through the transform pipeline
 */
export function importAnalysisPlugin() {
  // implementation omitted for brevity
}

/**
 * Detect import statements to a known optimized CJS dependency and provide ES named imports interop.
 */
export function transformCjsImport() {
  // implementation omitted for brevity
}

4. Conclusion

Vite can greatly improve development efficiency, but its ecosystem is still maturing and requires extensive community testing. In practice, Vite is recommended for development, while Webpack remains the preferred choice for production builds. The current React refresh‑plugin is based on Babel and has performance drawbacks; future improvements should focus on native solutions to reduce inefficient JavaScript overhead. For deeper source‑code analysis, use Node and Chrome debugging tools.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Frontend DevelopmentWebpackBuild ToolsViteESMHot Module Replacement
Sensors Frontend
Written by

Sensors Frontend

Regularly shares the Sensors tech team's cutting‑edge explorations and technical insights in front‑end development.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.