超万字!深度剖析vite5 依赖预构建执行原理🚀🚀

1,703 阅读23分钟

在使用vite时,我们通常最直观的感受就是-“太快了!”。打包构建速度简直就是飞一般的感觉。

但是你有没有想过,为什么 vite 可以做到启动速度这么快呢?他对比传统的 webpack 打包工具来说,到底具备什么黑魔法才能让启动速度快这么多?

这就要提到本篇文章中要介绍的--‘预构建“。

什么是预构建?

首先需要明确的是,预构建是在 vite 的开发环境中存在一个优化的操作。

在本地服务器启动之前 vite 会扫描使用到的依赖对其进行构建,在代码中每次导入(import​)时也会动态地加载构建过的依赖。

与 webpack 在启动前会打包所有代码所不同的是,vite 在开发环境并不会对所有的代码和依赖进行打包,而是将代码直接交给浏览器处理。

现代浏览器原生支持了 ES 模块规范,因此原生的 ES 语法也可以直接放到浏览器中执行,只需要在 script​ 标签中声明 type="module"​ 。

浏览器每遇到一个import​都会向本地服务器发起一个请求,比如我们新建一个 react 项目,当在 App.tsx​中使用import​引入main.tsx​时:

image.png

此时在浏览器中会请求main.tsx​这个资源,然后读取对应的文件内容,将处理的结果返回给浏览器。

上面的例子只是说明浏览器在处理import​语句时的特性,我们开发人员编写的项目代码(比如main.tsx​中的代码),并不是“依赖”的范畴,这里的依赖通常是指第三方依赖包。

vite 会将应用中的模块区分为 依赖源码 两类:

  • 依赖 更多指的是代码中使用到的第三方模块,比如 vue​、react​ 等。
    vite 将会使用 esbuild 在应用启动时对于依赖部分进行预构建依赖。
  • 源码 指的是我们项目中的 jsx​、vue​ 等文件,这部分代码会在运行时被编译,并不会进行任何打包。

vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据实际动态导入代码,也就是说会在进行交互,实际使用时才会被处理。

所以这里的依赖预构建,其实是针对于第三方模块依赖的预构建过程。

为什么要对依赖进行预构建?

最重要的一点,vite 在开发环境是依靠浏览器Esmodule​规范进行导入的。但是对于第三方包来说,我们并不能控制第三方包的产物采用什么规范。依然有大量的第三方包采用非ESM​模块规范,比如react​存在有CommonJS​ 格式的打包产物等等。

在开发阶段,我们需要借助预构建的过程将这部分非 esm​ 模块的依赖模块转化为 esm​ 模块。从而在浏览器中进行 import​ 这部分模块时也可以正确识别该模块语法。

还有一点就是浏览器自动识别import​语法自动发送请求的方式也会带来一定的弊端,比如我们来引用一个非常常用的库lodash-es​:

lodash-es​库里面有海量的方法会使用import​进行导出,而且方法之间还会存在很多依赖关系,当我们在使用这类库时,如果不进行预构建浏览器会同时发出 600 多个 HTTP 请求,这无疑会让页面加载变得明显缓慢。

所以在 vite 中会依赖预构建将所有模块进行打包,最终将其构建成为单个模块后仅需要一个 HTTP 请求就可以解决。

image.png

处理的方式也很简单,在使用 vite​ 启动项目时,会发现项目 node_modules​ 目录下会额外增加一个 node_modules/.vite/deps​ 的目录:

image.png

这里面保存着 vite 在开发环境下对第三方依赖进行预构建后的产物,而且在浏览器中请求地址也已经被 vite 服务器拦截,引入路径已经被重写,也就是处理后的产物地址:

image.png

同时对于依赖的请求结果,vite 的 Dev Server 会设置强缓存,表示缓存过期前浏览器对 react 预构建产物的请求不会再经过 Vite Dev Server,直接用缓存结果。

image.png

同时,.vite/deps​ 目录下还会存在一个 _metadata.json​文件:

image.png

这个文件是用来保存预编译阶段生成文件的映射关系(optimized​ 字段),方便在开发环境运行时重写依赖路径。

image.png

综上所述,预构建最主要的作用就是:

  1. 统一对不同打包规范的第三方依赖进行编译处理,便于在后续 vite 统一对依赖进行管理。
  2. 对第三方依赖的导入进行整合,优化第三方依赖的导入方式,最大限度的提高启动速度。

vite 对于第三方依赖的编译由 Esbuild​ 处理,这是一款由 go 语言开发的编译工具,基于 go 语言的优势,天然就会比javascript开发的传统编译工具快很多。

预构建功能如何进行配置?

开启

vite 中预编译是自动开启的,也就是说当第一次启动pnpm run dev​时,vite 会自动将依赖打包->创建.vite​文件->写入编译后文件->重写依赖引入路径->设置缓存

除了 HTTP 缓存,Vite 还设置了本地文件系统的缓存,所有的预构建产物默认缓存在node_modules/.vite​目录中。如果以下 3 个地方都没有改动,Vite 将一直使用缓存文件:

  1. package.json 的 dependencies​ 字段
  2. 各种包管理器的 lock 文件
  3. optimizeDeps​ 配置内容

自定义配置

vite 同时提供了一些 api,便于我们对预构建编译进行自定义配置。

  • entries

optimizeDeps.entries​用来配置自定义预构建的入口文件。

通常默认会获取项目中的 HTML 文件,比如项目的入口文件是index.html​。也是大部分前端项目脚手架的默认入口,将 HTML 文件作为应用入口,然后根据入口文件扫描出项目中用到的第三方依赖,最后对这些依赖逐个进行编译。

如果需要有自定义的入口文件,entries​属性就派上用场了,比如我们现在想在vue中使用main.vue​作为预构建的入口文件,在vite.config.ts​中加入配置:

{
  optimizeDeps: {
    entries: ["./src/main.vue"];
  }
}

optimizeDeps.entries​接收一个字符串数组,说明可以配置多入口:

{
  optimizeDeps: {
    entries: ["./src/main.vue", "./src/foo.jsx"];
  }
}

也支持 glob 语法,比如:

{
  optimizeDeps: {
    entries: ["**/*.vue"];
  }
}

glob​语法代表使用*匹配字符,上面的配置代表将所有的 .vue​ 文件作为扫描入口。

  • ​​include

可以强制预构建的依赖项。

配置为一个字符串数组,将 lodash-es​ 和 vue​两个包强制进行预构建

optimizeDeps: {
  include: ["lodash-es", "vue"];
}

optimizeDeps: {
   include: ['my-lib/components/**/*.vue'],
},

  • ​​exclude

对某些依赖项进行排除,不进行预构建。

optimizeDeps: {
  exclude: ["esm-dep"], // 将指定数组中的依赖不进行依赖预构建
}

当使用这个配置项是需要注意:如果一个 ESM 依赖项被排除在优化之外,但有一个嵌套的 CommonJS​ 依赖项,则应将 CommonJS​ 依赖项添加到 optimizeDeps.include​ 中。

间接依赖的声明语法,通过>分开, 如a > b表示 a 中依赖的 b。

optimizeDeps: {
   include: ['esm-dep > cjs-dep'],
}

  • ​​esbuildOptions

vite本身还提供了esbuildOptions​ 参数来让我们自定义 Esbuild 本身的配置,常用的场景是加入一些 Esbuild 插件:

{
  optimizeDeps: {
    esbuildOptions: {
       plugins: [
        // 加入 Esbuild 插件
      ];
    }
  }
}

整体流程

image.png

  1. 启动开发服务器(执行pnpm un dev​命令),在没有指定入口文件的情况下(rollupOptions.input​/optimizeDeps.entries​),vite会获取项目目录下的所有的(config.root​) .html​ 文件来检测需要预构建的依赖项。通常默认会使用单个 index.html​ 作为入口文件。

  2. 分析 index.html​ 入口文件内容。 其次,当首次运行启动命令后。vite 会寻找到入口 HTML 文件后会分析该入口文件中的 <script>​ 标签寻找引入的 js/ts​ 资源(例如 react 项目中的 /src/main.ts​)。

  3. 分析 /src/main.ts​ 模块依赖 之后,会进入 /main.ts​ 代码中进行扫描,扫描该模块中的所有 import​ 导入语句。这一步主要会将依赖分为两种类型从而进行不同的处理方式:

    • 对于源码中引入的第三方依赖模块,比如 lodash​、react​ 等第三方模块。Vite 会在这个阶段将导入的第三方依赖的入口文件地址记录到内存中,简单来说比如当碰到 import antd from 'antd'​时 Vite 会记录 { antd: '/xxx/vite/vite-use/node_modules/antd/es/index.js' }​,同时会将第三方依赖当作外部依赖(external​)进行处理(并不会递归进入第三方依赖进行扫描)。
    • 对于模块源代码,就比如我们在项目中编写的源代码。Vite 会依次扫描模块中所有的引入,对于非第三方依赖模块会再次递归进入扫描。
  4. 在扫描处理完成 /src/main.ts​ 后,Vite 会对于该模块中的源码模块进行递归分析。这一步会重新进行第三步骤。例如处理main.tsx​下面的/src/App.tsx​文件。

最终,经过上述步骤 Vite 会从入口文件出发扫描出项目中所有依赖的第三方依赖,同时会存在一份类似于如下的映射关系表:

{
    "antd": {
            // key 为引入的第三方依赖名称,value 为该包的入口文件地址
         "src": "/Users/vite/vite-use/node_modules/antd/es/index.js"
    },
     // ...
}
  1. 生产依赖预构建产物。我们已经生成了一份源码中所有关于第三方导入的依赖映射表。 最后,Vite 会根据这份映射表调用 EsBuild 对于扫描出的所有第三方依赖入口文件进行打包。将打包后的产物存放在 node_modules/.vite/deps​ 文件中。 比如,源码中导入的 antd​ 最终会被构建为一个单独的 antd.js​ 文件存放在 node_modules/.vite/deps/antd.js​ 中。

预构建对于第三方依赖生成 node_modules/.vite/deps​ 资源后。在开发环境下 vite​ 会“拦截”浏览器的所有 ESM 请求,将源码中对于第三方依赖的请求地址重写为我们预构建之后的资源产物,

源码解析

我们目前只关心预构建相关的代码,所以只需要关注调用 /vite/src/node/server/index.ts​ 创建开发服务器的逻辑。

入口

对于预构建的功能,vite 有一个单独的启动命令:vite optimize​。

执行这个启动命令会自动开始执行预构建的逻辑。--force​这个选项指忽略缓存文件,强制执行预构建。

