小白也能读懂的vite源码系列——vite 依赖预构建(三)

1,606 阅读34分钟

前言

上一章提到了vite 的一些中间件,在处理请求的过程中扮演了很重要的角色,其中 有一个中间件(文件转换)很重要。会对源代码进行转换,以便浏览器能够识别,处理完成后会缓存, 后续由缓存中间件处理。

平常我们引入的第三方依赖,像 vue、axios、vue-router等,都是经过vite 转换后,浏览器才能去识别到。还记得上一章的结尾所提到的吗?导入的第三方依赖会被转换成 '/node_modules/.vite/deps/vue?v=123456' ,?v=123456 这是查询版本,用于让浏览器强制更新,确保使用的依赖是最新的

import { createApp } from 'vue'
// 会被转换成
import { createApp } from '/node_modules/.vite/deps/vue?v=123456'

image.png

我们可以看到main.ts 中请求的vue 变化了,这里的变化是通过导入分析插件来完成的,下一章单独讲这个插件。我们在node_modules/.vite/deps 目录下面发现了vite 生成的依赖(文件系统缓存,后续都是用经过优化处理的缓存)

image.png

/node_modules/.vite/deps 下面的文件就是依赖预构建的时候生成的,然后在通过转换将 'vue' 替换为 /node_modules/.vite/deps/vue?v=123456,这样就能够正常加载文件。(浏览器是不能识别裸导入,路径需要提供相对路径或者绝对路径

文章篇幅较长,可以放心食用

1. 预构建期间做了哪些事情?

Vite 的依赖预构建(dependency pre-bundling)是一个重要的优化步骤,它通过提前处理和优化项目依赖,提升了开发服务器的启动速度和模块的解析速度。

vite官网文档对依赖预构建的描述

官网这里提到主要有两个目的:

  1. CJS 和 UMD 兼容性处理:因为vite需要使用 esm 模块化,所以会先把CJS 和 UMD 转化为 esm
  2. 性能:提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。如lodash-es 有600多个内置模块,当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。

vite 官方文档依赖预构建提到的地方,我们这里都会在源码里找到相应地方,并讲解。在开发模式下,vite 会使用 esbuild 对依赖进行预构建,让我们一步一步来。

2. 依赖预构建流程解析

还记得第一章我们提到的 initServer 这个方法吗,我们再来看一看

1. 执行入口分析

const initServer = async () => {
    //检查服务器是否已经初始化
    if (serverInited) return;
    //检查是否有正在进行的初始化过程
    if (initingServer) return initingServer;

    //开始初始化过程
    initingServer = (async function () {
      // 调用 buildStart 钩子函数,开始构建过程
      await container.buildStart({});
      // 在所有容器插件准备好后启动深度优化器
      if (isDepsOptimizerEnabled(config, false)) {
        //如果启用了依赖优化器,则初始化依赖优化器
        /** 这里开始的依赖预构建 */
        await initDepsOptimizer(config, server);
      }

      //调用 warmupFiles 函数,对一些文件进行预热,以提高性能
      warmupFiles(server);

      //初始化完成后,重置 initingServer 以允许将来的重新初始化
      initingServer = undefined;
      //设置 serverInited 为 true,表示服务器已经初始化完成
      serverInited = true;
    })();
    return initingServer;
  };

initDepsOptimizer 这个方法就是预构建的开始,接下来会 debug 带着大家走一遍

还记得第一章vite 启动流程吗?创建完 vite 开发服务器实例后,会执行 server.listen()

image.png

进入到 server 身上的listen 方法,这个方法里面去调用了 startServer 来启动

image.png

startServer 里面真正执行服务器启动的是 httpServerStart 这个方法 image.png

在 httpServerStart 里面去真正执行了 httpServer.listen 这个方法来启动服务器,但请注意,这里的listen方法被重写过,在这里我们也可以清晰的看到,当端口被占用后,vite会去尝试下一个端口(++port)继续启动,直到启动成功

让我们来看一看 listen 方法的重写,在创建开发服务器的时候完成的,将原生的listen 方法保存一份,并重写 listen方法,这样做的目的是在服务器真正启动之前去完成一些初始化的工作

image.png

所以现在知道预构建是在哪一步执行的了吧,在执行真正的 listen 方法之前会去执行一些初始化的逻辑,如预构建。源码中有对 initServer 的一些相关注释,我们来看一看

  • 在某些情况下,httpServer.listen 可能会被多次调用。 比如在尝试不同端口号启动服务器时,可能会尝试多个端口,直到找到一个可用的端口。
  • 这段代码的目的是为了避免多次调用 buildStart 方法。buildStart 方法可能涉及到耗时的初始化过程,如果多次调用会导致性能问题或其他不可预见的问题。

所以这些的初始化的代码在成功执行完一次后,不会在执行了,其中就包含 initDepsOptimizer,也就是依赖预构建的执行入口 image.png

2. 预构建的执行逻辑

我们可以发现,没有缓存的情况下,就会去创建一个依赖优化器。注意,这里的缓存并不是预构建依赖的文件系统缓存,而是内存缓存

image.png

depsOptimizerMapdevSsrDepsOptimizerMap,我们可以看到这两个是预构建依赖的内存缓存 image.png

我们接下来来看创建依赖优化器(createDepsOptimizer),这个函数涉及到的内容很多,我们这里只关注主流程细节,更多的细节可以去我github项目查看更多注释

async function createDepsOptimizer(
  config: ResolvedConfig,
  server: ViteDevServer
): Promise<void> {

  /* 这里主要是在初始化一些数据,定义一些辅助函数 */

    // 初始化元数据
  let metadata =
    cachedMetadata || initDepsOptimizerMetadata(config, ssr, sessionTimestamp);

   const depsOptimizer: DepsOptimizer = {
    metadata, //依赖优化的元数据
    registerMissingImport, //注册缺失的导入
    run: () => debouncedProcessing(0), //启动防抖处理
    // 建判断是否为优化依赖文件的函数
    isOptimizedDepFile: createIsOptimizedDepFile(config),
    // 创建判断是否为优化依赖 URL 的函数
    isOptimizedDepUrl: createIsOptimizedDepUrl(config),
    // 获取优化依赖的 ID,通过依赖信息的 file 和 browserHash 生成
    getOptimizedDepId: (depInfo: OptimizedDepInfo) =>
      `${depInfo.file}?v=${depInfo.browserHash}`,
    close, //关闭优化器的函数
    options, //优化器的配置选项
  };

    // 设置缓存
  depsOptimizerMap.set(config, depsOptimizer);

    if (!cachedMetadata) {
    // 冷启动(没有缓存的元数据)

    // 进入处理状态,直到静态导入的抓取结束
    // 设置为 true,表示正在处理依赖优化
    currentlyProcessing = true;

    // 初始化手动包含的依赖项
    /**来存储手动包含的依赖项 */
    const manuallyIncludedDeps: Record<string, string> = {};

    // 添加手动包含的依赖项
    await addManuallyIncludedOptimizeDeps(manuallyIncludedDeps, config, ssr);

    // 将手动包含的依赖项转换 这是一个依赖信息的记录
    const manuallyIncludedDepsInfo = toDiscoveredDependencies(
      config,
      manuallyIncludedDeps,
      ssr,
      sessionTimestamp
    );

    // 将每个手动包含的依赖信息添加到 metadata 的 discovered 部分,
    // 同时将 processing 设置为 depOptimizationProcessing.promise。
    for (const depInfo of Object.values(manuallyIncludedDepsInfo)) {
      addOptimizedDepInfo(metadata, "discovered", {
        ...depInfo,
        /**
         * depOptimizationProcessing 是一个包含 resolve 和 promise 属性的对象
         *
         * 将一个表示依赖优化处理状态的 Promise 关联到每一个依赖项上
         * 1. 跟踪依赖项的优化状态:任何地方都可以通过这个 Promise 知道依赖优化过程是否完成
         * 2. 实现异步操作:当依赖项在优化过程中,可以通过这个 Promise 实现异步等待,确保优化完成后再进行下一步操作
         */
        processing: depOptimizationProcessing.promise,
      });
      // 新的依赖发现
      newDepsDiscovered = true;
    }

    // 下面这段代码的主要目的是在开发环境中扫描项目依赖项,并根据扫描结果进行依赖优化处理
    if (noDiscovery) {
      // We don't need to scan for dependencies or wait for the static crawl to end
      // Run the first optimization run immediately
      // 如果不需要进行依赖扫描或等待静态抓取结束
      // 立即进行第一次优化

      // noDiscovery 为 true 表示不需要进行依赖扫描,可以立即进行第一次优化处理
      runOptimizer();
    } else {
      // 进入依赖扫描和优化的流程

      // 注意,扫描器仅用于开发环境
      // 这个 Promise 用于追踪依赖扫描过程的状态
      depsOptimizer.scanProcessing = new Promise((resolve) => {
        // 在后台运行,以防止阻塞高优先级任务
        (async () => {
          try {
            debug?.(colors.green(`scanning for dependencies...`));

            // 异步函数扫描项目依赖项,并将结果存储在 deps 中
            discover = discoverProjectDependencies(config);
            const deps = await discover.result;
            discover = undefined;

            // 获取手动包含的依赖项列表
            const manuallyIncluded = Object.keys(manuallyIncludedDepsInfo);

            // 过滤出扫描过程中发现的新依赖项,并将其添加到 discoveredDepsWhileScanning 列表中
            discoveredDepsWhileScanning.push(
              ...Object.keys(metadata.discovered).filter(
                // 过滤出发现的新依赖项
                (dep) => !deps[dep] && !manuallyIncluded.includes(dep)
              )
            );

            // Add these dependencies to the discovered list, as these are currently
            // used by the preAliasPlugin to support aliased and optimized deps.
            // This is also used by the CJS externalization heuristics in legacy mode
            /**
             *  这一段注释解释了为什么要将这些依赖项添加到已发现的依赖项列表中
             *
             * 1. 支持别名和优化依赖项:
             * 这些依赖项目前由 preAliasPlugin 使用,preAliasPlugin 是 Vite 中的一个插件,用于处理依赖项的别名和优化
             * 将这些依赖项添加到已发现的依赖项列表中,可以确保它们在构建过程中被正确处理和优化。
             *
             * 2. 旧模式中的 CJS 外部化启发式:
             * 这些依赖项还用于旧模式中的 CJS(CommonJS)外部化启发式方法。
             * 在旧模式下,Vite 可能需要根据某些启发式方法决定哪些依赖项需要外部化处理(即不打包进最终构建产物中,而是保持为外部依赖)
             */

            // 将扫描过程中发现的依赖项添加到元数据的 discovered 部分。
            for (const id of Object.keys(deps)) {
              // 检查这些依赖项是否已经在 metadata.discovered 中(即是否已经被记录为已发现的依赖项)
              if (!metadata.discovered[id]) {
                // 对于尚未被记录为已发现的依赖项,将其添加到已发现的依赖项列表中
                addMissingDep(id, deps[id]);
              }
            }

            // 准备已知的依赖项
            const knownDeps = prepareKnownDeps();
            // 用来启动下一个已发现的依赖项批次处理
            startNextDiscoveredBatch();

            // 在开发环境中,依赖项扫描器和第一次依赖项优化将在后台运行
            optimizationResult = runOptimizeDeps(config, knownDeps, ssr);

            /**
             * 如果 holdUntilCrawlEnd 为 true,表示我们需要等待静态导入的爬取过程结束,
             * 然后再决定是否将结果发送到浏览器,或者需要执行另一个优化步骤
             */
            if (!holdUntilCrawlEnd) {
              // If not, we release the result to the browser as soon as the scanner
              // is done. If the scanner missed any dependency, and a new dependency
              // is discovered while crawling static imports, then there will be a
              // full-page reload if new common chunks are generated between the old
              // and new optimized deps.
              /**
               * 这段注释解释了在 holdUntilCrawlEnd 为 false 的情况下,
               * 依赖扫描器完成后会立即将结果释放到浏览器的行为,以及在静态导入爬取过程中可能出现的情况
               *
               *
               * 1. 释放结果到浏览器:
               * 如果 holdUntilCrawlEnd 为 false,意味着不需要等待静态导入的爬取过程结束
               * 在这种情况下,一旦依赖扫描器完成扫描,结果就会立即发送到浏览器
               * 这可以加快开发过程中的反馈速度,让开发者更快地看到变化
               *
               * 2. 可能的依赖项遗漏:
               * 扫描器可能会遗漏某些依赖项,特别是那些在初始扫描过程中未被检测到的依赖项
               * 这些遗漏的依赖项可能会在静态导入爬取过程中被发现
               *
               * 3. 新依赖项的发现与处理:
               * 如果在静态导入爬取过程中发现了新的依赖项,而这些依赖项在初始扫描过程中未被检测到,则需要处理这些新依赖项
               * 如果这些新发现的依赖项与旧的优化依赖项之间生成了新的公共模块(common chunks),
               * 则浏览器会进行一次完整的页面重载(full-page reload),以确保新的优化依赖项能够正确加载
               *
               * @example
               * 假设我们有一个项目,初始扫描过程中未检测到依赖 foo,但在静态导入爬取过程中发现了 foo
               * 如果 foo 被发现生成了新的公共模块,浏览器会进行完整的页面重载,以确保新依赖项 foo 正确加载并优化
               */
              optimizationResult.result.then((result) => {
                /**
                 * 在处理结果时,会检查静态导入的爬取是否已经完成。
                 * 如果已经完成,结果将由 onCrawlEnd 回调处理
                 * 如果尚未完成,将 optimizationResult 设置为 undefined,表示我们将使用当前结果,
                 * 然后调用 runOptimizer 函数进行优化
                 */
                if (!waitingForCrawlEnd) return;

                optimizationResult = undefined; // signal that we'll be using the result

                runOptimizer(result);
              });
            }
          } catch (e) {
            // 记录错误信息
            logger.error(e.stack || e.message);
          } finally {
            // 用于标记当前异步操作已经完成并且成功
            resolve();

            // 设置为 undefined 表示当前没有正在进行的依赖项扫描过程
            // 这是为了清理状态,防止下次扫描时误认为还有未完成的扫描任务
            depsOptimizer.scanProcessing = undefined;
          }
        })();
      });
    }
  }

    //.......
   /* 这里主要定义一些辅助函数 */
}

这一块逻辑很重要,在冷启动的过程中,会去完成onCrawlEnd 这个函数,这里的逻辑要最后去讲解,先给大家提一下,后续会再次讲解 这段代码的目的是为了优化依赖的首次加载和后续的依赖发现过程:

  • 冷启动

    1. 在冷启动时(没有缓存的元数据),会等待首次请求中发现的静态导入(static imports)
    2. 此时,代码会监听 onCrawlEnd 事件,即等待爬取静态导入完成后再继续。
    3. 这么做的目的是为了确保在首次加载时尽可能减少页面重新加载的次数,从而提高用户体验
  • 热启动

    1. 在热启动或首次优化之后,每次发现新的依赖项时,采用一个更简单的防抖(debounce)策略进行处理
    2. 这意味着不需要等待爬取结束,可以更快地处理新发现的依赖项,从而提高整体的响应速度

没有缓存的元数据(node_modules/.vite/deps 没有数据),会把 onCrawlEnd 这个方法添加到server里面,这个方法会在爬取结束后去执行(esbuild 扫描完源代码的依赖项,后面会讲)

  let waitingForCrawlEnd = false;
  if (!cachedMetadata) {
    // 检查是否有缓存的元数据,如果没有缓存的元数据,则意味着这是一个冷启动

    // 如果是冷启动,代码会注册一个回调函数 onCrawlEnd,该回调函数将在爬取静态导入完成时被调用。
    server._onCrawlEnd(onCrawlEnd);

    // 设置为true 表示当前正在等待依赖爬取结束
    waitingForCrawlEnd = true;
  }

image.png

我们重点来看下面这块逻辑,这里的逻辑涉及到去运行依赖优化器,开发环境下使用 esbuild 去扫描源代码 image.png

这块逻辑根据 noDiscovery 的值来做不同的处理

  • noDiscovery 为 true,表示不需要进行依赖扫描,可以立即进行第一次优化处理
  • noDiscovery 为 false,则需要进行依赖扫描,扫描完成后在进行优化处理

依赖扫描

我们来看为false 的情况,因为包含了依赖扫描优化处理

depsOptimizer.scanProcessing = new Promise((resolve) => {
        // Runs in the background in case blocking high priority tasks
        ;(async () => {
          try {
            debug?.(colors.green(`scanning for dependencies...`))

            discover = discoverProjectDependencies(config)
            const deps = await discover.result
            discover = undefined

            const manuallyIncluded = Object.keys(manuallyIncludedDepsInfo)
            discoveredDepsWhileScanning.push(
              ...Object.keys(metadata.discovered).filter(
                (dep) => !deps[dep] && !manuallyIncluded.includes(dep),
              ),
            )

            // Add these dependencies to the discovered list, as these are currently
            // used by the preAliasPlugin to support aliased and optimized deps.
            // This is also used by the CJS externalization heuristics in legacy mode
            for (const id of Object.keys(deps)) {
              if (!metadata.discovered[id]) {
                addMissingDep(id, deps[id])
              }
            }

            const knownDeps = prepareKnownDeps()
            startNextDiscoveredBatch()

            // For dev, we run the scanner and the first optimization
            // run on the background
            optimizationResult = runOptimizeDeps(config, knownDeps, ssr)

            // If the holdUntilCrawlEnd stratey is used, we wait until crawling has
            // ended to decide if we send this result to the browser or we need to
            // do another optimize step
            if (!holdUntilCrawlEnd) {
              // If not, we release the result to the browser as soon as the scanner
              // is done. If the scanner missed any dependency, and a new dependency
              // is discovered while crawling static imports, then there will be a
              // full-page reload if new common chunks are generated between the old
              // and new optimized deps.
              optimizationResult.result.then((result) => {
                // Check if the crawling of static imports has already finished. In that
                // case, the result is handled by the onCrawlEnd callback
                if (!waitingForCrawlEnd) return

                optimizationResult = undefined // signal that we'll be using the result

                runOptimizer(result)
              })
            }
          } catch (e) {
            logger.error(e.stack || e.message)
          } finally {
            resolve()
            depsOptimizer.scanProcessing = undefined
          }
        })()
      })

这里将 promise 的状态给到了 scanProcessing,因为后续有些操作需要等待这个promise 完成后在执行。这里在后台运行,以防止阻塞高优先级任务。这里可以看到,在执行扫描的过程中是不会影响后续的流程,会打开浏览器,等待扫描完成后继续进行下一步处理 image.png

// 异步函数扫描项目依赖项,并将结果存储在 deps 中
discover = discoverProjectDependencies(config)
const deps = await discover.result
discover = undefined

这里使用一个异步函数扫描项目依赖项,等待扫描完成将结果存储在deps中。discoverProjectDependencies 这个函数里面去执行真正的扫描。

这个函数的目的是执行初始的依赖扫描,以发现需要预构建的依赖项,并包括用户手动指定的依赖项,它使用 esbuild 来进行快速扫描,目的是尽早找到需要打包的依赖项 image.png

我们可以看到这个函数内部去执行了 scanImports ,这个函数才是真正的去执行了 esbuild 来构建项目 image.png

这里执行 computeEntries 计算入口点,并返回一个 esbuild 扫描器,我们来看入口点怎么确定的。

image.png

这里我在 config 文件并没有配置优化依赖的入口点(config.optimizeDeps.entries) ,也没有配置打包的入口点,所以最终的兜底处理就是使用 globEntries 函数查找所有 HTML 文件 **/*.html 作为入口文件,使用 isScannable 函数过滤掉不支持扫描的入口文件类型, 使用 fs.existsSync 函数过滤掉不存在的文件路径,最终得到入口文件

计算完入口文件后,还需要准备一个 esbuild 扫描器,就是通过 prepareEsbuildScanner 来完成的

return prepareEsbuildScanner(config, entries, deps, missing, scanContext)

image.png

这里会创建一个插件容器 container,用于管理插件和配置,如果扫描被取消了则直接返回,防止造成不必要的性能开销;没有取消则准备一些扫描过程中需要使用的 esbuild 插件,然后从用户配置中获取依赖优化的 esbuild配置插件和 其他配置选项,最终会和默认的合并在一起(合并默认插件与用户提供的esbuild 插件和配置)。后面的逻辑是处理tsconfig 文件,源码中这里也有相应的注释

源码中的注释解释了为什么在 prepareEsbuildScanner 函数中需要手动加载最接近根目录的 tsconfig.json 文件而不依赖 esbuild 自动加载的功能

  1. esbuild 默认行为

    • esbuild 会自动加载最近的 tsconfig.json 文件来配置 TypeScript 的编译选项,这意味着如果项目中存在多个 tsconfig.json 文件,esbuild 会使用离入口点最近的那个文件
  2. 问题与限制

    • 当插件解析了路径后,esbuild 无法正确读取 tsconfig.json。这是因为 esbuild 不会考虑插件解析后的路径
    • TypeScript 中的实验性装饰器语法与 TC39 的装饰器存在语法不兼容的问题
  • 解决方案
    • 为了解决路径解析问题和实验性装饰器的语法兼容性,Vite 在大多数情况下使用距离项目根目录最近的 tsconfig.json
    • 这样可以确保 esbuild 在大多数情况下都能够正确地配置 TypeScript 编译选项,并且能够处理实验性装饰器的语法问题

我们来看返回的 esbuild 扫描器上下文。注意:还记得vite 预构建的目的吗?

预构建目的一:CJS、UMD 兼容性

将 CJS 和 UMD 模块化 转换为 ESM 模块化

这里 esbuild 中的 format: "esm", 就是将这些文件打包成 esm 模块。

生产构建中vite 将@rollup/plugin-commonjs

return await esbuild.context({
    // 设置为当前工作目录
    absWorkingDir: process.cwd(),
    // 设置为 false,表示不写入输出文件
    write: false,
    // 包含了以 entries 为基础的导入语句
    stdin: {
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join("\n"),
      loader: "js",
    },
    bundle: true,
    format: "esm",
    logLevel: "silent", // 设置为 "silent",表示日志级别为静默,不输出日志
    plugins: [...plugins, plugin], //包括了之前创建的 plugin 和从用户配置中获取的其他插件
    ...esbuildOptions, // 合并用户的esbuild 配置
    tsconfigRaw,
  });

esbuild.contextesbuild v0.18.0 引入的新API,用于创建一个构建上下文,这个上下文可以进行多次增量构建而无需重新解析配置。 注意,这里并不会立即开始打包,而是创建了一个构建环境,你可以在这个环境中进行构建操作

让我们回来 扫描的主流程上面,prepareEsbuildScanner 返回了一个 esbuild 的上下文环境(esbuildContext),then 里面可以拿到上下文,手动调用了rebuild 触发构建 ,构建完成后最终会释放掉这个上下文环境 image.png

我们可以发现这里在构建完成后 返回了 依赖(经过排序)和缺失的依赖,那这个 depsmissing 是哪里被处理的呢,我们可以看到 prepareEsbuildScanner 里面使用到了 deps 和 missing

async function prepareEsbuildScanner(
  config: ResolvedConfig,
  entries: string[],
  deps: Record<string, string>,
  missing: Record<string, string>,
  scanContext?: { cancelled: boolean },
): Promise<BuildContext | undefined> {

  const container = await createPluginContainer(config)

  if (scanContext?.cancelled) return

  const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
  //........
  
}

我们可以看到 在准备 esbuild 插件的时候 将 deps 和 missing 传递进去了

esbuildScanPlugin 这个函数内容很多,里面处理在扫描过程遇到的很多情况,deps 和 missing 就是在这块地方处理的,具体的详情可以查看项目地址

image.png

scanImports 会将扫描完成的结果返回 image.png

所以这里我们就可以拿到扫描完成后的依赖 image.png

 // 获取手动包含的依赖项列表
  const manuallyIncluded = Object.keys(manuallyIncludedDepsInfo);

  // 过滤出扫描过程中发现的新依赖项,并将其添加到 discoveredDepsWhileScanning 列表中
  discoveredDepsWhileScanning.push(
    ...Object.keys(metadata.discovered).filter(
      // 过滤出发现的新依赖项
      (dep) => !deps[dep] && !manuallyIncluded.includes(dep)
    )
  );

  // Add these dependencies to the discovered list, as these are currently
  // used by the preAliasPlugin to support aliased and optimized deps.
  // This is also used by the CJS externalization heuristics in legacy mode

   // 将扫描过程中发现的依赖项添加到元数据的 discovered 部分
  for (const id of Object.keys(deps)) {
    // 检查这些依赖项是否已经在 metadata.discovered 中(即是否已经被记录为已发现的依赖项)
    if (!metadata.discovered[id]) {
      // 对于尚未被记录为已发现的依赖项,将其添加到已发现的依赖项列表中
      addMissingDep(id, deps[id]);
    }
  }

源码中这一段注释解释了为什么要将这些依赖项添加到已发现的依赖项列表中。

  1. 支持别名和优化依赖项:

    • 这些依赖项目前由 preAliasPlugin 使用,preAliasPlugin 是 Vite 中的一个插件,用于处理依赖项的别名和优化
    • 将这些依赖项添加到已发现的依赖项列表中,可以确保它们在构建过程中被正确处理和优化。
  2. 旧模式中的 CJS 外部化启发式:

    • 这些依赖项还用于旧模式中的 CJS(CommonJS)外部化启发式方法
    • 在旧模式下,Vite 可能需要根据某些启发式方法决定哪些依赖项需要外部化处理(即不打包进最终构建产物中,而是保持为外部依赖)

依赖优化

主要来看下面的一块逻辑,这两段逻辑我们拆开来看

optimizationResult = runOptimizeDeps(config, knownDeps, ssr);

 if (!holdUntilCrawlEnd) {
  optimizationResult.result.then((result) => {
    if (!waitingForCrawlEnd) return
    optimizationResult = undefined // signal that we'll be using the result
    runOptimizer(result)
  })
}
预构建目的二:多个内部模块转为单个模块

runOptimizeDeps 是 Vite 中用于执行依赖优化的核心函数之一,它主要负责预构建依赖项,将它们打包成单个模块,以减少 HTTP 请求的数量并提高页面加载性能 。这里面实际还是使用 esbuild 来做优化,我们来看主要的部分

export function runOptimizeDeps(
  resolvedConfig: ResolvedConfig,
  depsInfo: Record<string, OptimizedDepInfo>,
  ssr: boolean,
): {
  cancel: () => Promise<void>
  result: Promise<DepOptimizationResult>
} {
  // 初始化优化器上下文和配置
  const optimizerContext = { cancelled: false };

  // 将其命令设置为 "build"
  const config: ResolvedConfig = {
    ...resolvedConfig,
    command: "build",
  };

  /**依赖缓存目录 (config.cacheDir, "deps")默认是 node_modules/.vite */
  const depsCacheDir = getDepsCacheDir(resolvedConfig, ssr);
  /**处理缓存目录,这是一个临时目录 */
  const processingCacheDir = getProcessingDepsCacheDir(resolvedConfig, ssr);

  //  确保所有嵌套的子目录也会被创建
  fs.mkdirSync(processingCacheDir, { recursive: true });
  
  // 在 processingCacheDir 目录中创建并写入一个 package.json 文件
  // 这个 package.json 文件的内容是 {"type": "module"},这会提示 Node.js 将目录中的所有文件识别为 ES 模块
  fs.writeFileSync(
    path.resolve(processingCacheDir, "package.json"),
    `{\n  "type": "module"\n}\n`
  );

  // 初始化依赖优化器的元数据
  const metadata = initDepsOptimizerMetadata(config, ssr);

  // 算浏览器哈希值:用于标识优化后的依赖在浏览器中的唯一性
  metadata.browserHash = getOptimizedBrowserHash(
    metadata.hash,
    depsFromOptimizedDepInfo(depsInfo)
  );

  /**存储 depsInfo 对象的所有键(即依赖项的 ID) */
  const qualifiedIds = Object.keys(depsInfo);
  // 用于跟踪是否已经执行了清理或提交操作
  let cleaned = false;
  let committed = false;

  // cleanUp 清理函数
  // 省略部分逻辑 successfulResult 比较重要,稍后再说

  
  // 用于准备 esbuild 优化器的运行
  const preparedRun = prepareEsbuildOptimizerRun(
    resolvedConfig, // 已解析的配置
    depsInfo, // 依赖项的信息
    ssr,
    processingCacheDir, // 处理缓存目录
    optimizerContext // 优化器上下文
  );

  // 使用 esbuild 进行打包处理,里面的逻辑我们单独拿出来看
  const runResult = preparedRun.then(({ context, idToExports }) => {
    // 处理逻辑
  }
                                     
  return {
    //cancel 取消函数
    // 处理结果
    result: runResult,
  }
}

这两步会创建一个临时目录,并且在这个临时目录中写入 package.json,其中 type:module。然后初始化当前解析后配置(config)的元数据(metadata)

image.png

接下来我们看来 successfulResult 这个对象,这个对象里面有metadata一个清理函数一个提交函数(这个比较重要),我们来看 commit 这个函数,commit 主要就是将上面生成的临时目录重命名,safeRename 通过这个函数来完成重命名,将临时目录 deps_temp_84082c61 重命名为 deps。在这个commit 执行之前,优化后的依赖会写入到这个临时目录中

const successfulResult: DepOptimizationResult = {
    metadata,
    cancel: cleanUp,
    commit: async () => {
      if (cleaned) {
        throw new Error(
          "Can not commit a Deps Optimization run as it was cancelled"
        );
      }
      // 在这个步骤之后,不再处理清理请求,以确保在完成新依赖缓存文件提交之前,临时文件夹不会被删除
      // 一旦 committed 被设置为 true,cleanUp 函数的逻辑将不再执行删除操作
      // 这是为了避免在提交过程中临时文件夹被意外删除,从而导致依赖缓存目录不一致或损坏
      committed = true;

      // 下面代码的主要作用就是:写入元数据文件,将处理文件夹提交到全局依赖缓存,
      // 将临时处理目录的文件路径重定向到最终依赖缓存目录

      // 获取元数据路径
      const dataPath = path.join(processingCacheDir, METADATA_FILENAME);
      debug?.(
        colors.green(`creating ${METADATA_FILENAME} in ${processingCacheDir}`)
      );

      // 写入元数据文件:确保处理目录中包含最新的依赖优化元数据
      fs.writeFileSync(
        dataPath,
        stringifyDepsOptimizerMetadata(metadata, depsCacheDir)
      );

      /**
       * 源码中这段注释解释了在重命名和删除依赖缓存目录时采取的步骤和原因:
       *
       * 1. 通过在重命名过程中采取一些措施,尽量减少依赖缓存目录不一致的时间
       *
       * 2. 先将旧的依赖缓存目录重命名到一个临时路径,再将新的处理缓存目录重命名为最终的依赖缓存目录,
       * 可以确保在整个过程中,依赖缓存目录始终处于一致状态
       *
       * 3. 在那些可以安全同步完成重命名操作的系统中,执行原子操作(至少对于当前线程而言),以确保操作的一致性
       *
       * 4. 在 Windows 系统中,重命名操作有时会提前结束,但实际上并未完成
       * 因此,需要进行优雅的重命名操作,确保文件夹已经正确重命名
       *
       * 5. 通过先重命名旧文件夹再重命名新文件夹(然后在后台删除旧文件夹)的方式,
       * 比直接删除旧文件夹再重命名新文件夹更加安全
       *
       */

      /**旧的依赖缓存目录加上一个临时后缀,用于生成临时路径 */
      const temporaryPath = depsCacheDir + getTempSuffix();

      /**检查 depsCacheDir 目录是否存在 */
      const depsCacheDirPresent = fs.existsSync(depsCacheDir);
      if (isWindows) {
        // Windows 系统的重命名操作
        if (depsCacheDirPresent) {
          // 缓存目录存在
          debug?.(colors.green(`renaming ${depsCacheDir} to ${temporaryPath}`));

          // 将 depsCacheDir 重命名为 temporaryPath safeRename 是异步函数
          await safeRename(depsCacheDir, temporaryPath);
        }
        debug?.(
          colors.green(`renaming ${processingCacheDir} to ${depsCacheDir}`)
        );

        // 将 processingCacheDir 重命名为 depsCacheDir
        await safeRename(processingCacheDir, depsCacheDir);
      } else {
        // 非 Windows 系统的重命名操作
        if (depsCacheDirPresent) {
          debug?.(colors.green(`renaming ${depsCacheDir} to ${temporaryPath}`));
          // 将 depsCacheDir 重命名为 temporaryPath,renameSync 是同步函数
          fs.renameSync(depsCacheDir, temporaryPath);
        }
        debug?.(
          colors.green(`renaming ${processingCacheDir} to ${depsCacheDir}`)
        );
        // 将 processingCacheDir 重命名为 depsCacheDir
        fs.renameSync(processingCacheDir, depsCacheDir);
      }

      // Delete temporary path in the background
      if (depsCacheDirPresent) {
        debug?.(colors.green(`removing cache temp dir ${temporaryPath}`));
        // 台删除临时目录 temporaryPath
        fsp.rm(temporaryPath, { recursive: true, force: true });
      }
    },
  };

我们再来看这个函数 prepareEsbuildOptimizerRun, 这个函数是一个用于准备 esbuild 优化器运行的异步函数,会返回一个esbuild 打包上下文环境,和之前扫描依赖类似。

image.png

prepareEsbuildOptimizerRun 这个函数里面有两块地方我们需要注意:

  • Promise.all 中处理的逻辑
  • esbuild 上下文环境的配置

image.png

这里 depsInfo 就是 esbuild 在扫描依赖结束后生成的,这里项目的根目录index.html 中引入了 src/main.ts,main.ts 中引入了 vue 、vue-router、lodash-es(用于测试多个内部模块打包成一个文件,减少请求数量)

这段代码的主要目的是遍历所有依赖项,提取每个依赖项的导出数据,并根据需要设置 esbuild 的加载器选项。最终生成扁平化 ID 到源文件路径和依赖 ID 到导出数据的映射。这样做是为了确保在优化过程中,所有依赖项都能被正确处理和解析。

我们来看返回的esbuild 上下文 image.png

这里是将扫描到的所有依赖,都作为入口点去打包, esbuild 会递归解析每个入口点的所有依赖,并将它们打包成一个或多个输出文件。

这里主要的就是 bundle:true

  • bundle: true esbuild 会从指定的入口点开始,递归解析所有导入的模块和文件,并将它们合并到一个或多个输出文件中。这减少了浏览器需要加载的文件数量,从而减少了 HTTP 请求,提升了加载速度。

  • splitting: true 启用代码分割功能,允许 esbuild 将代码分割成多个块(chunks),从而生成多个输出文件。这对于大规模应用特别有用,可以提升加载性能和优化缓存。

esbuild 在进行代码分割时,会分析模块的依赖图并根据以下逻辑决定哪些模块会被分割:

  1. 入口点模块:每个入口点会生成一个主文件。
  2. 共享模块:如果一个模块被多个入口点依赖,esbuild 会将其提取为一个共享的 chunk。
  3. 动态导入:任何通过 import() 语法动态导入的模块都会被单独分割成一个 chunk。

注意:bundle: true 是一个明确的选项,用于指示 esbuild 将所有入口点及其依赖项打包在一起。默认情况下,esbuild 不会进行打包,除非显式指定 bundle: true。即使指定了 splitting: true,如果没有启用 bundle,代码分割也不会生效。

bundle: true 的行为:

  1. 不递归解析依赖项:esbuild 只处理指定的入口点文件,不会解析和合并它们的依赖项。也就是说,每个入口点文件只会被单独编译,输出文件只包含入口点本身的内容,而不包含它的依赖项。
  2. 不进行代码分割:即使指定了 splitting: true,如果没有启用 bundle: true,代码分割功能也不会生效。代码分割是打包的一部分功能,只有在打包时才能进行代码分割。
  3. 生成的文件:生成的文件是独立的,不包含其他模块的代码。这意味着需要手动管理模块的加载和依赖关系。
 const context = await esbuild.context({
    // 设置当前工作目录为进程的当前工作目录
    absWorkingDir: process.cwd(),
    //  指定构建入口点,这里是flatIdDeps的键数组
    entryPoints: Object.keys(flatIdDeps),
    bundle: true, //  启用打包
    /**
     * neutral:
     * 在esbuild中,平台设置为neutral表示构建的代码既不专门为浏览器也不专门为Node.js环境设计
     * 这种设置在某些场景下可能很有用,但在这里并不适用
     *
     * node:
     * 针对Node.js环境的构建,esbuild会有一些特定的处理,如支持require等Node.js特有的语法
     *
     * browser:
     * 针对浏览器环境的构建,esbuild会处理浏览器特有的语法和特性
     *
     * 由于esbuild对node和browser平台有特定的处理方式(如根据mainFields和条件处理模块入口),
     * 这些处理方式在'neutral'平台中无法模拟
     */
    platform, //设置构建平台,值为"node"或"browser"
    define, //定义全局变量,将process.env.NODE_ENV替换为构建模式
    format: "esm", //输出格式为"esm"
    // See https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694

    //  如果平台是node,则添加一个banner,用于支持import语法
    banner:
      platform === "node"
        ? {
            js: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`,
          }
        : undefined,
    target: ESBUILD_MODULES_TARGET, // 设置构建目标环境
    external, //指定排除的依赖
    logLevel: "error", //设置日志级别为"error"
    splitting: true, // 启用代码拆分
    sourcemap: true, //启用源映射
    outdir: processingCacheDir, //设置输出目录为processingCacheDir
    ignoreAnnotations: true, //忽略注解
    metafile: true, //启用元文件生成
    plugins, //使用前面定义的插件数组
    charset: "utf8", //设置字符集为"utf8"
    ...esbuildOptions, // 合并用户自定义的esbuild选项

    //  合并默认支持的esbuild功能和用户自定义的支持功能
    supported: {
      ...defaultEsbuildSupported,
      ...esbuildOptions.supported,
    },
  });

这里的输出目录,就是之前生成的临时缓存目录

image.png

image.png

esbuild 打包执行完成之后,这些依赖就被输出到缓存目录(这里是临时目录,commit 之后会变成永久目录)了

image.png

我们可以看到lodash-es 里面的文件包含了很多代码 image.png

我们再来看看 不设置bundle打包后的结果,我们可以发现并没有将代码打包进去,引入lodash的地方,到时候会把这些js文件全部请求一次,这样就造成了大量的网络请求。不过这里打包后的文件有点问题,引入路径不正确,因为这里的bunlde 不设置后,esbuild 的默认行为只打包源代码,依赖关系需要在运行时解决。

image.png

我们来看vite 的更新日志,可以发现在vite2.0版本引入了esbuild 来加快预构建速度,这里在vite1.0的时候应该是通过rollup 来完成预构建的,当时应该是通过什么插件将lodash-es 多个内部模块打包成一个文件,esbuild 只需要简单的配置就能完成

image.png

这里后面执行的逻辑,就是将优化后的依赖(optimized)、chunks等关联到 metadata里面,最终会生成一个 _metadata.json文件 image.png

image.png

这样打包完成后会 返回 successfulResult,runOptimizeDeps 这个函数会返回一个取消函数和一个result

image.png

我们会来到这个函数的执行,还记得前面的内容吗,会把这个函数(onCrawlEnd)存起来,等待合适的机会执行(这期间涉及的东西很多,有感兴趣的话我单独出一章) image.png

最终会来到这个函数的执行 runOptimizer,这个函数最终会去执行 commit ,还记得commit吗 image.png

image.png

这里会去执行  processingResult.commit() image.png

我们来看看 processingResult,它是 runOptimizer 传递过来的值,也就是优化完成的结果

image.png

我们再来看 onCrawlEnd,可以看到优化完的结果就是从这里传递进去的

image.png

还记得 successfulResult 这个对象吗,里面有commit,打包完成的时候也是将这个对象给返回了,这里面主要就是将临时目录重命名

image.png

image.png

预构建到这里,要做的主要工作基本上已经处理完,还记得上一章我们提到的导入分析插件

import { createApp } from 'vue'

//依赖会被转换
//这样的 导入路径浏览器能够识别,也能真正的发请求获取到
import { createApp } from '/node_modules/.vite/deps/vue?v=123456'

这里做了分包,会去加载这个文件

image.png

在执行导入分析插件之前,我们已经准备好了依赖文件,下一章我们就来讲讲 vue ----> /node_modules/.vite/deps/vue?v=123456 是如何完成

3. vite 官网预构建其他功能讲解

自动搜寻依赖

  1. 如果没有找到现有的缓存,Vite 会扫描您的源代码,并自动寻找引入的依赖项(即 "bare import",表示期望从 node_modules 中解析),并将这些依赖项作为预构建的入口点。预打包使用 esbuild 执行,因此通常速度非常快。

  2. 在服务器已经启动后,如果遇到尚未在缓存中的新依赖项导入,则 Vite 将重新运行依赖项构建过程,并在需要时重新加载页面。

  1. 首先会判断有没有缓存,没有的话会扫描源代码,还记得我们上面讲的依赖扫描吗,让我们再来看看

没有依赖,且根据配置,我们演示的是走下面的逻辑,通过 discoverProjectDependencies 来完成对源代码的扫描

image.png

还记得吗,discoverProjectDependencies 里面实际去调用了 scanImports, 这里面最终会使用** esbuild 来对源码进行扫描**

image.png

会计算入口点,我们演示的过程中没有给,所以会扫描所有的html文件,并从其中分析依赖

image.png

  1. 让我看再来看服务器已经启动后,有新的依赖项改如何处理

我们启动完服务器之后,安装一个axios,可以看到监听到 package.json 发生了变化

image.png

还记得吗,第一章创建开发服务器的时候,chokidar 会监听文件的变化,这里可以看到pacakge.json 发生变化,这一段的处理可以不看,因为没有去影响到重新构建依赖

我们在源代码里面去使用 axios,当保存成功后,会监听到,chokidar 并触发相应的回调

image.png

image.png

来到插件容器中的 watchChange 方法,里面去执行了一些钩子函数

image.png

我们可以看到会在所有的插件里面寻找有 watchChage函数的插件,然后去执行这个watchChange 这个函数

image.png

这里匹配不上,不是package 发生变化,所以不会处理

image.png

我们再来看处理热更新的逻辑,热更新会专门出一章讲解

image.png

onHMRUpdate 里面 主要就是去执行了 handleHMRUpdate 这个函数,里面才是真正处理更新的逻辑

image.png

我们直接来看 update 的逻辑

image.png

我们可以看到有有两个插件里面有 handleHotUpdate 这个钩子的处理

  • vite:watch-package-data,这个插件不用看,处理package.json的,
  • vite:vue 这个插件是vite 官方提供的(@vitejs/plugin-vue),专门用来处理vue文件的

image.png

调用完插件相应的钩子函数后,会去执行更新模块信息 updateModules

image.png

执行完成之后,updat 函数里面最后会通知客户端更新,请求相应的文件 image.png

然后会走到文件转换中间件,进行转换

image.png

最终会来到这里,执行 runOptimizer 这个方法去优化依赖

image.png

此时的 runOptimizer 没有传递优化的结果,所以走到下面的逻辑,执行 runOptimizeDeps

image.png

经过依赖优化的处理后生成依赖的临时目录,最终重命名这个临时目录,并删掉之前的目录

image.png

image.png

热更新章节中会再次重点讲解整个流程

Monorepo 和链接依赖

在一个 monorepo 启动中,该仓库中的某个包可能会成为另一个包的依赖。Vite 会自动侦测没有从 node_modules 解析的依赖项,并将链接的依赖视为源码。它不会尝试打包被链接的依赖,而是会分析被链接依赖的依赖列表。

然而,这需要被链接的依赖被导出为 ESM 格式。如果不是,那么你可以在配置里将此依赖添加到 optimizeDeps.includebuild.commonjsOptions.include 这两项中

这里要注意,vite 不会打包,且直接使用链接的依赖。这里是通过一个插件(importAnalysis)来完成的,在第二章有提到,导入分析插件,会对导入的依赖进行处理,这里会将裸导入进行重写。比如,import 'vue' 会替换为预构建中优化完的依赖 import '/node_modules/.vite/deps/vue?v=123456'

这里需要注意,如果导入的依赖不在项目的根目录,比如monorepo 或者 链接依赖,vite 预构建不会打包,会直接链接到改依赖,所以这个依赖需要提供esm 模块化

我们这里在 packges 目录下新建一个 test-pkg,里面导出一个 sum求和函数,然后再vue-demo 里面去安装这个包

pnpm add test-pkg@workspace

image.png

安装完成之后,我们在App.vue 文件中去使用,在请求 App.vue 这个文件的时候,获取到App.vue文件后,发现里面引入了 axiostest-pkg, 然后会再次请求这两个文件

image.png

test-pkg 会经过一些处理(导入分析)会加上 /@fs/ 这样的前缀 然后拼接上文件的真实路径

image.png

image.png

文件转换中间件,会处理这次的请求结果,将转换完成后的结果(result.code)返回给浏览器。更多的解析在导入分析插件那一章讲解

自定义行为

有时候默认的依赖启发式算法(discovery heuristics)可能并不总是理想的。如果您想要明确地包含或排除依赖项,可以使用 optimizeDeps配置项 来进行设置。

optimizeDeps.includeoptimizeDeps.exclude 的一个典型使用场景,是当 Vite 在源码中无法直接发现 import 的时候。例如,import 可能是插件转换的结果。这意味着 Vite 无法在初始扫描时发现 import —— 只能在文件被浏览器请求并转换后才能发现。这将导致服务器在启动后立即重新打包。

includeexclude 都可以用来处理这个问题。如果依赖项很大(包含很多内部模块)或者是 CommonJS,那么你应该包含它;如果依赖项很小,并且已经是有效的 ESM,则可以排除它,让浏览器直接加载它。

你可以通过 optimizeDeps.esbuildOptions选项 进一步自定义 esbuild。例如,添加一个 esbuild 插件来处理依赖项中的特殊文件,或者更改 buildtarget

接下来我们来看看 includeexclude 还有 esbuildOptions 在哪里被处理的

  1. include 在创建依赖优化器的时候,addManuallyIncludedOptimizeDeps 这个函数就是将手动包含在 optimizeDeps.include 中的依赖添加到依赖项记录中

image.png

这里的循环处理每一个 include 里面的值,将符合条件的添加到 deps信息里面

image.png

这里将 include 里面的信息 添加到 metadata 里面之后,进行后续的处理

image.png

到时候执行这一步的时候, depsInfo 里面就会有用户手动添加的 image.png

  1. exclude & esbuildOptions 依赖优化这个步骤实际上也是通过 esbuild 来完成,exclude 就是在 准备 esbuild 上下文环境中处理的

image.png

在这里我们就可以看到通过vite.config 文件中传入的 optimizeDeps 中的 excludeesbuildOptions,就是在这里处理的,最终会传递给 esbuild

image.png

缓存

Vite 将预构建的依赖项缓存到 node_modules/.vite 中。它会基于以下几个来源来决定是否需要重新运行预构建步骤:

  • 包管理器的锁文件内容,例如 package-lock.jsonyarn.lockpnpm-lock.yaml,或者 bun.lockb
  • 补丁文件夹的修改时间;
  • vite.config.js 中的相关字段;
  • NODE_ENV 的值。

只有在上述其中一项发生更改时,才需要重新运行预构建。

如果出于某些原因你想要强制 Vite 重新构建依赖项,你可以在启动开发服务器时指定 --force 选项,或手动删除 node_modules/.vite 缓存目录。

文件系统缓存

我们来看创建依赖优化器(createDepsOptimizer)的前面几行的代码,loadCachedDepOptimizationMetadata 通过这个函数来去加载预构建生成的依赖项,如果没有找到,就进行扫描源代码、依赖优化,这是我们上面提到的流程。我们来看看这个方法

image.png

这个函数创建初始的依赖优化元数据,从依赖缓存中加载(如果存在的话),并且没有强制预打包的情况(启动vite 的时候传递 --force 启动强制预构建),否则会根据需要重新优化依赖

export async function loadCachedDepOptimizationMetadata(
  config: ResolvedConfig,
  ssr: boolean,
  force = config.optimizeDeps.force,
  asCommand = false
): Promise<DepOptimizationMetadata | undefined> {
  const log = asCommand ? config.logger.info : debug;

  // 首次加载处理
  if (firstLoadCachedDepOptimizationMetadata) {
    firstLoadCachedDepOptimizationMetadata = false;
    // 如果是第一次加载缓存元数据,
    // 会启动一个定时器来清理陈旧的依赖缓存目录,防止之前的进程异常退出导致的遗留问题。
    setTimeout(() => cleanupDepsCacheStaleDirs(config), 0);
  }

  // 获取依赖缓存目录
  const depsCacheDir = getDepsCacheDir(config, ssr);

  // 如果没有强制重新打包
  if (!force) {
    // 如果没有强制重新优化,尝试从缓存文件中读取并解析元数据
    let cachedMetadata: DepOptimizationMetadata | undefined;
    try {
      const cachedMetadataPath = path.join(depsCacheDir, METADATA_FILENAME);
      // 从缓存文件中解析元数据
      cachedMetadata = parseDepsOptimizerMetadata(
        await fsp.readFile(cachedMetadataPath, "utf-8"),
        depsCacheDir
      );
      // 忽略错误
    } catch (e) {}
    // hash is consistent, no need to re-bundle
    // 哈希值(锁文件哈希和配置哈希)一致时,不需要重新打包优化依赖
    /**
     * 在依赖优化过程中,Vite 使用哈希值来确保缓存的有效性和一致性。
     *
     * 如果缓存的依赖元数据中的哈希值与当前项目的哈希值匹配,就意味着依赖项和配置没有发生变化,
     * 此时可以使用缓存的优化结果,而不需要重新执行优化过程。
     */

    // 如果缓存的元数据存在
    if (cachedMetadata) {
      // 检查其锁文件哈希和配置哈希是否与当前的一致
      if (cachedMetadata.lockfileHash !== getLockfileHash(config, ssr)) {
        config.logger.info(
          "Re-optimizing dependencies because lockfile has changed"
        );

        // 检查缓存的配置哈希是否与当前的配置哈希一致
      } else if (cachedMetadata.configHash !== getConfigHash(config, ssr)) {
        config.logger.info(
          "Re-optimizing dependencies because vite config has changed"
        );
      } else {
        // 如果哈希一致,跳过重新优化,并提示可以使用 --force 参数来覆盖
        log?.("Hash is consistent. Skipping. Use --force to override.");
        // Nothing to commit or cancel as we are using the cache, we only
        // need to resolve the processing promise so requests can move on

        // 直接返回缓存的元数据
        return cachedMetadata;
      }
    }
  } else {
    config.logger.info("Forced re-optimization of dependencies");
  }

  // 如果强制重新优化,删除旧的缓存目录
  debug?.(colors.green(`removing old cache dir ${depsCacheDir}`));
  await fsp.rm(depsCacheDir, { recursive: true, force: true });
}

我们来看这里,当文件的哈希发生变化后,会重新优化,如果没有发生变化,则跳过重新优化

image.png

image.png

浏览器缓存

已预构建的依赖请求使用 HTTP 头 max-age=31536000, immutable 进行强缓存,以提高开发期间页面重新加载的性能。一旦被缓存,这些请求将永远不会再次访问开发服务器。如果安装了不同版本的依赖项(这反映在包管理器的 lockfile 中),则会通过附加版本查询自动失效。如果你想通过本地编辑来调试依赖项,您可以:

  1. 通过浏览器开发工具的 Network 选项卡暂时禁用缓存;
  2. 重启 Vite 开发服务器指定 --force 选项,来重新构建依赖项;
  3. 重新载入页面。

我们来看vite 是在哪里对预构建的依赖进行强缓存的,下图可以看到在 文件转换中间件(transformMiddleware)这里面有对请求进行缓存处理。这里会对url 进行判断,看是否是预构建的依赖

  • isDep为true,是预构建的依赖,设置 cacheControl: 'max-age=31536000,immutable' ,强缓存
  • 则不是,设置 cacheControl: 'no-cache',不缓存

image.png

结语

本章主要是讲解了vite 在启动的时候做了一些初始化工作,如依赖预构建,为什么要做预构建以及怎么做的,在上面的文章中都有提到过。前几篇文章可以发现,多次提到了文件转换中间件(transformMiddleware)和导入分析插件(importAnalysis)。在介绍导入分析插件之前,下一章先给大家讲一下transformMiddleware 这个中间件,它在 vite 中扮演很重要的角色,下一章我们就可以知道为什么 vite 这么快

源码系列