初探 Vite 秒级预构建实现

0 阅读9分钟

什么是预构建

文章中解读的 vite 版本为 6.2.1

依赖预构建的官方文档:cn.vite.dev/guide/dep-p…

新建一个 vite 项目,选择 vue 或 react 都可,安装完依赖后,执行 pnpm run dev 启动项目。

项目启动完成后,打开 node_modules 目录,可以看到多了一个 .vite 文件夹,内部包含项目引入的 vue 和 vuex 等第三方依赖和 _metadata.json 文件。

此时打开控制台的 Network 调试板块,可以看到第三方依赖的引入路径也发生了修改,以 vue 为例,引入路径为 node_modules/.vite/deps/vue.js

对于依赖的请求结果,Vite 的本地服务器设置了 Cache-Control 时间为一年的强缓存。

接着打开 _metadata.json 文件,可以发现里面记录了各个第三方依赖的 hash 和路径等关键信息。

上面就是预构建的产物,vite 在首次启动时,会对项目进行扫描,将使用到第三方依赖进行预构建,构建后的产物存储到 node_modules/.vite/deps 中,同时生成 _metadata.json 记录预构建产物的路径映射关系和文件 hash。

注意:可能很多朋友会好奇,vite 不是 no-module 模式吗?预构建中怎么出现了 boundle 的概念。vite 中的 no-boundle 的针对的是项目的源代码,对于第三方依赖,vite 同样选择了打包,并且选用 go 编写的,速度极快的 esbuild 来完成,实现近乎秒级的依赖预构建。

为什么要进行预构建

那为什么需要预构建那?在上面的过程中,粗略解释了原因:对项目中使用的第三方依赖进行缓存,如果再启动项目,无需再次构建,提升开发效率。

上述浅层的理解,有一定的道理,但不够深刻,可以从两个方面分析这个问题:

其一,vite 的 no-bundle 是基于浏览器的 ESM 实现的,这也就意味着使用 vite 的项目必须要严格符合 ESM 规范,这其中不止包含应用代码,还包含第三方依赖。在当下的前端生态中,践行 ESM 规范的第三方依赖逐渐增多,但总是会存在一些守旧派,例如前端三大框架之一 react,就只提供了 CommonJS 产物。因此在开发阶段,就需要采用预构建的方式,将非 ESM 规范依赖转换成 ESM 规范。

其二,为了提升页面加载性能,应对诸如请求瀑布流问题。例如 lodash-es 库,为了支持 ESM 规范,将原有的CommonJS 拆解为 600 多个模块,当执行 import { debounce } from 'lodash-es',浏览器会同时发出 600 多个 HTTP 请求,会导致页面加载的前几秒处于卡顿状态。通过依赖预构建,lodash-es 会被构建为单个模块,只需要一次 HTTP 请求,页面加载速度相应就会变快很多。

最近,小包认真阅读了 vite 预构建的源码,阅读完后,感觉受益匪浅,精读预构建的源码,不止会增加对于 Vite 的熟悉程度,还能更进一步熟悉 Esbuild 的一些巧妙使用。

整个源码实现可以粗略划分为两个阶段:依赖扫描和依赖打包,都是通过 Esbuild 实现的,是的你没有看错,在预构建阶段 Esbuild 被使用了两次,都非常的巧妙。源码解读文章会非常细节和丰富,这里小包拆解为 liang篇来讲解,本篇主要讲解 OptimizeDeps 参数(途中会涉及一些简单的源码)和源码阅读 & 调试的一些准备工作。

文章中涉及的源码,为了方便理解,对于复杂的边界情形处理和与当前代码弱相关的逻辑,进行了精简处理,不耽误源码的理解和阅读。

源码调试准备

推荐直接下载 vite 的源码,源码中提供了 playgrounds 目录,vite 官方提前写好各种情形下的项目案例,可直接用于调试

项目结构

Vite 采用 monorepo 模式进行管理,packages 中存放使用的各个子依赖,create-vite 创建项目模版脚手架,plugin-legacy 是vite封装的一套语法降级方案,vite 为核心目录。

前端在阅读源码时,首先要重点关注 package.json 文件,尤其是里面的 bin、main 和 script 字段。

其中 bin 字段定义了模块中可执行文件的路径;main 字段定义了模块的主入口文件。当模块被其他项目作为依赖项安装时,main 指定的文件会被加载。

{
  "bin": {
    "vite": "bin/vite.js"
  },
  "main": "./dist/node/index.js",
  "scripts": {
    "dev": "tsx scripts/dev.ts"
  },
}

launch.json 配置

预构建调试选择 playgroud/optimize-deps-no-discovery 项目