cli
  .command('optimize [root]', 'pre-bundle dependencies')
  .option(
    '--force',
    `[boolean] force the optimizer to ignore the cache and re-bundle`,
  )
  .action(
    async (root: string, options: { force?: boolean } & GlobalCLIOptions) => {
 
      const { optimizeDeps } = await import('./optimizer')
      try {
		// 生成默认的配置文件
        const config = await resolveConfig(
          {
            root,
            base: options.base,
            configFile: options.config,
            logLevel: options.logLevel,
            mode: options.mode,
          },
          'serve',
        )
		// 预构建入口
        await optimizeDeps(config, options.force, true)
      } catch (e) {
        // ...
      }
    },
  )

resolveConfig​主要是处理配置文件配置项的默认值。

在 cli 函数中我们只关注找到预构建的入口函数optimizeDeps​。

注意我们在梳理预构建主流程时,会排除掉与核心构建流程无关的处理逻辑分枝。只关注核心逻辑。

export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.optimizeDeps.force,
  asCommand = false,
): Promise<DepOptimizationMetadata> {
  const log = asCommand ? config.logger.info : debug
  const ssr = false
  // 加载缓存文件 _metadata.json
  // 加载缓存的预构建元数据,如果缓存有效则直接返回。
  const cachedMetadata = await loadCachedDepOptimizationMetadata(
    config,
    ssr,
    force,
    asCommand,
  )
  // 不需要重新构建,直接将缓存文件作为结果返回
  if (cachedMetadata) {
    return cachedMetadata
  }
  // 扫描项目中的依赖项,生成依赖项列表。
  const deps = await discoverProjectDependencies(config).result
  // 添加手动包含的依赖项
  await addManuallyIncludedOptimizeDeps(deps, config, ssr)

  // ...
  // 整理依赖关系
  const depsInfo = toDiscoveredDependencies(config, deps, ssr)
  // 使用 esbuild 执行预构建任务。
  const result = await runOptimizeDeps(config, depsInfo, ssr).result
  // 写入缓存预构建结果
  await result.commit()

  return result.metadata
}

检查缓存

进入optimizeDeps​函数后首先进行缓存判断,当已经进行过一次预构建之后 vite 会将构建结果保存在.vite​文件下。随后下一次启动项目时会通过 hash 值来进行缓存的判断,如果命中缓存则不会进行后续的预构建流程。

