小白也能读懂的vite源码系列——vite 为什么这么快(四)

848 阅读10分钟

前言

上一章我们提到了 vite 的依赖预构建,vite 做依赖预构建的主要两个目的:

  1. CJS 和 UMD 的兼容性处理
  2. 提升性能,会将第三方依赖多个内部模块打包成一个,减少 http 请求

使用 vite 开发的项目,可以发现在开发环境下启动非常迅速,而且热更新也是很快。这一章我们就来探讨 vite 为什么那么快,这也和前面几章多次提到的 文件转换中间件 息息相关。

vite 工作模式

Vite 的 "no-bundle" 模式是其开发模式中的一个核心理念,它通过一种不同于传统打包工具的方法来加速开发过程。

Webpack 这样的传统打包工具在开发模式下,通常会将整个项目打包成一个或几个大的文件。每次你修改代码时,打包工具都会重新打包项目,生成新的文件,并将它们提供给浏览器。这种方法在处理大型项目时,重新打包的时间会变得很长,从而影响开发体验。

Vite 采用了 "no-bundle" 的方式,利用现代浏览器的 ES 模块支持直接在开发环境中进行模块加载。这意味着在开发时,Vite 不会预先打包整个项目,而是根据需要按需加载模块。所以也可以看出 vite 主要就快在 启动快、热更新快。具体来说:

  • ES 模块:现代浏览器(如 Chrome、Firefox)支持原生的 ES 模块,这允许浏览器直接在客户端解析和加载模块,而不需要预先打包。
  • 按需加载:在开发模式下,当你在浏览器中请求一个模块时,Vite 会即时解析这个模块及其依赖关系,并以独立文件的形式提供给浏览器,而不是将所有模块打包在一起。浏览器会根据需要加载这些模块。
  • 快速热更新(HMR) :由于 Vite 直接利用 ES 模块和 HTTP 请求,当你修改代码时,Vite 只需重新加载修改过的模块,而无需重新打包整个项目。这样可以实现极快的热更新。
  • esbuild:底层使用 esbuild 进行依赖预构建,esbuild 是一个高性能的 JavaScript 和 TypeScript 打包工具。在 Vite 中,esbuild 用于快速解析和构建依赖, 在开发环境中,极大地提升了开发体验。
  1. esbuild 是用 Go 语言编写的,Go 代码在编译时会被转化为机器码,执行速度比解释型语言快。 Go 原生支持并发编程,可以非常高效地利用多核 CPU 的性能。
  2. esbuild 采用了一次解析、多次使用的策略(上一章有提到,会创建一个 esbuild 打包上下文环境(esbuild.context),通过 rebuild 来多次增量构建),即在解析依赖关系的同时完成代码的转译和处理,大大减少了重复解析的开销
  3. esbuild 支持增量编译。即只对变化的文件进行重新编译,而不需要每次都从头编译整个项目。
  4. esbuild 原生支持 ES 模块,不需要像其他工具一样进行复杂的模块转换和兼容性处理。这种方式减少了额外的开销,提高了构建效率。

vite no-bundle 模式的工作原理

  1. 请求拦截和处理
  • 浏览器请求一个模块时,Vite 开发服务器会拦截请求并处理
  • Vite 会解析请求的模块及其依赖关系,将其转换为浏览器可以直接执行的 ES 模块。
  1. 依赖解析
  • Vite 会解析模块的依赖,并生成相应的 ES 模块导入路径
  • 如果依赖是 npm 包(如 lodash),Vite 会将其转换为符合 ES 模块规范的格式。
  1. 模块转换
  • Vite 会根据文件类型对模块进行转换。例如,Vue 文件会被转换成 JavaScript 模块,CSS 文件会被转换成 JavaScript 代码中的样式。
  • Vite 使用插件系统来处理不同类型的文件,确保它们可以被浏览器正确加载。
  1. 响应浏览器请求
  • Vite 处理完成后,将转换后的模块返回给浏览器。
  • 浏览器加载并执行这些模块,按需加载后续依赖。

深入源码来理解 no-bundle 整个过程

transformMiddleware 这个中间件主要就是用来拦截请求的模块,并对该模块进行转换,经过一系列的处理后,这个中间件会将处理完成的结果返回给浏览器。这也是前面几章多次提到 文件转换中间件 的概念,这一章我们就来详细看看。

transformMiddleware

transformMiddleware 这个中间件,在创建服务器使用的时候就已经注册上去了