使用 vscode 中 调试工具,生成 launch.json,写入如下配置

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Debug Vite",
            "program": "${workspaceFolder}/packages/vite/bin/vite.js",
            "runtimeExecutable": "/Users/zcxiaobao/.nvm/versions/node/v22.13.1/bin/node",
            "args": [], 
            "sourceMaps": true,
            "autoAttachChildProcesses": true,
            "cwd": "${workspaceFolder}/playground/optimize-deps-no-discovery",
            "console": "integratedTerminal"
        }
    ]
}
  • runtimeExecutable:如果当前使用的 npm 版本支持 vite,无需该设置
  • cwd:配置调试执行目录,这里选用 optimize-deps-no-discovery 项目
  • program:配置运行程序为 vite.js

打开 optimize-deps-no-discovery 目录中的 vue.config.js,先注释掉 noDiscovery 属性,下文会解释原因

打开 index.html,找到 type=module 中的 script 标签,当前项目引入了 vue、vuex 和 @vitejs/test-dep-no-discovery 三个第三方依赖。

Vite 执行入口

预构建的代码都在 optimizer 文件夹中,可以预先去 optimizer 文件夹下的 index 文件设置断点

预构建发生在开发模式中,也就是当执行 vite dev 或者 vite 命令时,预构建会触发。这种情形下,vite 作为命令被使用,定位到 bin 字段,找到入口文件 bin/vite.js

打开该文件,可以找到vite 入口 start 执行函数,该函数引入了 cli.js,cli 中定义 vite 中的各个命令的详细参数。

function start() {
  try {
    module.enableCompileCache?.()
  } catch { }
  return import('../dist/node/cli.js')
}

找到 dev 命令,在本地开发时,vite 会创建一个本地服务器,并启动监听,打断点,便可以进行调试了。

// 对源码进行了简略,只保留核心,不影响源码理解
cli
  .command('[root]', 'start dev server') // default command
  .alias('serve') // the command is called 'serve' in Vite's API
  .alias('dev') // alias to align with the script name
  .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
    filterDuplicateOptions(options)
    // output structure is preserved even after bundling so require()
    // is ok here
    const { createServer } = await import('./server')
    try {
      const server = await createServer({
        root,
        base: options.base,
        mode: options.mode,
        configFile: options.config,
        configLoader: options.configLoader,
        logLevel: options.logLevel,
        clearScreen: options.clearScreen,
        server: cleanGlobalCLIOptions(options),
        forceOptimizeDeps: options.force,
      })

      await server.listen()
   }

Vscode 中移除所有断点:Command + Shift + P,输入 Remove All Breakpoints

optimizeDeps

Vite 中可以通过 optimizeDeps 配置来控制依赖预构建过程,推荐看一下 playgroud/optimize-deps 项目的设置

entry

定义依赖预构建的入口