export async function loadCachedDepOptimizationMetadata(
  config: ResolvedConfig,
  ssr: boolean,
  force = config.optimizeDeps.force,
  asCommand = false,
): Promise<DepOptimizationMetadata | undefined> {
  // 获取deps路径
  const depsCacheDir = getDepsCacheDir(config, ssr)
  // 用户不需要强制预构建
  // 进入正常的缓存判断流程
  if (!force) {
    let cachedMetadata: DepOptimizationMetadata | undefined
    try {
      // 拼接 metadata 路径
      const cachedMetadataPath = path.join(depsCacheDir, METADATA_FILENAME)

      // 获取缓存文件_metadata.json
      cachedMetadata = parseDepsOptimizerMetadata(
        await fsp.readFile(cachedMetadataPath, 'utf-8'),
        depsCacheDir,
      )
    } catch (e) {}
    // hash is consistent, no need to re-bundle
    if (cachedMetadata) {
	  // 因锁定文件已更改而重新优化依赖关系
      if (cachedMetadata.lockfileHash !== getLockfileHash(config, ssr)) {
        config.logger.info(
          'Re-optimizing dependencies because lockfile has changed',
        )
	  // 因 vite 配置更改而重新优化依赖关系
      } else if (cachedMetadata.configHash !== getConfigHash(config, ssr)) {
        config.logger.info(
          'Re-optimizing dependencies because vite config has changed',
        )
	  // 命中缓存,不重新进行预构建
	  // 但是可以使用--force强制执行预构建
      } else {
        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 {
	// 强制预构建 设置force为true
    config.logger.info('Forced re-optimization of dependencies')
  }
  // 没有命中缓存策略,重新预构建
  // 并删除上次预构建产生的文件
  debug?.(colors.green(`removing old cache dir ${depsCacheDir}`))
  await fsp.rm(depsCacheDir, { recursive: true, force: true })
}

1. getDepsCacheDir 缓存路径解析

getDepsCacheDir​函数中查找缓存路径时,直接使用config.cacheDir​中的值进行拼接,这个值是vite在配置文件vite.config.js​中提供的,用于用户自定义存储缓存文件的目录。

cacheDir 配置项, 默认值为 "node_modules/.vite"

存储缓存文件的目录。此选项的值可以是文件的绝对路径,也可以是以项目根目录为基准的相对路径。当没有检测到 package.json 时,则默认为 .vite​。

vite直接使用cacheDir​中的路径同deps​进行拼接,只不过针对windows​平台做了一层兼容,最后的结果也就是 vite 默认保存预编译产物的路径:node_modules/.vite/deps

export function getDepsCacheDir(config: ResolvedConfig, ssr: boolean): string {
  return getDepsCacheDirPrefix(config) + getDepsCacheSuffix(ssr)
}

function getDepsCacheDirPrefix(config: ResolvedConfig): string {
  return normalizePath(path.resolve(config.cacheDir, 'deps'))
}

function getDepsCacheSuffix(ssr: boolean): string {
  return ssr ? '_ssr' : ''
}

export function normalizePath(id: string): string {
  return path.posix.normalize(isWindows ? slash(id) : id)
}

随后针对组装后的路径node_modules/.vite/deps​与_metadata.json​进行拼接:

const cachedMetadataPath = path.join(depsCacheDir, METADATA_FILENAME)
// 相当于最后的结果:
// node_modules/.vite/deps/_metadata.json

cachedMetadataPath​变量中最后保存的是_metadata.json​文件的路径。

2. parseDepsOptimizerMetadata 解析_metadata.json​配置文件

当拼接路径之后就会读取上一次生成的_metadata.json​文件的元信息。

parseDepsOptimizerMetadata​函数的主要任务就是解析缓存中_metadata.json​文件的信息。

此时传入parseDepsOptimizerMetadata​函数的第一个参数为被读取后的文件内容,第二个参数为缓存文件路径。

  1. 首先会对_metadata.json​文件中的file​或src​进行路径规范化,也就是每个依赖的真实路径:

image.png

  1. 判断是否存在chunks​内容,如果不存在chunks或者某一个依赖缺少fileHash​属性,那么也需要重新构建
if (
    !chunks ||
    Object.values(optimized).some((depInfo: any) => !depInfo.fileHash)
  ) {
    return
  }

  1. 遍历每个依赖的信息,将其组装到metadata.optimized​和metadata.chunks​中

function parseDepsOptimizerMetadata(
  jsonMetadata: string,
  depsCacheDir: string,
): DepOptimizationMetadata | undefined {
  const { hash, lockfileHash, configHash, browserHash, optimized, chunks } =
    JSON.parse(jsonMetadata, (key: string, value: string) => {
	  // 解析 JSON 字符串,并处理 file 和 src 字段
      if (key === 'file' || key === 'src') {
        return normalizePath(path.resolve(depsCacheDir, value))
      }
      return value
    })
  // 需要重新执行预构建
  if (
    !chunks ||
    Object.values(optimized).some((depInfo: any) => !depInfo.fileHash)
  ) {
  
    return
  }

  // 定义metadata初始信息
  const metadata = {
    hash,
    lockfileHash,
    configHash,
    browserHash,
    optimized: {},
    discovered: {},
    chunks: {},
    depInfoList: [],
  }
  // 保存所有optimized的值
  for (const id of Object.keys(optimized)) {
    addOptimizedDepInfo(metadata, 'optimized', {
      ...optimized[id],
      id,
      browserHash,
    })
  }
  // 保存所有chunks的值
  for (const id of Object.keys(chunks)) {
    addOptimizedDepInfo(metadata, 'chunks', {
      ...chunks[id],
      id,
      browserHash,
      needsInterop: false,
    })
  }
  return metadata
}

addOptimizedDepInfo​分别对optimized​ 和chunks​ 进行组装。同时将所有的依赖和chunk统一保存到depInfoList​列表中。

export function addOptimizedDepInfo(
  metadata: DepOptimizationMetadata,
  type: 'optimized' | 'discovered' | 'chunks',
  depInfo: OptimizedDepInfo,
): OptimizedDepInfo {
  // 根据type填充不同的数据
  metadata[type][depInfo.id] = depInfo
  metadata.depInfoList.push(depInfo)
  return depInfo
}

3. 计算hash值对比

‍ ​cachedMetadata​这个变量保存缓存中的_metadata.json​文件信息。包含上一次构建时产生的 hash 值。

本次启动执行时,会再一次计算 hash 值,比较两次的 hash 值是否发生变动,是否重新进行预编译主要依赖于hash值的判断,当进行一次预编译之后,会将所有相关的编译信息保存在_metadata.json​中。下一次启动项目时会使用新计算的hash值与_metadata.json​中的保存的上一次的hash值进行对比。判断是否命中缓存策略。以此来作为是否作为是否重新预构建的依据。

if (cachedMetadata) {
      if (cachedMetadata.lockfileHash !== getLockfileHash(config, ssr)) {
		// 依赖的lock文件发生变动,需要重新构建
        config.logger.info(
          'Re-optimizing dependencies because lockfile has changed',
        )
      } else if (cachedMetadata.configHash !== getConfigHash(config, ssr)) {
		// vite config文件发生更改,需要重新预构建
        config.logger.info(
          'Re-optimizing dependencies because vite config has changed',
        )
      } else {
        // 命中缓存,返回 cachedMetadata
        log?.('Hash is consistent. Skipping. Use --force to override.')
        return cachedMetadata
      }
  }

image.png

其中包含两种 hash 值的变动;vite.config​变动造成的变更和依赖文件造成的变动。

getLockfileHash

生成版本依赖文件的hash值,按照固定的文件名称支持:['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb']

const lockfileNames = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb']

function getLockfileHash(config: ResolvedConfig, ssr: boolean): string {
  // 查找依赖版本文件路径,按照lockfileNames中指定的文件名
  const lockfilePath = lookupFile(config.root, lockfileNames)
  // 读取版本文件内容
  let content = lockfilePath ? fs.readFileSync(lockfilePath, 'utf-8') : ''

  // ...
  // 根据读取的文件内容生成hash值
  return getHash(content)
}

查找路径时,由于查找依赖的版本文件是根据config.root​配置项开始查找,这项配置并不一定是位于根路径,所以 vite 要做的第一步是确定文件所在的位置。从当前工作目录开始,逐级向上查找,直到找到文件或到达文件系统的根目录。

遍历 fileNames​ 数组中的每个文件名。

  • 使用 path.join​ 构造完整的文件路径。
  • 使用 fs.statSync​ 检查文件是否存在且是一个文件(而不是目录)。
  • 如果找到匹配的文件,立即返回该文件的完整路径。
export function lookupFile(
  dir: string,
  fileNames: string[],
): string | undefined {
  // 这个循环会一直执行,直到找到文件或者到达文件系统的根目录。
  while (dir) {
	// 遍历支持的依赖版本文件
    for (const fileName of fileNames) {
      // 拼接路径
      const fullPath = path.join(dir, fileName)
	  // fs.statSync 判断文件或文件夹是否存在 ,如果不存在则报错。还可以判断是文件还是文件夹 。
      if (fs.statSync(fullPath, { throwIfNoEntry: false })?.isFile()) return fullPath
    }
	// 移动到父级目录
	// path.dirname 返回上级目录路径
    const parentDir = path.dirname(dir)
    if (parentDir === dir) return

    dir = parentDir
  }
}

如果在当前目录没有找到文件,移动到父目录

   const parentDir = path.dirname(dir)
   if (parentDir === dir) return
   dir = parentDir

  • 获取当前目录的父目录。

    • 如果父目录与当前目录相同(即已经到达文件系统的根目录),则返回 undefined。
    • 否则,将 dir 更新为父目录,继续下一次循环。

函数返回值:

  • 如果找到匹配的文件,返回该文件的完整路径。
  • 如果遍历到根目录仍未找到文件,返回 undefined。

getHash
export function getHash(text: Buffer | string, length = 8): string {
  const h = createHash('sha256').update(text).digest('hex').substring(0, length)
  if (length <= 64) return h
  return h.padEnd(length, '_')
}

生成哈希:

const h = createHash('sha256').update(text).digest('hex').substring(0, length)

使用 Node.js 的 crypto 模块创建一个 SHA-256 哈希对象。

  • 使用 update​ 方法将输入文本添加到哈希对象中。
  • 使用 digest('hex')​ 生成十六进制格式的哈希字符串。
  • 使用 substring(0, length)​ 截取指定长度的哈希字符串。

返回值:

if (length <= 64) return h
return h.padEnd(length, '_')

如果请求的长度小于或等于 64(SHA-256 的最大输出长度),直接返回截取的哈希字符串。

如果请求的长度大于 64,使用 padEnd​ 方法将哈希字符串填充到指定长度,填充字符为下划线 '_'。

​getConfigHash

vite.config.js​ 配置文件中只有部分配置会影响依赖的变化,也就是optimizeDeps​配置项中的配置内容:

image.png

所以对于vite.config.js​ 配置文件 hash 值的对比,只需要将这些影响预构建依赖的配置项提取出来,生成hash即可。

function getConfigHash(config: ResolvedConfig, ssr: boolean): string {
  // 提取配置文件中的optimizeDeps
  const optimizeDeps = config.optimizeDeps
  // 字符串序列化
  const content = JSON.stringify(
    {
      mode: process.env.NODE_ENV || config.mode,
      root: config.root,
      resolve: config.resolve,
      assetsInclude: config.assetsInclude,
      plugins: config.plugins.map((p) => p.name),
      optimizeDeps: {
        include: optimizeDeps?.include
          ?  Array.from(new Set(optimizeDeps.include)).sort()
          : undefined,
        exclude: optimizeDeps?.exclude
          ? Array.from(new Set(optimizeDeps.exclude)).sort()
          : undefined,
        esbuildOptions: {
          ...optimizeDeps?.esbuildOptions,
          plugins: optimizeDeps?.esbuildOptions?.plugins?.map((p) => p.name),
        },
      },
    },
    (_, value) => {
	  // 对于函数和正则,单独进行字符串转换
      if (typeof value === 'function' || value instanceof RegExp) {
        return value.toString()
      }
      return value
    },
  )
  // 生成hash
  return getHash(content)
}

getConfigHash​中的逻辑比较简单,主要就是组装配置对象,整理optimizeDeps​中的依赖项。值得一提的是,针对函数和正则的内容,还将他们单独转换字符串。

vite 对于整个缓存的处理也可以看到预构建的触发时机:

  • 开发服务器启动时
  • 某些配置项(如 resolve/optimizeDeps​)发生改变时
  • package.json/package-lock.json​ 中的 dependencies​ 依赖列表发生改变时
  • 用户通过命令行手动触发时 --force

以上只是文件系统的缓存,vite还存在浏览器缓存中的缓存:

image.png

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

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

依赖扫描

如果没有有效的缓存,vite 会扫描项目中的依赖项。它会解析项目的入口文件(如 index.html 或 main.js),找到所有的依赖项。

其实从discoverProjectDependencies​函数的ts类型标注也可以看出来,discoverProjectDependencies​函数返回取消函数和结果函数。其中结果函数是一个promise​类型,在返回扫描依赖结果之前,还对扫描过程中无法处理的依赖增加错误提示。

export function discoverProjectDependencies(config: ResolvedConfig): {
  cancel: () => Promise<void>
  result: Promise<Record<string, string>>
} {
  // 解析入口
  const { cancel, result } = scanImports(config)

  return {
    cancel,
	// 被跳过的依赖
    result: result.then(({ deps, missing }) => {
      const missingIds = Object.keys(missing)
      if (missingIds.length) {

		// 错误提示
        throw new Error(
          `The following dependencies are imported but could not be resolved:\n\n  ${missingIds
            .map(
              (id) =>
                `${colors.cyan(id)} ${colors.white(
                  colors.dim(`(imported by ${missing[id]})`),
                )}`,
            )
            .join(`\n  `)}\n\nAre they installed?`,
        )
      }

      return deps
    }),
  }
}

scanImports​函数是扫描依赖的入口。

export function scanImports(config: ResolvedConfig): {
  cancel: () => Promise<void>
  result: Promise<{
    deps: Record<string, string>
    missing: Record<string, string>
  }>
} {
  
  // ...

}

查找入口文件

首先如果要扫描全部依赖,第一件事就是要找到依赖的入口文件,确定开始扫描的入口:

确定扫描入口:

  • 默认情况下,扫描从 index.html​ 开始。
  • 如果配置了 build.rollupOptions.input​,则使用该配置作为入口。
  • 如果配置了 optimizeDeps.entries​,则使用该配置作为入口。

async function computeEntries(config: ResolvedConfig) {
  let entries: string[] = []
  // 入口
  const explicitEntryPatterns = config.optimizeDeps.entries
  // 如果指定了 build.rollupOptions.input,Vite 将转而去抓这些入口点
  const buildInput = config.build.rollupOptions?.input
  // 配置了 optimizeDeps.entries 作为入口
  if (explicitEntryPatterns) {
	// 支持 glob 语法
    entries = await globEntries(explicitEntryPatterns, config)
  } else if (buildInput) {
	// 整理配置路径
    const resolvePath = (p: string) => path.resolve(config.root, p)
    // 字符串类型
    if (typeof buildInput === 'string') {
      entries = [resolvePath(buildInput)]
    // 数组类型处理
    } else if (Array.isArray(buildInput)) {
      entries = buildInput.map(resolvePath)
    // 对象类型
    } else if (isObject(buildInput)) {
      entries = Object.values(buildInput).map(resolvePath)
    } else {
      throw new Error('invalid rollupOptions.input value.')
    }
  } else {
    // 默认入口 index.html
    entries = await globEntries('**/*.html', config)
  }

  // 筛选不支持的入口文件类型和虚拟文件不应进行依赖扫描
  entries = entries.filter(
    (entry) =>
      isScannable(entry, config.optimizeDeps.extensions) &&
      fs.existsSync(entry),
  )

  return entries
}

globEntries​函数用于处理optimizeDeps.entries​的配置项,这个配置项支持字符串或者数组的形式配置入口,并且支持 glob 语法。在globEntries​函数内部使用fast-glob​这个插件提供支持。

function globEntries(pattern: string | string[], config: ResolvedConfig) {
  // 参数转换为数组格式
  const resolvedPatterns = arraify(pattern)
  // 判断是否为动态glob模式?
  // 如果不是,直接格式化路径返回
  if (resolvedPatterns.every((str) => !glob.isDynamicPattern(str))) {
    return resolvedPatterns.map((p) =>
      normalizePath(path.resolve(config.root, p)),
    )
  }
  return glob(pattern, {
    cwd: config.root,
	// 忽略文件
    ignore: [
      '**/node_modules/**',
      `**/${config.build.outDir}/**`,
      ...(config.optimizeDeps.entries
        ? []
        : [`**/__tests__/**`, `**/coverage/**`]),
    ],
    absolute: true,
    suppressErrors: true,
  })
}

// 格式化配置 转换为数组
export function arraify<T>(target: T | T[]): T[] {
  return Array.isArray(target) ? target : [target]
}

glob.isDynamicPattern​判断是否为动态模式(包含glob语法)

fg.isDynamicPattern('*'); // true
fg.isDynamicPattern('abc'); // false

如果不包含 glob 语法,直接拼接为绝对路径并格式化处理。

normalizePath(path.resolve(config.root, p))

computeEntries​函数足后返回的结果是一个数组类型的入口文件集合。

创建 Esbuild 构建上下文

使用 Esbuild 创建一个构建上下文,这允许增量构建和取消操作:

 const esbuildContext: Promise<BuildContext | undefined> = computeEntries(
    config,
  ).then((computedEntries) => {
	// 入口文件
    entries = computedEntries

    // ...
    return prepareEsbuildScanner(config, entries, deps, missing, scanContext)
  })

prepareEsbuildScanner​函数中创建一个自定义的 Esbuild 插件,用于对不同的入口文件类型进行支持。

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

  const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}

  // ...

  // 创建一个 esbuild 构建上下文
  return await esbuild.context({
    absWorkingDir: process.cwd(),
    write: false,
    stdin: {
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      loader: 'js',
    },
    bundle: true,
    format: 'esm',
    logLevel: 'silent',
    plugins: [...plugins, plugin],
    ...esbuildOptions,
    tsconfigRaw,
  })
}

esbuildScanPlugin​函数初始化数据后返回一个 esbuild 插件,因为处理不同类型的逻辑非常繁多,我们只关注一下 html 类型的文件的处理。

const htmlTypesRE = /\.(html|vue|svelte|astro|imba)$/

function esbuildScanPlugin(
  config: ResolvedConfig,
  container: PluginContainer,
  depImports: Record<string, string>,
  missing: Record<string, string>,
  entries: string[],
): Plugin {

	return {
    	name: 'vite:dep-scan',
	    setup(build) {
			// 标记 HTML 文件的 namespace
      		build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
		        return {
		          path: await resolve(path, importer),
		          namespace: "html",
		        };
		      });
	
		      build.onLoad(
		        { filter: htmlTypesRE, namespace: "html" },
		        htmlTypeOnLoadCallback,
		    );
		}
	}
}