image.png

来让我们看看这个中间件里面到底做了什么

export function transformMiddleware(
  server: ViteDevServer,
): Connect.NextHandleFunction {
  // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`

  // check if public dir is inside root dir
  const { root, publicDir } = server.config
  const publicDirInRoot = publicDir.startsWith(withTrailingSlash(root))
  const publicPath = `${publicDir.slice(root.length)}/`

  return async function viteTransformMiddleware(req, res, next) {
    if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
      return next()
    }

    let url: string
    try {
      url = decodeURI(removeTimestampQuery(req.url!)).replace(
        NULL_BYTE_PLACEHOLDER,
        '\0',
      )
    } catch (e) {
      return next(e)
    }

    const withoutQuery = cleanUrl(url)

    try {
      const isSourceMap = withoutQuery.endsWith('.map')
      // since we generate source map references, handle those requests here
      if (isSourceMap) {
        const depsOptimizer = getDepsOptimizer(server.config, false) // non-ssr
        if (depsOptimizer?.isOptimizedDepUrl(url)) {
          // If the browser is requesting a source map for an optimized dep, it
          // means that the dependency has already been pre-bundled and loaded
          const sourcemapPath = url.startsWith(FS_PREFIX)
            ? fsPathFromId(url)
            : normalizePath(path.resolve(server.config.root, url.slice(1)))
          try {
            const map = JSON.parse(
              await fsp.readFile(sourcemapPath, 'utf-8'),
            ) as ExistingRawSourceMap

            applySourcemapIgnoreList(
              map,
              sourcemapPath,
              server.config.server.sourcemapIgnoreList,
              server.config.logger,
            )

            return send(req, res, JSON.stringify(map), 'json', {
              headers: server.config.server.headers,
            })
          } catch (e) {
            // Outdated source map request for optimized deps, this isn't an error
            // but part of the normal flow when re-optimizing after missing deps
            // Send back an empty source map so the browser doesn't issue warnings
            const dummySourceMap = {
              version: 3,
              file: sourcemapPath.replace(/\.map$/, ''),
              sources: [],
              sourcesContent: [],
              names: [],
              mappings: ';;;;;;;;;',
            }
            return send(req, res, JSON.stringify(dummySourceMap), 'json', {
              cacheControl: 'no-cache',
              headers: server.config.server.headers,
            })
          }
        } else {
          const originalUrl = url.replace(/\.map($|\?)/, '$1')
          const map = (
            await server.moduleGraph.getModuleByUrl(originalUrl, false)
          )?.transformResult?.map
          if (map) {
            return send(req, res, JSON.stringify(map), 'json', {
              headers: server.config.server.headers,
            })
          } else {
            return next()
          }
        }
      }

      if (publicDirInRoot && url.startsWith(publicPath)) {
        warnAboutExplicitPublicPathInUrl(url)
      }

      if (
        isJSRequest(url) ||
        isImportRequest(url) ||
        isCSSRequest(url) ||
        isHTMLProxy(url)
      ) {
        // strip ?import
        url = removeImportQuery(url)
        // Strip valid id prefix. This is prepended to resolved Ids that are
        // not valid browser import specifiers by the importAnalysis plugin.
        url = unwrapId(url)

        // for CSS, we differentiate between normal CSS requests and imports
        if (isCSSRequest(url)) {
          if (
            req.headers.accept?.includes('text/css') &&
            !isDirectRequest(url)
          ) {
            url = injectQuery(url, 'direct')
          }

          // check if we can return 304 early for CSS requests. These aren't handled
          // by the cachedTransformMiddleware due to the browser possibly mixing the
          // etags of direct and imported CSS
          const ifNoneMatch = req.headers['if-none-match']
          if (
            ifNoneMatch &&
            (await server.moduleGraph.getModuleByUrl(url, false))
              ?.transformResult?.etag === ifNoneMatch
          ) {
            debugCache?.(`[304] ${prettifyUrl(url, server.config.root)}`)
            res.statusCode = 304
            return res.end()
          }
        }

        // resolve, load and transform using the plugin container
        const result = await transformRequest(url, server, {
          html: req.headers.accept?.includes('text/html'),
        })
        if (result) {
          const depsOptimizer = getDepsOptimizer(server.config, false) // non-ssr
          const type = isDirectCSSRequest(url) ? 'css' : 'js'
          const isDep =
            DEP_VERSION_RE.test(url) || depsOptimizer?.isOptimizedDepUrl(url)
          return send(req, res, result.code, type, {
            etag: result.etag,
            // allow browser to cache npm deps!
            cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
            headers: server.config.server.headers,
            map: result.map,
          })
        }
      }
    } catch (e) {
      if (e?.code === ERR_OPTIMIZE_DEPS_PROCESSING_ERROR) {
        // Skip if response has already been sent
        if (!res.writableEnded) {
          res.statusCode = 504 // status code request timeout
          res.statusMessage = 'Optimize Deps Processing Error'
          res.end()
        }
        // This timeout is unexpected
        server.config.logger.error(e.message)
        return
      }
      if (e?.code === ERR_OUTDATED_OPTIMIZED_DEP) {
        // Skip if response has already been sent
        if (!res.writableEnded) {
          res.statusCode = 504 // status code request timeout
          res.statusMessage = 'Outdated Optimize Dep'
          res.end()
        }
        // We don't need to log an error in this case, the request
        // is outdated because new dependencies were discovered and
        // the new pre-bundle dependencies have changed.
        // A full-page reload has been issued, and these old requests
        // can't be properly fulfilled. This isn't an unexpected
        // error but a normal part of the missing deps discovery flow
        return
      }
      if (e?.code === ERR_CLOSED_SERVER) {
        // Skip if response has already been sent
        if (!res.writableEnded) {
          res.statusCode = 504 // status code request timeout
          res.statusMessage = 'Outdated Request'
          res.end()
        }
        // We don't need to log an error in this case, the request
        // is outdated because new dependencies were discovered and
        // the new pre-bundle dependencies have changed.
        // A full-page reload has been issued, and these old requests
        // can't be properly fulfilled. This isn't an unexpected
        // error but a normal part of the missing deps discovery flow
        return
      }
      if (e?.code === ERR_FILE_NOT_FOUND_IN_OPTIMIZED_DEP_DIR) {
        // Skip if response has already been sent
        if (!res.writableEnded) {
          res.statusCode = 404
          res.end()
        }
        server.config.logger.warn(colors.yellow(e.message))
        return
      }
      if (e?.code === ERR_LOAD_URL) {
        // Let other middleware handle if we can't load the url via transformRequest
        return next()
      }
      return next(e)
    }

    next()
  }

  function warnAboutExplicitPublicPathInUrl(url: string) {
    let warning: string

    if (isImportRequest(url)) {
      const rawUrl = removeImportQuery(url)
      if (urlRE.test(url)) {
        warning =
          `Assets in the public directory are served at the root path.\n` +
          `Instead of ${colors.cyan(rawUrl)}, use ${colors.cyan(
            rawUrl.replace(publicPath, '/'),
          )}.`
      } else {
        warning =
          'Assets in public directory cannot be imported from JavaScript.\n' +
          `If you intend to import that asset, put the file in the src directory, and use ${colors.cyan(
            rawUrl.replace(publicPath, '/src/'),
          )} instead of ${colors.cyan(rawUrl)}.\n` +
          `If you intend to use the URL of that asset, use ${colors.cyan(
            injectQuery(rawUrl.replace(publicPath, '/'), 'url'),
          )}.`
      }
    } else {
      warning =
        `Files in the public directory are served at the root path.\n` +
        `Instead of ${colors.cyan(url)}, use ${colors.cyan(
          url.replace(publicPath, '/'),
        )}.`
    }

    server.config.logger.warn(colors.yellow(warning))
  }
}