有三种方式可以定义依赖预构建的入口文件,分别为

  1. optimizeDeps.entries
  2. build.rollupOptions.input
  3. index.html(忽略node_modulesbuild.outDirtestscoverage
  4. 优先级从上往下降低

vite 源码中通过 computeEntries 函数来进行入口文件的搜寻

async function computeEntries(environment: ScanEnvironment) {
  let entries: string[] = []

  const explicitEntryPatterns = environment.config.optimizeDeps.entries
  const buildInput = environment.config.build.rollupOptions.input
  if (explicitEntryPatterns) { // 判断是否存在 optimizeDeps.entries
    entries = await globEntries(explicitEntryPatterns, environment)
  } else if (buildInput) { // 判断是否存在 build.rollupOptions.input
    const resolvePath = async (p: string) => {
      const id = (
        await environment.pluginContainer.resolveId(p, undefined, {
          scan: true,
        })
      )?.id
      return id
    }
    // rollupOptions.input 可能为 string、array和 object,分别进行处理
    if (typeof buildInput === 'string') {
      entries = [await resolvePath(buildInput)]
    } else if (Array.isArray(buildInput)) {
      entries = await Promise.all(buildInput.map(resolvePath))
    } else if (isObject(buildInput)) {
      entries = await Promise.all(Object.values(buildInput).map(resolvePath))
    } else {
      throw new Error('invalid rollupOptions.input value.')
    }
  } else {
    // 上述都没有找到,扫描全局的 index.html
    entries = await globEntries('**/*.html', environment)
  }

  // 过滤掉不支持的入口文件类型
  entries = entries.filter(
    (entry) =>
      isScannable(entry, environment.config.optimizeDeps.extensions) &&
      fs.existsSync(entry),
  )

  return entries
}

不满足前两种情形,则使用 globEntries 函数扫描当前项目,扫描时过滤掉 node_modules, dist 等干扰项,确保扫描到项目的入口文件

function globEntries(pattern: string | string[], environment: ScanEnvironment) {
  return glob(pattern, {
    absolute: true,
    cwd: environment.config.root,
    ignore: [
      '**/node_modules/**',
      `**/${environment.config.build.outDir}/**`, // dist
      // if there aren't explicit entries, also ignore other common folders
      ...(environment.config.optimizeDeps.entries // entries
        ? []
        : [`**/__tests__/**`, `**/coverage/**`]),
    ],
  })
}

include

Vite 默认只会对 node_modules 中涉及的第三方依赖进行预构建,include 可以设定任意的依赖进行预构建。

在 optimize-deps-no-discovery 项目中,@vitejs/test-dep-no-discovery为项目中定义的依赖,便可以通过 include 强制进行预构建。

optimizeDeps: {
    include: ['@vitejs/test-dep-no-discovery'],
 },

Vite 在进行第三方依赖的扫描之前,会首先对 include 中依赖进行处理,具体代码如下:

const manuallyIncludedDeps: Record<string, string> = {}
// 获取 include 中的依赖
await addManuallyIncludedOptimizeDeps(environment, manuallyIncludedDeps)

const manuallyIncludedDepsInfo = toDiscoveredDependencies(
  environment,
  manuallyIncludedDeps,
  sessionTimestamp,
)

for (const depInfo of Object.values(manuallyIncludedDepsInfo)) {
  addOptimizedDepInfo(metadata, 'discovered', {
    ...depInfo,
    processing: depOptimizationProcessing.promise,
  })
  newDepsDiscovered = true
}

exclude

强制排除预构建的依赖项

需要注意的是,Commonjs 规范的依赖不能排除。还有一种比较特殊的情形,排除某个 ESM 规范依赖,但该ESM却依赖某 Commonjs 依赖,此时需要将当前 Commonjs 添加到 optimizeDeps.include 属性中

具体场景可见 playground/optimize-deps 项目

force

设置为 true 后,可以强制进行依赖预构建

设置 force 为 true,vite 读取缓存的 loadCachedDepOptimizationMetadata 函数,会直接将 node_modules/.vite 直接清空,实现强制预构建,具体如下:

function loadCachedDepOptimizationMetadata(
  environment: Environment,
  force = environment.config.optimizeDeps.force ?? false,
  asCommand = false,
): Promise<DepOptimizationMetadata | undefined> {
  // 获取缓存目录
  const depsCacheDir = getDepsCacheDir(environment)

  if (!force) {
    // 返回缓存
  } else {
    environment.logger.info('Forced re-optimization of dependencies', {
      timestamp: true,
    })
  }

  // 清空 node_modules/.vite 文件夹
  await fsp.rm(depsCacheDir, { recursive: true, force: true })
}

noDiscovery

禁用依赖预构建,替代了原有的 disabled 属性

根据是否设置 noDiscovery,vite 定义了 createExplicitDepsOptimizercreateDepsOptimizer 类,createExplicitDepsOptimizer 核心为 optimizeExplicitEnvironmentDeps 方法,从中可以发现几个隐藏的业务逻辑:

  1. 如果项目最开始启动时执行过预构建,后续再设置了 noDiscovery: true,默认还是返回缓存,除非使用了 force 参数
  2. 即使设置 noDiscovery: true,vite 还是会对 optimizeDeps.include 值进行依赖预加载,因此要想完全的禁用依赖预加载,设置 noDiscovery: true 的同时要保证 optimizeDeps.include 未定义或为空。
export function createExplicitDepsOptimizer(
  environment: DevEnvironment,
): DepsOptimizer {
  let inited = false
  // 预构建入口 init 方法
  async function init() {
    if (inited) return
    inited = true
    depsOptimizer.metadata = await optimizeExplicitEnvironmentDeps(environment)
  }

  return depsOptimizer
}

function optimizeExplicitEnvironmentDeps(
  environment: Environment,
): Promise<DepOptimizationMetadata> {
  // 检测是否有缓存
  const cachedMetadata = await loadCachedDepOptimizationMetadata(
    environment,
    environment.config.optimizeDeps.force ?? false,
    false,
  )
  // 有缓存,返回缓存
  if (cachedMetadata) {
    return cachedMetadata
  }

  const deps: Record<string, string> = {}
  // 扫描 optimizeDep.include
  await addManuallyIncludedOptimizeDeps(environment, deps)

  const depsInfo = toDiscoveredDependencies(environment, deps)
  // 执行预构建
  const result = await runOptimizeDeps(environment, depsInfo).result

  await result.commit()

  return result.metadata
}

esbuildOptions

可以为预构建过程中esbuild传递一些配置,场景主要是加入一些 Esbuild plugins

// vite.config.ts
{
  optimizeDeps: {
    esbuildOptions: {
       plugins: [
        // 加入 Esbuild 插件
      ];
    }
  }
}

总结

在这一节中,主要讲解了 Vite 的预构建技术,以及结合部分源码,讲解了常用预构建配置的作用和实现。

Vite 的依赖预构建技术主要解决了两个问题,依赖的规范兼容问题和请求瀑布流问题,提升本地开发的性能和速度。通过删除 .vite 缓存和 force 属性,可以强制实现依赖预构建。

接着讲解了 vite 源码的项目结构和调试方法,推荐使用 playground 中的项目进行调试和学习。

最后学习了预构建的相关配置——entries、include、exclude、force等,并且详细介绍了 include 的属性的执行逻辑和使用场景,noDiscovery 属性表现也需要重点关注一番。