htmlTypeOnLoadCallback​ 函数 是一个用于处理 HTML 类型文件(包括 .html, .vue, .svelte, .astro 等)的回调函数。它的主要作用是解析这些文件中的 <script> 标签,并将其内容转换为可以被 esbuild 处理的 JavaScript 模块。

例如一个 html 文件:

<body>
	<script type="module" src="src/main.ts"></script>
	<script type="module">
		import React from 'react'
		console.log(React)
	</script>
</body>

经过插件转换之后:

import '/src/main.ts'
import React from 'react'
console.log(React)

扫描出所有带有 type=module​ 的 script 标签,对于含有 src 的 script​ 改写为一个 import 语句,对于含有具体内容的 script,则抽离出其中的脚本内容,最后将所有的 script 内容拼接成一段 js 代码。

export const scriptRE =
  /(<script(?:\s+[a-z_:][-\w:]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^"'<>=\s]+))?)*\s*>)(.*?)<\/script>/gis
export const commentRE = /<!--.*?-->/gs
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const typeRE = /\btype\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const contextRE = /\bcontext\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i

const htmlTypeOnLoadCallback: (
        args: OnLoadArgs,
      ) => Promise<OnLoadResult | null | undefined> = async ({ path: p }) => {
		// 读取文件内容
        let raw = await fsp.readFile(p, 'utf-8')
        // 删除所有注释内容
        raw = raw.replace(commentRE, '<!---->')
		// 是否为html
        const isHtml = p.endsWith('.html')
        let js = ''
        let scriptId = 0
		// 匹配 <script 标签
        const matches = raw.matchAll(scriptRE)
		// 利用捕获组匹配开始标签和内容
        for (const [, openTag, content] of matches) {
		  // 匹配type
          const typeMatch = typeRE.exec(openTag)
          const type =
            typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
		  // 匹配lang
          const langMatch = langRE.exec(openTag)
          const lang =
            langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
          // 如果不是html类型文件并且type不是module,直接跳过
          if (isHtml && type !== 'module') {
            continue
          }
          // 跳过 type=“application/ld+json” 和其他非 JavaScript 类型
          if (
            type &&
            !(
              type.includes('javascript') ||
              type.includes('ecmascript') ||
              type === 'module'
            )
          ) {
            continue
          }
          let loader: Loader = 'js'
          if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
            loader = lang
          } else if (p.endsWith('.astro')) {
            loader = 'ts'
          }
          const srcMatch = srcRE.exec(openTag)
		  // 有无src属性
          if (srcMatch) {
			// 直接将src后面的值使用import进行拼接引入
            const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
            js += `import ${JSON.stringify(src)}\n`
          } else if (content.trim()) {
           	// 增加虚拟模块
			// 在一个html文件中可能存在多个<script>标签
			// 防止变量名在它们之间重复使用
            const contents =
              content +
              (loader.startsWith('ts') ? extractImportPaths(content) : '')

            const key = `${p}?id=${scriptId++}`
            if (contents.includes('import.meta.glob')) {
              // ...
            } else {
			  // 按照key分别保存每块script标签在scripts对象中
              scripts[key] = {
                loader,
                contents,
                resolveDir: normalizePath(path.dirname(p)),
                pluginData: {
                  htmlType: { loader },
                },
              }
            }
			// 虚拟模块路径
			// virtualModulePrefix = 'virtual-module:'
            const virtualModulePath = JSON.stringify(virtualModulePrefix + key)

            const contextMatch = contextRE.exec(openTag)
            const context =
              contextMatch &&
              (contextMatch[1] || contextMatch[2] || contextMatch[3])

  			//  .svelte类型结尾文件
            if (p.endsWith('.svelte') && context !== 'module') {
              js += `import ${virtualModulePath}\n`
            } else {
              js += `export * from ${virtualModulePath}\n`
            }
          }
        }

        if (!p.endsWith('.vue') || !js.includes('export default')) {
          js += '\nexport default {}'
        }

        return {
          loader: 'js',
          contents: js,
        }
      }

使用 scriptRE.matchAll()​ 方法匹配这个 HTML 内容后,我们会得到一个迭代器。将这个迭代器转换为数组,每个匹配项都是一个数组,包含以下内容:

  1. 完整匹配的字符串
    第一个捕获组(开始标签)
    第二个捕获组(脚本内容)
export const scriptRE =
  /(<script(?:\s+[a-z_:][-\w:]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^"'<>=\s]+))?)*\s*>)(.*?)<\/script>/gis
// 分组匹配
const matches = raw.matchAll(scriptRE)

比如我们模拟一段 html 代码:

<!DOCTYPE html>
<html>
<head>
  <script src="external.js"></script>
  <script type="module">
    import { foo } from './module.js';
    console.log(foo);
  </script>
</head>
<body>
  <script>
    var x = 10;
    console.log(x);
  </script>
  <script type="text/javascript" async>
    function hello() {
      alert('Hello, world!');
    }
  </script>
</body>
</html>

使用上述的正则进行捕获:

[
  [
    '<script src="external.js"></script>',
    '<script src="external.js">',
    ''
  ],
  [
    '<script type="module">\n    import { foo } from \'./module.js\';\n    console.log(foo);\n  </script>',
    '<script type="module">',
    '\n    import { foo } from \'./module.js\';\n    console.log(foo);\n  '
  ],
  [
    '<script>\n    var x = 10;\n    console.log(x);\n  </script>',
    '<script>',
    '\n    var x = 10;\n    console.log(x);\n  '
  ],
  [
    '<script type="text/javascript" async>\n    function hello() {\n      alert(\'Hello, world!\');\n    }\n  </script>',
    '<script type="text/javascript" async>',
    '\n    function hello() {\n      alert(\'Hello, world!\');\n    }\n  '
  ]
]

在实际的 htmlTypeOnLoadCallback​ 函数中,这些匹配结果会被进一步处理:

  • 第一个元素(完整匹配)通常不会直接使用。
  • 第二个元素(开始标签)会被用来提取属性,如 type、lang、src 等。
  • 第三个元素(脚本内容)会被处理成 JavaScript 模块或被直接使用。

随后根据分组匹配结果:有无src属性,分为

<script type="module" src="src/main.ts"></script>

<script type="module">
	import React from 'react'
	console.log(React)
</script>

两种 script 标签导入方式。

拥有 src 属性的 script 标签直接使用 import​ 导入路径。

script 标签内的的代码创建一个虚拟模块使用scripts​对象进行保存,最后导出虚拟模块名称

可以看到,即使是html​或者类似这种类型的文件,也是能作为 Esbuild 的预构建入口来进行解析的。

执行扫描,收集依赖

使用 Esbuild 的 build 方法执行实际的扫描过程。在扫描过程中,插件会拦截每个模块的解析和加载。在扫描过程中,收集所有遇到的外部依赖,区分成功解析的依赖和无法解析的依赖。

入口的问题解决了,接下来还有一个问题: 如何在 Esbuild 编译的时候记录依赖呢?

Vite 中会把 bare import​的路径当做依赖路径,关于bare import​,你可以理解为直接引入一个包名,比如下面这样:

import React from "react";

而以.​开头的相对路径或者以/​开头的绝对路径都不能算bare import​:

// 以下都不是 bare import
import React from "../node_modules/react/index.js";
import React from "/User/sanyuan/vite-project/node_modules/react/index.js";

vite中关于预构建的依赖,主要是处理bare import​的导入。 对于解析 bare import​、记录依赖的逻辑依然实现在 scan 插件当中:

   build.onResolve(
        {
          // avoid matching windows volume
          filter: /^[\w@][^:]/,
        },
        async ({ path: id, importer, pluginData }) => {
		  // 如果在 optimizeDeps.exclude 列表或者已经记录过了,则将其 externalize (排除),直接 return
          if (moduleListContains(exclude, id)) {
            return externalUnlessEntry({ path: id })
          }
          if (depImports[id]) {
            return externalUnlessEntry({ path: id })
          }

		  // 解析路径,内部调用各个插件的 resolveId 方法进行解析
          const resolved = await resolve(id, importer, {
            custom: {
              depScan: { loader: pluginData?.htmlType?.loader },
            },
          })
          if (resolved) {
			// 判断是否应该 externalize
            if (shouldExternalizeDep(resolved, id)) {
              return externalUnlessEntry({ path: id })
            }
            if (isInNodeModules(resolved) || include?.includes(id)) {
             // 如果 resolved 为 js 或 ts 文件
              if (isOptimizable(resolved, config.optimizeDeps)) {
				// 现在将其正式地记录在依赖表中
                depImports[id] = resolved
              }
			  // 进行 externalize,因为这里只用扫描出依赖即可,不需要进行打包
              return externalUnlessEntry({ path: id })
            } else if (isScannable(resolved, config.optimizeDeps.extensions)) {
              // resolved 为 「类 html」 文件,则标记上 'html' 的 namespace
			  const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined

              return {
                path: path.resolve(resolved),
                namespace,
              }
            } else {
              return externalUnlessEntry({ path: id })
            }
          } else {
			//  没有解析到路径,记录到 missing 表中,后续会检测这张表,显示相关路径未找到的报错
            missing[id] = normalizePath(importer)
          }
        },
      )

这个插件依然使用正则进行过滤:

匹配以一个单词字符或 @ 开头,紧接着是一个非冒号字符的字符串。

这个正则表达式在 Vite 的依赖扫描过程中用于识别"裸"导入(bare imports)。具体来说:

  1. 它可以匹配大多数常见的包名格式,如:
  • react
  • @vue/runtime-core
  • lodash/debounce

它会排除以下类型的导入:

  • 相对路径导入(如 ./module 或 ../module)
  • 绝对路径导入(如 /absolute/path)
  • URL 导入(如 example.com/module.js)

特别注意,它会避免匹配 Windows 风格的绝对路径(如 C:\path\to\module)。

build.onResolve​ 设置了一个解析器,用于处理匹配 /\^[\\w@][\^:]/​ 的导入(通常是第三方模块或裸导入bare import​)。

  1. 解析过程:

a. 检查是否在排除列表中:

if (moduleListContains(exclude, id)) {
    return externalUnlessEntry({ path: id })
}

排除列表是 vite 已经默认配置了一些再加上我们在vite.config.js​配置文件中的配置项exclude​:

  const exclude = [
    ...(config.optimizeDeps?.exclude || []),
    '@vite/client',
    '@vite/env',
  ]

在记录依赖的同时标记是否为入口文件。

// entries为入口文件列表
const isUnlessEntry = (path: string) => !entries.includes(path)

const externalUnlessEntry = ({ path }: { path: string }) => ({
  path,
  external: isUnlessEntry(path),
})

如果模块在排除列表中,将其标记为外部模块(除非是入口文件)。

b. 检查是否已经在依赖导入中:

if (depImports[id]) {
   return externalUnlessEntry({ path: id })
}

如果模块已经在依赖导入列表中,同样将其标记为外部模块。

c. 解析模块:

const resolved = await resolve(id, importer, {
   custom: {
     depScan: { loader: pluginData?.htmlType?.loader },
   },
})

使用 Vite 的解析器解析模块路径。

  1. 处理解析结果:

a. 如果解析成功(resolved​ 存在)

  • 检查是否应该外部化:
 if (shouldExternalizeDep(resolved, id)) {
    return externalUnlessEntry({ path: id })
 }

shouldExternalizeDep​判断后续是否在 esbuild 中被加载:

function shouldExternalizeDep(resolvedId: string, rawId: string): boolean {
  // 解析之后不是一个绝对路径,不在 esbuild 中进行加载
  if (!path.isAbsolute(resolvedId)) {
    return true
  }
  // 1. import 路径本身就是一个绝对路径
  // 2. 虚拟模块(Rollup 插件中约定虚拟模块以`\0`开头)
  if (resolvedId === rawId || resolvedId.includes('\0')) {
    return true
  }
  return false
}

  • 检查是否在 node_modules​ 中被包含:
if (isInNodeModules(resolved) || include?.includes(id)) {
    if (isOptimizable(resolved, config.optimizeDeps)) {
        depImports[id] = resolved
    }
    return externalUnlessEntry({ path: id })
}

如果是,将其添加到 depImports​ 并标记为外部模块。

  • 检查是否可扫描:
else if (isScannable(resolved, config.optimizeDeps.extensions)) {
     const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
	// 继续向下扫描子文件
     return {
        path: path.resolve(resolved),
        namespace,
     }
}

如果是可扫描的文件(如 JS、TS 或 HTML 类型)js​/html​/config.optimizeDeps.extensions​,继续扫描子文件过程。

function isScannable(id: string, extensions: string[] | undefined): boolean {
  return (
    JS_TYPES_RE.test(id) ||
    htmlTypesRE.test(id) ||
    extensions?.includes(path.extname(id)) ||
    false
  )
}

如果以上都不是,将其标记为外部模块:

else {
    return externalUnlessEntry({ path: id })
}

b. 如果解析失败:

else {
    missing[id] = normalizePath(importer)
}

将其添加到 missing​ 列表中,记录无法解析的模块。

  const resolve = async (
    id: string,
    importer?: string,
    options?: ResolveIdOptions,
  ) => {
    const key = id + (importer && path.dirname(importer))
    if (seen.has(key)) {
      return seen.get(key)
    }
    const resolved = await container.resolveId(
      id,
      importer && normalizePath(importer),
      {
        ...options,
        scan: true,
      },
    )
    const res = resolved?.id
    seen.set(key, res)
    return res
  }

最后扫描函数scanImports​的大体结构如下:其中省略大部分与主线无关的逻辑。

export function scanImports(config: ResolvedConfig): {
  cancel: () => Promise<void>
  result: Promise<{
    deps: Record<string, string>
    missing: Record<string, string>
  }>
} {

  const start = performance.now()
  const deps: Record<string, string> = {}
  const missing: Record<string, string> = {}
  let entries: string[]

  const scanContext = { cancelled: false }
  
  const esbuildContext: Promise<BuildContext | undefined> = computeEntries(
    config,
  ).then((computedEntries) => {
    entries = computedEntries

    // ...
    if (scanContext.cancelled) return

    // ...
    return prepareEsbuildScanner(config, entries, deps, missing, scanContext)
  })

  const result = esbuildContext
    .then((context) => {
      // ...
      if (!context || scanContext?.cancelled) {
        // ...
        return { deps: {}, missing: {} }
      }
      return context
        .rebuild()
        .then(() => {
          return {
            // 确保有固定的顺序,这样哈希值就能保持稳定并改进日志记录
            deps: orderedDependencies(deps),
            missing,
          }
        })
        .finally(() => {
          // ...
        })
    })
    .catch(async (e) => {
      if (e.errors && e.message.includes('The build was canceled')) {
        // esbuild logs an error when cancelling, but this is expected so
        // return an empty result instead
        return { deps: {}, missing: {} }
      }

      // ...
    .finally(() => {
  
    })

  // 最后返回结果列表
 // 取消函数
  return {
    cancel: async () => {
      scanContext.cancelled = true
      return esbuildContext.then((context) => context?.cancel())
    },
    result,
  }
}

在 esbuild 中,rebuild()​ 是一个方法。它允许你在不创建新的构建上下文的情况下重新执行构建。

以下为摘自 esbuild 文档中的解释:

如果你的用例涉及使用相同的选项重复调用 esbuild 的构建 API,你可能需要使用这个 API。例如,如果你要实现自己的文件监视器服务,这就很有用。重建比重新构建更有效率,因为上一次构建的部分数据已被缓存,如果原始文件在上一次构建后没有发生变化,就可以重复使用。重建 API 目前使用两种形式的缓存:

文件存储在内存中,如果文件元数据自上次构建后未发生变化,则不会从文件系统重新读取。这种优化只适用于文件系统路径。它不适用于插件创建的虚拟模块。

解析后的 AST 保存在内存中,如果文件内容自上次构建以来未发生变化,则会避免重新解析 AST。除了文件系统模块外,只要虚拟模块路径保持不变,这一优化也适用于插件创建的虚拟模块。

下面是重建的方法:

import * as esbuild from 'esbuild'

let ctx = await esbuild.context({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
})

// Call "rebuild" as many times as you want
for (let i = 0; i < 5; i++) {
  let result = await ctx.rebuild()
}

// Call "dispose" when you're done to free up resources
ctx.dispose()

​toDiscoveredDependencies

toDiscoveredDependencies​函数主要目的是将原始的依赖信息转换为 Vite 内部使用的优化依赖信息格式。让我们逐步解析这个函数:

  1. 函数参数:
  • config: Vite 的配置对象
  • deps: 原始依赖对象,键是依赖 ID,值是依赖的源文件路径
  • ssr: 是否为服务器端渲染模式
  • timestamp: 可选的时间戳
  1. 生成浏览器哈希:
   const browserHash = getOptimizedBrowserHash(
     getDepHash(config, ssr).hash,
     deps,
     timestamp,
   )

这个哈希值用于缓存和版本控制,确保在依赖或配置发生变化时能够重新优化。

  1. 初始化结果对象:
   const discovered: Record<string, OptimizedDepInfo> = {}
  1. 处理每个依赖:
   for (const id in deps) {
     const src = deps[id]
     discovered[id] = {
       id,
       file: getOptimizedDepPath(id, config, ssr),
       src,
       browserHash: browserHash,
       exportsData: extractExportsData(src, config, ssr),
     }
   }

对于每个依赖来说:

  • id: 保持原始依赖 ID
  • file: 使用 getOptimizedDepPath 获取优化后的依赖文件路径
  • src: 原始依赖的源文件路径
  • browserHash: 前面生成的浏览器哈希值
  • exportsData: 使用 extractExportsData 提取依赖的导出数据

模拟最后的生成结果:

{
  'react': {
    id: 'react',
    file: '/project/.vite/deps/react.js',
    src: '/node_modules/react/index.js',
    browserHash: 'a1b2c3d4',
    exportsData: {
      hasModuleSyntax: true,
      exports: ['useState', 'useEffect', 'Component'],
      jsxLoader: true
    }
  },
  'lodash': {
    id: 'lodash',
    file: '/project/.vite/deps/lodash.js',
    src: '/node_modules/lodash/lodash.js',
    browserHash: 'a1b2c3d4',
    exportsData: {
      hasModuleSyntax: false,
      exports: ['default'],
      jsxLoader: false
    }
  },
  '@vite/client': {
    id: '@vite/client',
    file: '/project/.vite/deps/_vite_client.js',
    src: '/node_modules/@vite/client/dist/client.js',
    browserHash: 'a1b2c3d4',
    exportsData: {
      hasModuleSyntax: true,
      exports: ['createHotContext', 'updateStyle'],
      jsxLoader: false
    }
  }
}

执行预构建

‍ ​runOptimizeDeps​正式开始处理构建流程。

总体流程:

export function runOptimizeDeps(
  resolvedConfig: ResolvedConfig,
  depsInfo: Record<string, OptimizedDepInfo>,
  ssr: boolean,
): {
  cancel: () => Promise<void>
  result: Promise<DepOptimizationResult>
} {
  // 初始化上下文和目录
  const optimizerContext = { cancelled: false }
  const depsCacheDir = getDepsCacheDir(resolvedConfig, ssr)
  const processingCacheDir = getProcessingDepsCacheDir(resolvedConfig, ssr)

  // 创建临时目录和 package.json
  fs.mkdirSync(processingCacheDir, { recursive: true })
  fs.writeFileSync(
    path.resolve(processingCacheDir, 'package.json'),
    `{\n  "type": "module"\n}\n`,
  )

  // 初始化元数据
  const metadata = initDepsOptimizerMetadata(config, ssr)

  // 准备 esbuild 优化运行
  const preparedRun = prepareEsbuildOptimizerRun(
    resolvedConfig,
    depsInfo,
    ssr,
    processingCacheDir,
    optimizerContext,
  )

  // 执行构建并处理结果
  const runResult = preparedRun.then(({ context, idToExports }) => {
    return context.rebuild().then((result) => {
      // 处理构建结果,更新元数据
      // ...

      return {
        metadata,
        cancel: cleanUp,
        commit: async () => {
          // 提交优化结果到缓存目录
          // ...
        },
      }
    })
  })

  // 错误处理和清理
  runResult.catch(() => {
    cleanUp()
  })

  // 返回结果
  return {
    cancel: async () => {
      optimizerContext.cancelled = true
      const { context } = await preparedRun
      await context?.cancel()
      cleanUp()
    },
    result: runResult,
  }
}

初始化:

  • 创建一个优化器上下文 optimizerContext。
  • 设置缓存目录和临时处理目录。

准备缓存目录:

  • 创建临时处理目录。
  • 在临时目录中创建 package.json 文件,标记为 ES 模块。

image.png

package.json文件中生成如下语句:

{
  "type": "module"
}

此临时目录并非最终产物,最后在 commit​ 时会被切换,

初始化元数据:

  • 创建 DepOptimizationMetadata​ 对象,包含各种哈希值和依赖信息。
export function initDepsOptimizerMetadata(
  config: ResolvedConfig,
  ssr: boolean,
  timestamp?: string,
): DepOptimizationMetadata {
  const { lockfileHash, configHash, hash } = getDepHash(config, ssr)
  return {
    hash,
    lockfileHash,
    configHash,
    browserHash: getOptimizedBrowserHash(hash, {}, timestamp),
    optimized: {},
    chunks: {},
    discovered: {},
    depInfoList: [],
  }
}

准备 esbuild 优化运行:

  • 调用 prepareEsbuildOptimizerRun​ 函数,设置 esbuild 的配置和插件。

async function prepareEsbuildOptimizerRun(
  resolvedConfig: ResolvedConfig,
  depsInfo: Record<string, OptimizedDepInfo>,
  ssr: boolean,
  processingCacheDir: string,
  optimizerContext: { cancelled: boolean },
): Promise<{
  context?: BuildContext
  idToExports: Record<string, ExportsData>
}> {
  const config: ResolvedConfig = {
    ...resolvedConfig,
    command: 'build',
  }

  
  const flatIdDeps: Record<string, string> = {}
  const idToExports: Record<string, ExportsData> = {}

  // 获取 optimizeDeps 配置 === config.optimizeDeps
  const optimizeDeps = getDepOptimizationConfig(config, ssr)

  const { plugins: pluginsFromConfig = [], ...esbuildOptions } =
    optimizeDeps?.esbuildOptions ?? {}

  await Promise.all(
    Object.keys(depsInfo).map(async (id) => {
      const src = depsInfo[id].src!
  
	  // ...

	  // 扁平化路径
      const flatId = flattenId(id)
      flatIdDeps[flatId] = src
      idToExports[id] = exportsData
    }),
  )

  // ...

  const external = [...(optimizeDeps?.exclude ?? [])]

  const plugins = [...pluginsFromConfig]
  if (external.length) {
    plugins.push(esbuildCjsExternalPlugin(external, platform))
  }
  plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr))

  // esbild 配置
  const context = await esbuild.context({
     absWorkingDir: process.cwd(),
     entryPoints: Object.keys(flatIdDeps),
     bundle: true,
     platform,
     define,
     format: 'esm',
     // ... 其他 esbuild 配置
     plugins,
   })
  return { context, idToExports }
}

由于各个第三方包的产物目录结构不一致,这种深层次的嵌套目录对于 Vite 路径解析来说,其实是增加了不少的麻烦的,带来了一些不可控的因素。

node_modules/.vite
├── _metadata.json
├── react
│   └── dist
│       └── jsx-dev-runtime.js

为了解决嵌套目录带来的问题,Vite 这样做来达到扁平化的预构建产物输出:

嵌套路径扁平化,/​被换成下划线,如 react/jsx-dev-runtime​,被重写为react_jsx-dev-runtime

const replaceSlashOrColonRE = /[/:]/g
const replaceDotRE = /\./g
const replaceNestedIdRE = /\s*>\s*/g
const replaceHashRE = /#/g
// 替换字符
export const flattenId = (id: string): string => {
  const flatId = limitFlattenIdLength(
    id
      .replace(replaceSlashOrColonRE, '_')
      .replace(replaceDotRE, '__')
      .replace(replaceNestedIdRE, '___')
      .replace(replaceHashRE, '____'),
  )
  return flatId
}

// 设置长度
const limitFlattenIdLength = (
  id: string,
  limit: number = FLATTEN_ID_MAX_FILE_LENGTH,
): string => {
  if (id.length <= limit) {
    return id
  }
  return id.slice(0, limit - (FLATTEN_ID_HASH_LENGTH + 1)) + '_' + getHash(id)
}

最终的产物路径的名称:

node_modules/.vite
├── _metadata.json
├── vue.js
├── react.js
├── react_jsx-dev-runtime.js

image.png

Esbuild 插件
  • esbuildDepPlugin

esbuildDepPlugin​ 是 Vite 中用于处理依赖优化的关键 esbuild 插件

export function esbuildDepPlugin(
  qualified: Record<string, string>,
  external: string[],
  config: ResolvedConfig,
  ssr: boolean,
): Plugin {

  // ...

  // 设置esm解析器
  const _resolve = config.createResolver({
    asSrc: false,
    scan: true,
    packageCache: esmPackageCache,
  })

  // 设置commonjs解析器
  const _resolveRequire = config.createResolver({
    asSrc: false,
    isRequire: true,
    scan: true,
    packageCache: cjsPackageCache,
  })

  return {
    name: 'vite:dep-pre-bundle',
    setup(build) {
      // 外部资源处理
      build.onResolve(
        {
          filter: new RegExp(
            `\\.(` + allExternalTypes.join('|') + `)(\\?.*)?$`,
          ),
        },
        async ({ path: id, importer, kind }) => {
          // ...
        },
      )

	  // 外部资源转换
      build.onLoad(
        { filter: /./, namespace: externalWithConversionNamespace },
        (args) => {
         // ...
        },
      )

	  // 主要模块解析
	  // 处理以字母、数字、下划线或 @ 开头的模块路径。
      build.onResolve(
        { filter: /^[\w@][^:]/ },
        async ({ path: id, importer, kind }) => {
         	// ...
        },
      )

	  // 浏览器兼容性处理
      build.onLoad(
        { filter: /.*/, namespace: 'browser-external' },
        ({ path }) => {
           // ...
        },
      )
	  // 可选对等依赖处理
      build.onLoad(
        { filter: /.*/, namespace: 'optional-peer-dep' },
        ({ path }) => {
          // ...
        },
      )
    },
  }
}

接下来就看看在插件内部是如何对不同的资源类型进行处理:

‍ 处理外部资源和非 JavaScript 文件类型

allExternalTypes​类型是一个非 js 文件集合:

const externalTypes = [
  'css',
  'less',
  'sass',
  'scss',
  'styl',
  'stylus',
  'pcss',
  'postcss',
  'wasm',
  'vue',
  'svelte',
  'marko',
  'astro',
  'imba',
  'jsx',
  'tsx',
  ...
]
{
	filter: new RegExp(
	   `\\.(` + allExternalTypes.join('|') + `)(\\?.*)?$`,
	),
}

过滤器匹配所有在 allExternalTypes​ 数组中定义的文件扩展名。这通常包括 CSS、图片、字体等非 JS 文件类型。 ‍

处理已转换的外部导入:

if (id.startsWith(convertedExternalPrefix)) {
     return {
       path: id.slice(convertedExternalPrefix.length),
       external: true,
     }
}

如果导入路径以特定前缀开头,说明它已经被转换为 import​,直接将其标记为外部模块。

path​取值为convertedExternalPrefix​标记后的真实路径。

const convertedExternalPrefix = 'vite-dep-pre-bundle-external:'

解析模块路径:

const resolved = await resolve(id, importer, kind)

使用 Vite 的解析器解析模块路径。

  • 处理解析结果:

如果路径成功解析:

a. 对于 require 调用:

if (kind === 'require-call') {
     if (resolved.endsWith('.js')) {
        return {
            path: resolved,
            external: false,
          }
     }
     return {
        path: resolved,
        namespace: externalWithConversionNamespace,
     }
}
  • 如果解析后的路径以 .js 结尾,不将其标记为外部模块。

  • 否则,将其放入特殊的命名空间 externalWithConversionNamespace​,用于后续的 require​ 到 import​ 的转换。

b. 对于其他情况:

return {
    path: resolved,
    external: true,
}

将解析后的路径标记为外部模块。

全部代码为:

build.onResolve(
        {
          filter: new RegExp(
            `\\.(` + allExternalTypes.join('|') + `)(\\?.*)?$`,
          ),
        },
        async ({ path: id, importer, kind }) => {
		  // 处理已经被标记的路径
          if (id.startsWith(convertedExternalPrefix)) {
            return {
              path: id.slice(convertedExternalPrefix.length),
              external: true,
            }
          }
		  // 解析
          const resolved = await resolve(id, importer, kind)
          if (resolved) {
			// require 调用
            if (kind === 'require-call') {
              // 导入 module.scss 路径,实际上就是 module.scss.js
              if (resolved.endsWith('.js')) {
                return {
                  path: resolved,
                  external: false,
                }
              }

              // 对 require 调用进行特殊处理,为后续的 CommonJS 到 ESM 的转换做准备。
              return {
                path: resolved,
                namespace: externalWithConversionNamespace,
              }
            }
			// 其他标记为外部模块 即 external: true
            return {
              path: resolved,
              external: true,
            }
          }
        },
      )

处理从 CommonJS 转换为 ESM 的外部模块。

在匹配非 js 类型的路径时,对于 commonjs 的路径我们将返回值 namespace​标记为externalWithConversionNamespace​,用于在onLoad​阶段进行处理。

   build.onLoad(
     { filter: /./, namespace: externalWithConversionNamespace },
     ...
   )

这个钩子会处理所有被标记为 externalWithConversionNamespace​ 命名空间的模块。这些通常是在前面的 onResolve​ 钩子中被识别为需要从 require 转换为 import 的模块。

模块路径处理:

   const modulePath = `"${convertedExternalPrefix}${args.path}"`

给模块路径添加一个特殊前缀 convertedExternalPrefix​。这个前缀用于在后续的解析过程中标识这个模块已经被转换过。

内容生成:

   return {
     contents:
       isCSSRequest(args.path) && !isModuleCSSRequest(args.path)
         ? `import ${modulePath};`
         : `export { default } from ${modulePath};` +
           `export * from ${modulePath};`,
     loader: 'js',
   }

这里根据模块类型生成不同的内容:

a. 对于 CSS 请求(非模块 CSS):

生成一个简单的 import 语句。这是因为 CSS 文件通常不需要导出任何内容,只需要被导入以触发其副作用(样式应用)。

b. 对于其他类型的模块:

生成两个导出语句:

  • export { default } from \${modulePath};​ 导出模块的默认导出。
  • export \* from \${modulePath};​ 导出模块的所有命名导出。

这种方式确保了无论原模块是如何导出其内容的,转换后的模块都能正确地重新导出这些内容。

==主要模块解析==

build.onResolve(
        { filter: /^[\w@][^:]/ },
        async ({ path: id, importer, kind }) => {
		  // 检查外部模块
          if (moduleListContains(external, id)) {
            return {
              path: id,
              external: true,
            }
          }

          let entry: { path: string } | undefined
 
          if (!importer) {
			// 尝试解析为入口文件
            if ((entry = resolveEntry(id))) return entry
    		// 如果直接解析失败,尝试通过别名解析,然后再次尝试解析为入口文件。
            const aliased = await _resolve(id, undefined, true)
            if (aliased && (entry = resolveEntry(aliased))) {
              return entry
            }
          }
		  // 使用 Vite 的解析器来解析模块路径
          const resolved = await resolve(id, importer, kind)
          if (resolved) {
            return resolveResult(id, resolved)
          }
        },
)


function resolveEntry(id: string) {
    const flatId = flattenId(id)
    if (flatId in qualified) {
        return {
            path: qualified[flatId],
        }
    }
}

过滤器设置: filter: /\^[\\w@][\^:]/

这个正则表达式匹配以字母、数字、下划线或 @ 开头,且第二个字符不是冒号的路径。这通常匹配大多数模块导入路径。

检查外部模块:

   if (moduleListContains(external, id)) {
     return {
       path: id,
       external: true,
     }
   }

如果模块 ID 在外部模块列表中,直接将其标记为外部模块。

处理入口文件:

   if (!importer) {
     if ((entry = resolveEntry(id))) return entry
     const aliased = await _resolve(id, undefined, true)
     if (aliased && (entry = resolveEntry(aliased))) {
       return entry
     }
   }
  • 如果没有 importer(即这是一个入口文件),尝试解析为入口文件。
  • 如果直接解析失败,尝试通过别名解析,然后再次尝试解析为入口文件。

使用 Vite 的解析器:

   const resolved = await resolve(id, importer, kind)
   if (resolved) {
     return resolveResult(id, resolved)
   }

使用 Vite 的解析器来解析模块路径。

  • 如果成功解析,通过 resolveResult​函数处理解析结果。

  const resolveResult = (id: string, resolved: string) => {
    if (resolved.startsWith(browserExternalId)) {
      return {
        path: id,
        namespace: 'browser-external',
      }
    }
    if (resolved.startsWith(optionalPeerDepId)) {
      return {
        path: resolved,
        namespace: 'optional-peer-dep',
      }
    }
    if (ssr && isBuiltin(resolved)) {
      return
    }
    if (isExternalUrl(resolved)) {
      return {
        path: resolved,
        external: true,
      }
    }
    return {
      path: path.resolve(resolved),
    }
  }

执行 esbuild 构建:

  • 使用 context.rebuild()​ 执行实际的构建过程。

处理构建结果:

  • 遍历构建输出,更新元数据中的依赖信息。
  • 处理优化后的依赖和生成的 chunk。

生成结果对象:

  • 创建包含元数据、取消和提交方法的结果对象。

清理和错误处理:

  • 设置清理函数,用于在取消或出错时删除临时目录。
  • 处理可能的错误,确保资源被正确清理。
let cleaned = false
let committed = false  

const cleanUp = () => {
    // 两个开关,分别控制是否处于清理中/写入中(commit函数中会被设置为true)
    if (!cleaned && !committed) {
      cleaned = true
   
      debug?.(colors.green(`removing cache dir ${processingCacheDir}`))
      try {
		// 删除临时目录
        fs.rmSync(processingCacheDir, { recursive: true, force: true })
      } catch (error) {

      }
    }
  }

返回结果:

  • 返回一个对象,包含取消方法和结果 Promise。
return {
    async cancel() {
      // ...
    },
    result: runResult,
}

缓存预构建结果

runOptimizeDeps​的执行结果会包含commit​函数,用来保存构建结果到本地目录中:

return {
    metadata,
    cancel: cleanUp,
    commit: async () => {

      committed = true

      const dataPath = path.join(processingCacheDir, METADATA_FILENAME)
  	  // 写入预构建结果
      fs.writeFileSync(
        dataPath,
        stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
      )

  
      const temporaryPath = depsCacheDir + getTempSuffix()
      const depsCacheDirPresent = fs.existsSync(depsCacheDir)
      if (isWindows) {

        // ...

      } else {
		// 切换到新的优化结果
        if (depsCacheDirPresent) {
          fs.renameSync(depsCacheDir, temporaryPath)
        }
  
        fs.renameSync(processingCacheDir, depsCacheDir)
      }

      // 删除旧的缓存目录
      if (depsCacheDirPresent) {
        fsp.rm(temporaryPath, { recursive: true, force: true })
      }
    },
  }

值得注意的是,在 commit​ 缓存文件时还会对构建时产生的临时文件进行切换并删除临时文件。

在临时目录中完成所有工作后,可以通过简单的重命名操作快速切换到新的优化结果,这比逐个文件复制更高效。

实现简易预构建流程

‍ ‍

image.png

创建开发服务器

创建了一个简单的目录文件:


├── bin                  
│   └── custom-vite               环境变量脚本文件      
├── package.json         
└── src                    		  源码目录

bin/custom-vite​ 与 package.json​ 中的 bin​ 字段进行关联:

{
  "name": "custom-vite",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  // 关联
  "bin": {
    "custom-vite": "bin/custom-vite"
  },
  // ...
}

然后在bin​中的custom-vite​文件中创建开发服务器:

#!/usr/bin/env node

import { createServer } from '../src/server/index.js';

(async function () {
  // 创建开发服务器
  const server = await createServer();
  server.listen('9999', () => {
    console.log('start server');
  });
})();

这样在本地项目中使用npm link​ 后在控制台执行 custom-vite​ 就会启动我们的开发服务器,打印出“start server”​。

bin​引入了一个 createServer​ 创建开发服务器的方法。

首先我们通过connect​模块配置 nodejs http​ 模块创建了一个支持中间件系统的应用服务。

通过 resolveConfig​ 方法来读取一些必要的配置属性:

import connect from 'connect';
import http from 'node:http';
import { staticMiddleware } from './middleware/staticMiddleware.js';
import resolveConfig from '../config.js';

export async function createServer() {
  const app = connect(); // 创建 connect 实例
  const config = await resolveConfig(); // 模拟读取配置清单
  app.use(staticMiddleware(config)); // 使用静态资源中间件

  const server = {
    async listen(port, callback) {
      // 执行预构建
      await runOptimizeDeps(config);

      // 启动服务
      http.createServer(app).listen(port, callback);
    }
  };
  return server;
}

resolveConfig​中定义一个项目根目录的返回:

async function resolveConfig() {
    const config = {
		// 项目根目录
        root: normalizePath(process.cwd()),
        // 增加一个 entryPoints 入口文件
        entryPoints: [path.resolve('index.html')]
    }

    return config
}

初始化配置文件后,我们再次调用 app.use(staticMiddleware(config));​ 为服务使用了静态资源目录的中间件,保证使用 custom-vite​ 的目录下的静态资源在服务上的可访问性。

import serveStatic from 'serve-static';

function staticMiddleware({ root }) {
  return serveStatic(root);
}

export default staticMiddleware;

解析HTML入口文件

接下来简单创建一个项目,启动项目,文件结构如下:

├── index.html
├── main.js
├── ./index.js
└── package.json

首先根据 index.html​ 中的脚本分析模块依赖,将所有项目中引入的第三方依赖(这里为 react​) 进行预构建。

将构建后的产物存储在 .vite/deps​ 目录中,同时将映射关系保存在 .vite/deps/_metadata.json​ 中,其中 optimized​ 对象中的 react​ 表示原始依赖的入口文件而 file​ 则表示经过预构建后生成的产物(两者皆为相对路径)。

我们在resolveConfig​函数中已经配置了解析的入口文件,表示 custom-vite​ 进行构建时的入口文件,即项目中的 index.html​ 文件:

entryPoints: [path.resolve('index.html')]

在读取完配置之后开始调用runOptimizeDeps​函数开始解析构建:

export async function createServer() {
  const app = connect(); // 创建 connect 实例
  const config = await resolveConfig(); // 模拟读取配置清单
 
  // ...

  const server = {
    async listen(port, callback) {
      // 启动服务之前的执行预构建
      await runOptimizeDeps(config);

      // ...
    }
  };
  return server;
}


async function runOptimize(config) {
  await createOptimizeDepsRun(config);
}

新建 /src/optimizer/index.js​ 文件:

async function createOptimizeDepsRun(config) {
   // 通过 scanImports 方法寻找项目中的所有需要预构建的模块
  const deps = await scanImports(config);
}

/src/optimizer/scan.js​ 的 scanImports​ 方法最后调用了 esbuild build​ 的 build 方法进行构建。

// src/optimizer/scan.js
import { build } from 'esbuild';
import { esbuildScanPlugin } from './scanPlugin.js';

async function scanImports(config) {
  // 保存扫描到的依赖
  const depImports = {};
  // 创建 Esbuild 扫描插件
  const scanPlugin = await esbuildScanPlugin();
  // 借助 EsBuild 进行依赖预构建
  await build({
    absWorkingDir: config.root, // esbuild 当前工作目录
    entryPoints: config.entryPoints, // 入口文件
    bundle: true, // 是否需要打包第三方依赖,默认 Esbuild 并不会,这里我们声明为 true 表示需要
    format: 'esm', // 打包后的格式为 esm
    write: false, // 不需要将打包的结果写入硬盘中
    plugins: [scanPlugin] // 自定义的 scan 插件
  });

  // ...

  return depImports;
}

esbuildScanPlugin​函数用于从入口文件开始寻找依赖模块,执行结果将会作为 esbuild 的插件执行。

import nodePath from 'path'
import fs from 'fs-extra'

const htmlTypesRe = /(\.html)$/;
const scriptModuleRe = /<script\s+type="module"\s+src\="(.+?)">/;

export function esbuildScanPlugin() {
    return {
        name: "ScanPlugin",
        setup(build) {
            // 引入时处理html文件
            build.onResolve({ filter: htmlTypesRe }, async ({ path, importer }) => {
                // 将传入的路径转化为绝对路径 使用 path.resolve 方法
                const resolved = await nodePath.resolve(path)
                if (resolved) {
                    return {
                        path: resolved?.id || resolved,
                        namespace: 'html'
                    }
                }
            })
            // 处理标记为 html 的文件
            build.onLoad({ filter: htmlTypesRe, namespace: 'html' }, async ({ path }) => {
                // 将 HTML 文件转化为 js 入口文件
                const htmlContent = fs.readFileSync(path, 'utf-8')
                // 寻找html中的script标签的引入

                const [, src] = htmlContent.match(scriptModuleRe)
				// 获取匹配到的 src 路径:/main.js
                console.log('匹配到的 src 内容', src);
				// 包装为import引入方式
                const jsContent = `import ${JSON.stringify(src)}`
                return {
                    contents: jsContent,
                    loader: 'js'
                };
            })
        }
    }
}

Esbuild 在进行构建时会对每一次 import​ 匹配插件的 build.onResolve​ 钩子,匹配的规则核心为两个参数,分别为:

  • filter​: 该字段可以传入一个正则表达式,Esbuild 会为每一次导入的路径与该正则进行匹配,如果一致则认为通过,否则则不会进行该钩子。
  • namespace​: 每个模块都有一个关联的命名空间,默认每个模块的命名空间为 file (表示文件系统),我们可以显示声明命名空间规则进行匹配。

上述的 scanPlugin​ 的核心思路为:

  1. 当运行 build​ 方法时,首先入口文件地址会进入 ScanPlugn​ 的 onResolve​ 钩子。

  2. 由于 filter​ 的正则匹配为后缀为 .html​,并不存在 namespace​(默认为 file​)。则此时,index.html​ 会进入 ScanPlugin​ 的 onResolve​ 钩子中。

  3. build.onResolve​ 中,我们先将传入的 path​ 转化为磁盘上的绝对路径,将 html 的绝对路径进行返回,同时修改入口 html 的 namespace​ 为自定义的 html​。

  4. build.onlod​ 钩子中,首先根据传入的 path​ 读取入口文件的 html​ 字符串内容获得 htmlContent​。

  5. 根据正则对于 htmlContent​ 进行了截取,获取 <script type="module" src="/main.js />"​ 中引入的 js 资源 /main.js​。

此时,Esbuil 会对于返回的 import "/main.js"​ 当作 JavaScript 文件进行递归处理,这样也就达成了我们解析 HTML 文件的目的。

解析 js/ts 文件

目前已经可以通过 HTML 文件寻找到引入的 /main.js​ 了,那么接下来自然我们需要对 js 文件进行递归分析寻找项目中需要被依赖预构建的所有模块。

对于 /main.js​ 的导入语句会分为以下两种情况分别进行不同的处理:

  • 对于 /main.js​ 中的导入的源码部分会进入该部分进行递归分析,比如 /main.js​ 中如果又引入了另一个源码模块 ./module.js​ 那么此时会继续进入 ./module.js​ 递归这一过程。
  • 对于 /main.js​ 中导入的第三方模块会通过 Esbuild 将该模块标记为 external ,从而记录该模块的入口文件地址以及导入的模块名。

比如 /main.js​ 中存在 import react from 'react'​,此时首先我们会通过 Esbuild 忽略进入该模块的扫描。同时我们也会记录代码中依赖的该模块相关信息。

// optimizer/scan.js

export async function scanImports(config) {
    const depImports = {}

++    const scanPlugin = await esbuildScanPlugin(config, depImports)

    await build({
        // ...
    })

++    return depImports;
}

scanImports​ 方法增加一个 depImports 的返回值。

esbuildScanPlugin​ 额外增加了一个 build.onResolve​ 来匹配任意路径文件。

对于入口的 html 文件,他会匹配我们最开始 filter​ 为 htmlTypesRe​ 的 onResolve 勾子来处理。而对于上一步我们从 html 文件中处理完成后的入口 js 文件(/main.js​),以及 /main.js​ 中的其他引入,比如 ./module.js​ 文件并不会匹配 htmlTypesRe​ 的 onResolve 钩子则会继续走到我们新增的 /.*/​ 的 onResolve 钩子匹配中。

import fs from 'fs-extra';
import { createPluginContainer } from './createPluginContainer.js';
import resolvePlugin from '../plugins/resolve.js';
const htmlTypesRe = /(\.html)$/;

++ const scriptModuleRe = /<script\s+type="module"\s+src\="(.+?)">/;

export async function esbuildScanPlugin(config, desImports) {
  // Vite 插件容器系统
++  const container = await createPluginContainer({
++    plugins: [resolvePlugin({ root: config.root })],
++    root: config.root
++  });

++  const resolveId = async (path, importer) => {
++    return await container.resolveId(path, importer);
++  };

  return {
    name: 'ScanPlugin',
    setup(build) {
      // 引入时处理 HTML 入口文件
      // ...

      // 增加一个 onResolve 方法来处理其他模块(非html,比如 js 引入)
      build.onResolve({ filter: /.*/ }, async ({ path, importer }) => {
        const resolved = await resolveId(path, importer);
        if (resolved) {
          const id = resolved.id || resolved;
          if (id.includes('node_modules')) {
            desImports[path] = id;
            return {
              path: id,
              external: true
            };
          }
          return {
            path: id
          };
        }
      });

      // 当加载命名空间为 html 的文件时
    
	  // ...
    }
  };
}

esbuildScanPlugin​ 会返回一个 Esbuild 插件,然后我们在 Esbuild 插件的 build.onResolve​ 钩子中实际调用的是 pluginContainer.resolveId​ 来处理。

// /plugins/resolve

import os from 'os'
import path from 'path'
import resolve from 'resolve'
import fs from 'fs'

const windowsDrivePathPrefixRE = /^[A-Za-z]:[/\\]/;

const isWindows = os.platform() === 'win32';
const bareImportRE = /^(?![a-zA-Z]:)[\w@](?!.*:\/\/)/;

function tryNodeResolve(id, importer, root) {
    const pkgDir = resolve.sync(`${id}/package.json`, {
        basedir: root
    })

    const pkg = JSON.parse(fs.readFileSync(pkgDir), 'utf-8')
    const entryPoint = pkg.module ?? pkg.main;
    const entryPointsPath = path.join(path.dirname(pkgDir), entryPoint);
    return {
        id: entryPointsPath
    };
}

function withTrailingSlash(path) {
    if (path[path.length - 1] !== '/') {
        return `${path}/`;
    }
    return path;
}

export const isNonDriveRelativeAbsolutePath = (p) => {
    if (!isWindows) return p[0] === '/';
    return windowsDrivePathPrefixRE.test(p);
};

// 寻找模块所在绝对路径
export default function resolvePlugin({ root }) {
    // 相对路径
    // window 下的 /
    // 绝对路径

    return {
        name: 'vite:resolvePlugin',
        async resolveId(id, importer) {
            // 如果是 / 开头的绝对路径,同时前缀并不是在该项目(root) 中,那么 vite 会将该路径当作绝对的 url 来处理(拼接项目所在前缀)
            // /foo -> /fs-root/foo
            if (id[0] === '/' && !id.startsWith(withTrailingSlash(root))) {
                const fsPath = path.resolve(root, id.slice(1));
                return fsPath;
            }

            // 相对路径 ./
            if (id.startsWith('.')) {
                const basedir = importer ? path.dirname(importer) : process.cwd();
                const fsPath = path.resolve(basedir, id);

                return {
                    id: fsPath
                };
            }


            if (isWindows && id.startsWith('/')) {
                // 同样为相对路径
                const basedir = importer ? path.dirname(importer) : process.cwd();
                const fsPath = path.resolve(basedir, id);
                return {
                    id: fsPath
                };
            }

            // 绝对路径
            if (isNonDriveRelativeAbsolutePath(id)) {
                return {
                    id
                };
            }

            // bar imports
            if (bareImportRE.test(id)) {
                // 寻找包所在的路径地址
                const res = tryNodeResolve(id, importer, root);
                return res;
            }

        }
    }
}

模拟创建vite的插件容器功能,其实就是对所有插件遍历调用。

// /optimizer/createPluginContainer.js

import { normalizePath } from '../utils'

export async function createPluginContainer({ plugins }) {
    const container = {
        async resolveId(path, importer) {
            let resolved = path
            for (const plugin of plugins) {
                if (plugin.resolveId) {
                    const result = await plugin.resolveId(resolved, importer);
                    if (result) {
                        resolved = result.id || result;
                        break;
                    }
                }
            }

            return {
                id: normalizePath(resolved)
            };
        }
    }

    return container
}

生成预构建产物

借助 Esbuild 以及 scanPlugin 我们已经可以在启动 Vite 服务之前完成依赖扫描获得源码中的所有第三方依赖模块。

接下来我们需要做的,正是对于刚刚获取到的 deps 对象中的第三方模块进行构建输出经过预构建后的文件以及一份 _metadata.json​ 文件。

首先修改src/config.js​ 配置文件,增加对缓存文件的支持:

async function resolveConfig() {
  const config = {
    root: normalizePath(process.cwd()),
	// 增加一个 cacheDir 目录
    cacheDir: findNearestPackageData(normalizePath(process.cwd())), 
    entryPoints: [path.resolve('index.html')]
  };
  return config;
}

findNearestPackageData​用于当生成预构建文件后的存储目录,这里我们固定写死为当前项目所在的 node_modules​ 下的 .custom-vite​ 目录。

function findNearestPackageData(basedir) {
  // 原始启动目录
  const originalBasedir = basedir;
  const pckDir = path.dirname(resolve.sync(`${originalBasedir}/package.json`));
  // 返回生成文件的固定目录路径
  return path.resolve(pckDir, 'node_modules', '.custom-vite');
}

src/optimizer/index.js​中进行修改:

// src/optimizer/index.js
import path from 'path';
import fs from 'fs-extra';
import { scanImports } from './scan.js';
import { build } from 'esbuild';

// 分析项目中的第三方依赖
async function createOptimizeDepsRun(config) {
  const deps = await scanImports(config);
  // 创建缓存目录
  const { cacheDir } = config;
  const depsCacheDir = path.resolve(cacheDir, 'deps');
  // 创建缓存对象 (_metaData.json)
  const metadata = {
    optimized: {}
  };
  for (const dep in deps) {
    // 获取需要被依赖预构建的目录
    const entry = deps[dep];
    metadata.optimized[dep] = {
      src: entry, // 依赖模块入口文件(相对路径)
      file: path.resolve(depsCacheDir, dep + '.js') // 预编译后的文件(绝对路径)
    };
  }
  // 将缓存文件写入文件系统中
  await fs.ensureDir(depsCacheDir);
  await fs.writeFile(
    path.resolve(depsCacheDir, '_metadata.json'),
    JSON.stringify(
      metadata,
      (key, value) => {
        if (key === 'file' || key === 'src') {
          // 注意写入的是相对路径
          return path.relative(depsCacheDir, value);
        }
        return value;
      },
      2
    )
  );
  // 依赖预构建
  await build({
    absWorkingDir: process.cwd(),
    define: {
      'process.env.NODE_ENV': '"development"'
    },
    entryPoints: Object.keys(deps),
    bundle: true,
    format: 'esm',
    splitting: true,
    write: true,
	// 输出缓存目录
    outdir: depsCacheDir
  });
}

export { createOptimizeDepsRun };

src/optimizer/index.js ​中,之前我们已经通过 scanImports​ 方法拿到了 deps 对象:

{
  react: '/Users/ccsa/Desktop/custom-vite-use/node_modules/react/index.js'
} 

我们从 config 对象中拿到了 depsCacheDir​ 拼接上 deps​ 目录,得到的是存储预构建资源的目录。

同时创建了一个名为 metadata​ 的对象,遍历生成的 deps 为 metadata.optimize​ 依次赋值,经过 for of 循环后所有需要经过依赖预构建的资源全部存储在 metadata.optimize​ 对象中,这个对象的结构如下:

// 模拟结果:
{
  optimized: {
    react: {
      src: "/Users/ccsa/Desktop/custom-vite-use/node_modules/react/index.js",
      file: "/Users/ccsa/Desktop/custom-vite-use/node_modules/.custom-vite/deps/react.js",
    },
  },
}

完成!✅

本文也参考了:juejin.cn/post/731043…

‍‍ ‍写在最后 ⛳

未来可能继续输出vite源码及编译相关的解析系列文章,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳 ‍ ‍ ‍ ‍