我们主要来看try catch 里面的逻辑

 try {
      // 这一块主要是处理 sourcemap 文件

      //......


      // 检查请求的 URL 是否涉及到 Vite 项目中的 public 目录,并在特定情况下发出警告
      // 1. 避免冗余路径:在 URL 中显式包含 public 目录可能是多余的,因为 Vite 已经配置了静态资源的处理方式
      // 2. 防止错误:显式在 URL 中包含 public 目录的路径可能会导致路径解析错误或资源未找到的问题
      // 3. 一致性:确保 URL 路径的一致性和正确性,避免用户在 URL 中使用不必要的路径
      // 例如 访问public 下面的文件 /public/img.png 这里是不需要加 public
      if (publicDirInRoot && url.startsWith(publicPath)) {
        warnAboutExplicitPublicPathInUrl(url);
      }

      if (
        // 是否是 js 请求
        isJSRequest(url) ||
        // 是否是 导入请求(如模块请求)
        isImportRequest(url) ||
        // 是否是 css 请求
        isCSSRequest(url) ||
        // 请求的是否是 html-proxy
        isHTMLProxy(url)
      ) {
        // 用于去除 URL 中的 ?import 查询参数,以便更好地处理和转换请求
        url = removeImportQuery(url);
        // 去除由 importAnalysis 插件添加的有效 ID 前缀,这些前缀通常用于解决导入路径,但在处理请求时需要去除
        // 像/@id/
        url = unwrapId(url);

      
        if (isCSSRequest(url)) {
          if (
            // 检查请求头中是否接受 text/css 类型
            req.headers.accept?.includes("text/css") &&
            // 检查请求是否不是直接请求(即可能是 CSS 导入)
            !isDirectRequest(url)
          ) {
            // 为 css 导入 注入查询参数
            url = injectQuery(url, "direct");
          }

          // check if we can return 304 early for CSS requests. These aren't handled
          // by the cachedTransformMiddleware due to the browser possibly mixing the
          // etags of direct and imported CSS
          /**
           * 1. 这段注释说明了代码的主要目的是检查是否可以提前返回一个 304 状态码。304 状态码表示“未修改”,
           * 用于告诉客户端缓存的资源是最新的,无需重新下载。这样做可以提高响应速度和效率。
           *
           * 2. cachedTransformMiddleware 是 Vite 中的一个中间件,用于处理缓存文件转换模块。
           * 在开发模式中,Vite 需要对 JavaScript 和 CSS 模块进行转换和缓存管理。
           * CSS 请求(特别是直接请求和导入请求)不会经过 cachedTransformMiddleware 处理。
           * 这是因为 cachedTransformMiddleware 可能只处理了部分资源,而 CSS 文件的处理需要特别注意。
           *
           * 3. etags 是用于缓存验证的标识符。浏览器使用 ETag 值来判断缓存的资源是否已经过期。
           * 混合 etags 指的是浏览器可能将直接请求的 CSS 文件和通过 JavaScript 导入的 CSS 文件的 ETag 值混合在一起。这可能导致缓存冲突或验证问题。
           */

          /**
           * 这段代码处理了 CSS 请求中的缓存验证,尤其是在处理直接请求和导入请求时的缓存策略。其目的是提高缓存效率和避免缓存冲突。
           */
          const ifNoneMatch = req.headers["if-none-match"];
          if (
            ifNoneMatch &&
            // 检查 if-none-match 请求头中的 ETag 值,并与服务器上实际模块的 ETag 进行比较
            // 确保 CSS 文件的缓存验证不会混淆或冲突
            (await server.moduleGraph.getModuleByUrl(url, false))
              ?.transformResult?.etag === ifNoneMatch
          ) {
            // 如果 ETag 匹配,服务器可以快速返回 304 状态码,避免重新处理和传输 CSS 文件,从而提高响应速度和性能
            debugCache?.(`[304] ${prettifyUrl(url, server.config.root)}`);
            res.statusCode = 304;
            return res.end();
          }
        }
        
        // resolve, load and transform using the plugin container
        const result = await transformRequest(url, server, {
          html: req.headers.accept?.includes("text/html"),
        });

        // 如果 result 存在,意味着请求的模块经过解析、加载和转换,现在可以返回给客户端
        if (result) {
          // 用于获取依赖优化器
          const depsOptimizer = getDepsOptimizer(server.config, false); // non-ssr
          // 判断请求的 URL 是否是直接请求 CSS 文件
          const type = isDirectCSSRequest(url) ? "css" : "js";

          // 检查当前请求的 url 是否为经过依赖预构建,如果是的话则设置强缓存
          const isDep =
            DEP_VERSION_RE.test(url) || depsOptimizer?.isOptimizedDepUrl(url);
          return send(req, res, result.code, type, {
            etag: result.etag,
            // allow browser to cache npm deps!
            cacheControl: isDep ? "max-age=31536000,immutable" : "no-cache",
            headers: server.config.server.headers,
            map: result.map,
          });
        }
      }
    } catch (e) {
      // 错误处理这里就不看了
      return next(e);
    }

