初探 Vite 秒级预构建实现

什么是预构建

文章中解读的 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 指定的文件会被加载。

json 复制代码
{
  "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,写入如下配置

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 中的各个命令的详细参数。

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

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

ts 复制代码
// 对源码进行了简略,只保留核心,不影响源码理解
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 函数来进行入口文件的搜寻

ts 复制代码
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 等干扰项,确保扫描到项目的入口文件

ts 复制代码
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 强制进行预构建。

ts 复制代码
optimizeDeps: {
    include: ['@vitejs/test-dep-no-discovery'],
 },

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

ts 复制代码
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 直接清空,实现强制预构建,具体如下:

ts 复制代码
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 未定义或为空。
ts 复制代码
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

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

总结

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

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

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

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

相关推荐
GISer_Jing3 分钟前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪1 小时前
CSS复习
前端·css
咖啡の猫3 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲5 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5816 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路6 小时前
GeoTools 读取影像元数据
前端
ssshooter6 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry7 小时前
Jetpack Compose 中的状态
前端
dae bal8 小时前
关于RSA和AES加密
前端·vue.js
柳杉8 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化