在处理CSS 请求时,Vite 区分了两种情况:普通的 CSS 请求和 CSS 导入,目的是确保对这两种请求类型进行适当的处理,尤其是在缓存和响应策略方面

  1. 普通的 CSS 请求是指直接从浏览器请求的 CSS 文件。这通常是通过 标签或 CSS 文件的 URL 进行的请求。例如:
<link rel="stylesheet" href="/styles/main.css">

对于普通的 CSS 请求:

  • Vite 会设置缓存策略,以确保浏览器能够缓存 CSS 文件并在未来的请求中使用这些缓存的文件,Vite 会为这些请求设置适当的响应头,例如 Cache-Control
  1. CSS 导入是指在 JS 文件中通过 import 语法导入的 CSS 文件。例如:
import './styles/main.css';

对于 CSS 导入:

如果请求的 CSS 文件是通过 JS 导入的,Vite 会在 URL 中注入查询参数(例如 ?direct),以区分这种类型的请求,这样可以确保 Vite 在处理这些请求时采用不同的策略。由于这些 CSS 文件可能被多个模块动态导入,Vite 可能会使用不同的缓存策略,以确保不会发生缓存冲突或错误

接下来我们的重点放在下面函数(transformRequest

const result = await transformRequest(url, server, {
  html: req.headers.accept?.includes("text/html"),
});

在 Vite 的开发服务器中,resolve, load, 和 transform 是处理模块请求的关键步骤。这些步骤通常通过插件容器(plugin container)来完成

  1. resolve 是用于解析模块的路径。它负责将模块的导入路径转换为实际的文件系统路径。
  2. load 是用于加载模块的内容。它负责读取文件内容并将其提供给后续的处理步骤。
  3. transform 是用于处理和转换模块内容的步骤。这包括将原始文件内容转换为目标格式,如将 ES6 模块转为 ES5、处理 CSS 预处理器等。

transformRequest 这个方法用于处理请求并进行模块转换

export function transformRequest(
  url: string,
  server: ViteDevServer,
  options: TransformOptions = {},
): Promise<TransformResult | null> {
  if (server._restartPromise && !options.ssr) throwClosedServerError()

  const cacheKey = (options.ssr ? 'ssr:' : options.html ? 'html:' : '') + url

  // This module may get invalidated while we are processing it. For example
  // when a full page reload is needed after the re-processing of pre-bundled
  // dependencies when a missing dep is discovered. We save the current time
  // to compare it to the last invalidation performed to know if we should
  // cache the result of the transformation or we should discard it as stale.
  //
  // A module can be invalidated due to:
  // 1. A full reload because of pre-bundling newly discovered deps
  // 2. A full reload after a config change
  // 3. The file that generated the module changed
  // 4. Invalidation for a virtual module
  //
  // For 1 and 2, a new request for this module will be issued after
  // the invalidation as part of the browser reloading the page. For 3 and 4
  // there may not be a new request right away because of HMR handling.
  // In all cases, the next time this module is requested, it should be
  // re-processed.
  //
  // We save the timestamp when we start processing and compare it with the
  // last time this module is invalidated
  const timestamp = Date.now()

  const pending = server._pendingRequests.get(cacheKey)
  if (pending) {
    return server.moduleGraph
      .getModuleByUrl(removeTimestampQuery(url), options.ssr)
      .then((module) => {
        if (!module || pending.timestamp > module.lastInvalidationTimestamp) {
          // The pending request is still valid, we can safely reuse its result
          return pending.request
        } else {
          // Request 1 for module A     (pending.timestamp)
          // Invalidate module A        (module.lastInvalidationTimestamp)
          // Request 2 for module A     (timestamp)

          // First request has been invalidated, abort it to clear the cache,
          // then perform a new doTransform.
          pending.abort()
          return transformRequest(url, server, options)
        }
      })
  }

  const request = doTransform(url, server, options, timestamp)

  // Avoid clearing the cache of future requests if aborted
  let cleared = false
  const clearCache = () => {
    if (!cleared) {
      server._pendingRequests.delete(cacheKey)
      cleared = true
    }
  }

  // Cache the request and clear it once processing is done
  server._pendingRequests.set(cacheKey, {
    request,
    timestamp,
    abort: clearCache,
  })

  return request.finally(clearCache)
}

下面是这段代码中对注释的解释:

  • 模块可能在处理过程中无效化:

    • 场景:在发现缺少的依赖后重新处理预打包的依赖时,可能需要完全重新加载页面
    • 处理方式:保存当前时间戳,用于与最后一次无效化的时间进行比较,以确定是缓存转换结果还是将其丢弃为过期的
  • 模块无效化的原因:

    • 预打包新发现依赖:由于重新处理预打包的依赖,可能需要完全重新加载页面
    • 配置更改后完全重新加载:配置更改后,可能需要完全重新加载页面
    • 生成模块的文件发生变化:文件发生变化后,生成的模块可能无效化
    • 虚拟模块的无效化:虚拟模块的无效化
  • 处理流程:

    • 场景 1 和 2:在无效化后,浏览器重新加载页面时,会发出新的请求
    • 场景 3 和 4:由于热模块替换 (HMR) 的处理,可能不会立即发出新的请求
  • 解决方法:

    • 无论是哪种情况,下次请求这个模块时,都应该重新处理
    • 时间戳比较:保存开始处理时的时间戳,并与模块最后无效化的时间戳进行比较

这些注释说明了处理模块转换时需要考虑的各种无效化场景,并解释了通过时间戳比较来确保模块在无效化后得到正确处理的方法。这样可以确保转换结果的有效性,并在必要时重新处理模块,以保持系统的正确性和一致性

我们来看这个函数的一些逻辑

const cacheKey = (options.ssr ? "ssr:" : options.html ? "html:" : "") + url;
const timestamp = Date.now();

const pending = server._pendingRequests.get(cacheKey)
  if (pending) {
    return server.moduleGraph
      .getModuleByUrl(removeTimestampQuery(url), options.ssr)
      .then((module) => {
        if (!module || pending.timestamp > module.lastInvalidationTimestamp) {
          return pending.request
        } else {
          pending.abort()
          return transformRequest(url, server, options)
        }
      })
  }
  1. 首先会根据配置和请求的url生成一个唯一的缓存key,检查该key 是否存在有待处理的请求。

  2. 如果存在,这里会检查该模块是否在请求待处理期间被无效化, 通过 url 从模块图中获取模块信息

    • 如果 module 不存在,表示模块还没有被处理过,因此可以继续处理待处理的请求

    • 如果挂起请求的时间戳(pending.timestamp)晚于模块的最后无效化时间戳(module.lastInvalidationTimestamp)则表示待处理的请求仍然有效,可以安全地重用待处理请求的结果

    • 这里直接 return pending.request; 将处理的结果返回

  3. 否则待处理请求无效(因为模块已经失效并重新验证) :

    • 中止当前待处理请求以清理缓存
    • 执行新的转换请求(将当前请求的模块重新转换一次)

这段代码的主要目的是优化模块请求的处理,通过以下几个步骤来提高效率:

  1. 检查是否有相同的待处理请求,以避免重复处理
  2. 重用有效的待处理请求结果,减少不必要的重新转换。
  3. 请求失效时中止无效请求并执行新的转换请求,以确保模块的最新状态
const request = doTransform(url, server, options, timestamp)

// Avoid clearing the cache of future requests if aborted
let cleared = false
const clearCache = () => {
  if (!cleared) {
    server._pendingRequests.delete(cacheKey)
    cleared = true
  }
}

// Cache the request and clear it once processing is done
server._pendingRequests.set(cacheKey, {
  request,
  timestamp,
  abort: clearCache,
})

return request.finally(clearCache)

这段代码是 Vite 处理模块请求并缓存请求结果的一部分。它通过缓存机制避免重复请求,同时确保在请求完成或中止时清理缓存。

我们可以看到执行 doTransform 来处理模块,然后将处理的结果缓存在待处理的请求中。doTransform 是 Vite 中的一个核心函数,它负责对请求的模块进行解析、加载和转换

async function doTransform(
  url: string,
  server: ViteDevServer,
  options: TransformOptions,
  timestamp: number,
) {
  url = removeTimestampQuery(url)

  const { config, pluginContainer } = server
  const ssr = !!options.ssr

  if (ssr && isDepsOptimizerEnabled(config, true)) {
    await initDevSsrDepsOptimizer(config, server)
  }

  let module = await server.moduleGraph.getModuleByUrl(url, ssr)
  if (module) {
    // try use cache from url
    const cached = await getCachedTransformResult(
      url,
      module,
      server,
      ssr,
      timestamp,
    )
    if (cached) return cached
  }

  const resolved = module
    ? undefined
    : (await pluginContainer.resolveId(url, undefined, { ssr })) ?? undefined

  // resolve
  const id = module?.id ?? resolved?.id ?? url

  module ??= server.moduleGraph.getModuleById(id)
  if (module) {
    // if a different url maps to an existing loaded id,  make sure we relate this url to the id
    await server.moduleGraph._ensureEntryFromUrl(url, ssr, undefined, resolved)
    // try use cache from id
    const cached = await getCachedTransformResult(
      url,
      module,
      server,
      ssr,
      timestamp,
    )
    if (cached) return cached
  }

  const result = loadAndTransform(
    id,
    url,
    server,
    options,
    timestamp,
    module,
    resolved,
  )

  if (!ssr) {
    // Only register client requests, server.waitForRequestsIdle should
    // have been called server.waitForClientRequestsIdle. We can rename
    // it as part of the environment API work
    const depsOptimizer = getDepsOptimizer(config, ssr)
    if (!depsOptimizer?.isOptimizedDepFile(id)) {
      server._registerRequestProcessing(id, () => result)
    }
  }

  return result
}

这一步是在调用插件容器身上的方法 resolveId 来对请求的url进行处理

const resolved = module
  ? undefined
  : (await pluginContainer.resolveId(url, undefined, { ssr })) ?? undefined

loadAndTransform 函数加载并转换模块,这里真正执行了调用模块转换

const result = loadAndTransform(
    id,
    url,
    server,
    options,
    timestamp,
    module,
    resolved

让我们来看看 loadAndTransform 这个函数,里面有两个重要部分** load** 和 transform

// 存储加载的模块代码
let code: string | null = null
// 存储加载的源映射信息 sourcemap
let map: SourceDescription['map'] = null

// load
const loadStart = debugLoad ? performance.now() : 0
const loadResult = await pluginContainer.load(id, { ssr })
if (loadResult == null) {
  // 如果是 HTML 请求并且没有加载结果,并且不是以 .html 结尾的文件
  // 则跳过到单页应用 (SPA) 的回退处理
  if (options.html && !id.endsWith('.html')) {
    return null
  }
  // try fallback loading it from fs as string
  // if the file is a binary, there should be a plugin that already loaded it
  // as string
  // only try the fallback if access is allowed, skip for out of root url
  // like /service-worker.js or /api/users
  if (options.ssr || isFileServingAllowed(file, server)) {
    try {
      // 读取文件内容
      code = await fsp.readFile(file, 'utf-8')
      debugLoad?.(`${timeFrom(loadStart)} [fs] ${prettyUrl}`)
    } catch (e) {
      if (e.code !== 'ENOENT') {
        if (e.code === 'EISDIR') {
          e.message = `${e.message} ${file}`
        }
        throw e
      }
    }
    if (code != null) {
      // 确保文件被监视
      ensureWatchedFile(server.watcher, file, config.root)
    }
  }
  if (code) {
    try {
      // 从文件中提取源映射信息 source map
      const extracted = await extractSourcemapFromFile(code, file)
      if (extracted) {
        code = extracted.code
        map = extracted.map
      }
    } catch (e) {
      logger.warn(`Failed to load source map for ${file}.\n${e}`, {
        timestamp: true,
      })
    }
  }
} else {
  debugLoad?.(`${timeFrom(loadStart)} [plugin] ${prettyUrl}`)
  if (isObject(loadResult)) {
    code = loadResult.code
    map = loadResult.map
  } else {
    code = loadResult
  }
}
if (code == null) {
  const isPublicFile = checkPublicFile(url, config)
  let publicDirName = path.relative(config.root, config.publicDir)
  if (publicDirName[0] !== '.') publicDirName = '/' + publicDirName
  const msg = isPublicFile
    ? `This file is in ${publicDirName} and will be copied as-is during ` +
      `build without going through the plugin transforms, and therefore ` +
      `should not be imported from source code. It can only be referenced ` +
      `via HTML tags.`
    : `Does the file exist?`
  const importerMod: ModuleNode | undefined = server.moduleGraph.idToModuleMap
    .get(id)
    ?.importers.values()
    .next().value
  const importer = importerMod?.file || importerMod?.url
  const err: any = new Error(
    `Failed to load url ${url} (resolved id: ${id})${
      importer ? ` in ${importer}` : ''
    }. ${msg}`,
  )
  err.code = isPublicFile ? ERR_LOAD_PUBLIC_URL : ERR_LOAD_URL
  throw err
}

load 期间会调用插件容器的 load 方法来加载请求的模块,插件容器中的load 方法主要是拿到有 load 方法的插件,并依次去调用这些插件的 load 方法。

image.png

这里会获取所有有 load 方法的插件,并依次执行这些插件的 load 方法。

image.png

image.png

接下来就是对 load 的结果做处理:

  • 没有返回结果

    • 如果是 HTML 请求并且不是以 .html 结尾的文件 则跳过到单页应用 (SPA) 的回退处理
    • ssr 环境或者文件系统允许的情况下,去读取文件内容
  • 有返回结果

    • 从返回结果中获取code 和 map

这里还会继续检查一下 code,如果不存在,则会生成相应的错误消息并抛出错误,中断加载流程并显示错误信息

我们再来看 transform,它会去调用插件身上的 tranform 方法来对代码进行处理

// transform
  const transformStart = debugTransform ? performance.now() : 0
  const transformResult = await pluginContainer.transform(code, id, {
    inMap: map,
    ssr,
  })
  const originalCode = code
  if (
    transformResult == null ||
    (isObject(transformResult) && transformResult.code == null)
  ) {
    // no transform applied, keep code as-is
    debugTransform?.(
      timeFrom(transformStart) + colors.dim(` [skipped] ${prettyUrl}`),
    )
  } else {
    debugTransform?.(`${timeFrom(transformStart)} ${prettyUrl}`)
    code = transformResult.code!
    map = transformResult.map
  }

image.png

这里和 load 的处理类似,也是会去找到所有插件身上有 tranform 的插件,并依次执行这些插件

image.png

这里提一下 importAnalysis 插件,就是在这里被调用处理的,这个插件会单独出一章来讲解。

image.png

这里的 code 就是经过 load 处理后,再传入 transform 进行进一步处理,然后将处理完成的结果返回,最后将结果返回给客户端

image.png

cachedTransformMiddleware

这个中间件用于处理 Vite 开发服务器中的缓存转换逻辑。它的主要目的是检查请求的 ETag 头,如果可以使用缓存响应,就返回 HTTP 状态码 304 (Not Modified),以避免重新处理请求,从而提升性能。

export function cachedTransformMiddleware(
  server: ViteDevServer,
): Connect.NextHandleFunction {
  // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
  return function viteCachedTransformMiddleware(req, res, next) {
    // check if we can return 304 early
    const ifNoneMatch = req.headers['if-none-match']
    if (ifNoneMatch) {
      const moduleByEtag = server.moduleGraph.getModuleByEtag(ifNoneMatch)
      if (moduleByEtag?.transformResult?.etag === ifNoneMatch) {
        // For CSS requests, if the same CSS file is imported in a module,
        // the browser sends the request for the direct CSS request with the etag
        // from the imported CSS module. We ignore the etag in this case.
        const maybeMixedEtag = isCSSRequest(req.url!)
        if (!maybeMixedEtag) {
          debugCache?.(`[304] ${prettifyUrl(req.url!, server.config.root)}`)
          res.statusCode = 304
          return res.end()
        }
      }
    }

    next()
  }
}

这段代码先检查If-None-Matc 浏览器在请求头中带有 If-None-Match,表示它已经有一个缓存的版本,并带有 ETag 值,服务器通过这个 ETag 来判断是否内容有变化。然后从模块依赖图中根据 etag 来获取对应的模块信息, 如果模块的转换结果中的 ETag 与请求头中的 ETag 相同,说明内容没有变化,可以使用缓存。

这里会忽略css 请求,源码中给出了解释,让我们来看一下:

  • 开发环境中,一个 CSS 文件可能会被多次请求。一次是直接请求,比如 <link href="style.css" rel="stylesheet">,另一次是通过模块系统导入,比如在一个 JavaScript 模块中 import './style.css'
  • 浏览器会为直接请求和模块系统导入的请求发送 ETag 头。
  • 在处理 CSS 请求时,服务器发现 ETag 值匹配,但这是通过模块系统导入的请求,浏览器可能会混淆直接请求和导入请求的 ETag
  • 为了避免这种混淆,服务器选择忽略这种情况下的 ETag,而不是直接返回 304 状态码。

结语

这一章主要讲解了为什么vite 能这么快,这得益于它的 no-bundle 模式,充分利用浏览器 esm 特性,以及开发环境使用 esbuild,使用预处理速度变得更快。vite 的热更新在文件发生变化后,会经过一些处理,然后直接请求变更的文件,而不用像webpack 那样重新打包,这也使得vite 的热更新是极快的。

后面的章节会继续介绍 importAnalysisPlugin 和 vite 的热更新