深度挖掘 Vite 配置文件解析的细节和核心要点

152 阅读6分钟

在阅读 Vite 官方文档时,配置方面整体阅读是非常顺畅的,但当时遗留了几个疑问,让我感到怪异和好奇,本以为随着对于 Vite 的深入使用,疑惑都会释然解开,但不曾想,日益加深,因此还是决定静下心来,从源码中找到答案。

阅读本文,你将学到:

  1. 掌握 Vite 配置文件解析的过程

  2. 收获 EsbuildVite 中的使用 +1

  3. 学会用户插件和环境变量的解析过程

  4. 了解 AOTJIT 两种编译技术的区别以及应用

流程梳理

Vite 配置文件整体还是特别复杂的,考虑的情形、边界条件很多,但是从大方向上可以划分为四个阶段:

  • 配置文件加载
  • 用户插件解析
  • 加载环境变量
  • 插件流水线生成

接下来,先进行源码前的一些准备工作:

准备工作

下载 vite 源码,使用 vite/playground/resolve 项目作为配置文件的调试项目,执行下列命令:

git clone git@github.com:vitejs/vite.git
cd playground/resolve
pnpm i

启动 VSCode 的调试功能,配置 launch.json 如下

  1. program 指向当前项目的 vite 命令(注意:vite 命令等同于 vite dev 和 vite serve
  2. cwd 指定 vite 命令执行的项目
  3. 若需要指定 node 环境,添加 "runtimeExecutable": "/Users/zcxiaobao/.nvm/versions/node/v22.13.1/bin/node" 配置
{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Debug Vite",
            "program": "${workspaceFolder}/packages/vite/bin/vite.js",
           
            "args": [], 
            "sourceMaps": true,
            "autoAttachChildProcesses": true,
            "cwd": "${workspaceFolder}/playground/resolve",
            "console": "integratedTerminal"
        }
    ]
}

packages/vite/dist/node/cli.js 中截图处打断点,启动调试。

Vite 配置文件解析由 resolveConfig 函数完成,也可以直接给该函数添加断点

配置文件 & 命令行配置

Vite 中,有两种情形的配置:配置文件和命令执行时传入配置,命令行中传入的配置优先级大于配置文件

其中配置文件支持 js、ts,同时兼容 esm 和 cjs,会有以下几种情形

const DEFAULT_CONFIG_FILES = [
  'vite.config.js',
  'vite.config.mjs',
  'vite.config.ts',
  'vite.config.cjs',
  'vite.config.mts',
  'vite.config.cts',
]

另外,Vite 脚手架是基于 commander 实现,命令行中传入的配置可以通过 action 的 options 进行获取。

除了 vite 命令定义的一些 option 配置外,还定义了一些公共的配置

配置文件加载

下面进入配置文件解析的核心逻辑,从下面的代码中可以发现,除非在命令行中传入 configFile = false,否则默认都会执行下面配置文件加载的逻辑。接下来重点关注一下 loadConfigFromFile 函数

// config 即为命令行传入的配置
let { configFile } = config
if (configFile !== false) {
  const loadResult = await loadConfigFromFile(
    configEnv,
    configFile,
    config.root,
    config.logLevel,
    config.customLogger,
    config.configLoader,
  )
  if (loadResult) {
    // 解析出配置文件内容后,与命令行传入配置进行合并
    config = mergeConfig(loadResult.config, config)
    configFile = loadResult.path
    configFileDependencies = loadResult.dependencies
  }
}

入口文件寻址

  1. 首先会寻找配置文件的 path,如果命令行传入 configFile,直接使用,否则逐个 DEFAULT_CONFIG_FILES 进行尝试
let resolvedPath: string | undefined
// 命令行传入 configFile 直接使用
if (configFile) {
  resolvedPath = path.resolve(configFile)
} else {
  // 逐个进行尝试
  for (const filename of DEFAULT_CONFIG_FILES) {
    const filePath = path.resolve(configRoot, filename)
    if (!fs.existsSync(filePath)) continue
    resolvedPath = filePath
    break
  }
}
  1. 加载配置文件的内容

命令行没有传入 configLoader 配置,取默认值 bundle,进入 bundleAndLoadConfigFile 函数进行配置文件内容加载。

配置文件 DEFAULT_CONFIG_FILES根据文件后缀和 js/ts 使用,可以划分为多种情形

  • TS + ESM 格式
  • TS + CJS 格式
  • JS + ESM 格式
  • JS + CJS 格式

因此需要首先判断配置文件使用的规范,着重关注 isFilePathESM 函数

const isESM =
    typeof process.versions.deno === 'string' || isFilePathESM(resolvedPath)

function isFilePathESM(
  filePath: string,
  packageCache?: PackageCache,
): boolean {
  // 以 mjs | mts 结尾,ESM 规范
  if (/.m[jt]s$/.test(filePath)) {
    return true
  } else if (/.c[jt]s$/.test(filePath)) {
    // 以 cjs | cts 结尾,CJS 规范
    return false
  } else {
    // check package.json for type: "module"
    try {
      // 从当前目录开始,逐级寻找 package.json
      const pkg = findNearestPackageData(path.dirname(filePath), packageCache)
      // 如果 package.json 中设定了 type = module,ESM 规范
      return pkg?.data.type === 'module'
    } catch {
      return false
    }
  }
}

Esbuild 打包入口文件

不知道有没有印象,在阅读官方文档的时候,在 vite 配置 中看到类似的注释:默认情况下,Vite 使用 esbuild 将配置文件打包到临时文件中并加载它,当时感到非常诧异,配置解析的过程就会有 esbuild 的身影吗?

还真是,当执行到 bundleConfigFile 后,存在一些熟悉的 Esbuild 配置,着重关注下面的内容

  • entryPoints,配置文件路径为入口
  • write: false,打包产物不写入磁盘中
  • format: isESM? 'esm' : 'cjs'

此外还定义了两个用于配置文件解析的 plugin,Esbuild 中 plugin 的使用请参考 Esbuild 插件基础知识

  1. externalize-deps plugin

filter: /^[^.#].*/ onResolve 钩子,匹配规则:模块路径第一个字母不能是 . 或 #

该钩子主要来处理配置文件中的依赖,为什么要这么做那?

配置文件可能会有很多依赖,有些是第三方依赖,有些是 Node 内置模块,还有可能项目中写的依赖,例如 resolve 项目,当项目中的依赖发生变化时,vite 会进行监听,通过 HMR 触发更新。

筛选项目依赖的流程如下

这里需要尤其注意一下,onResolve 钩子返回值,通常会有两个属性

  • path 为当前模块的路径,如果未返回,则 esbuild 会使用默认的路径解析逻辑
  • external 代表是否为外部模块,默认值 false,设置为 true 后,该模块为外部模块,将不会打包到产物中,由运行时环境处理
  • 判断是否为入口文件、内置模块或者已经是绝对路径,return 返回
if (
    kind === 'entry-point' ||
    path.isAbsolute(id) ||
    isNodeBuiltin(id)
  ) {
    return
  }
  • 判断是否为类node内置模块,返回 external: true
const nodeLikeBuiltins = [
  ...nodeBuiltins,
  new RegExp(`^${NODE_BUILTIN_NAMESPACE}`), // node:
  new RegExp(`^${NPM_BUILTIN_NAMESPACE}`),  // npm:
  new RegExp(`^${BUN_BUILTIN_NAMESPACE}`),  // bun:
]
  • 其他情形,通过 tryNodeResolve 方法寻找模块路径
const isImport = isESM || kind === 'dynamic-import'
const idFsPath = resolveByViteResolver(id, importer, !isImport)
return {
  path: idFsPath,
  external: true,
}
  1. inject-file-scope-variables plugin

filter: /.[cm]?[jt]s$/

该钩子相对功能比较简单,为类 js 文件注入 dirnameVarName 、filenameVarName和 importMetaUrlVarName 变量

build.onLoad({ filter: /.[cm]?[jt]s$/ }, async (args) => {
  const contents = await fsp.readFile(args.path, 'utf-8')
  const injectValues =
    `const ${dirnameVarName} = ${JSON.stringify(
      path.dirname(args.path),
    )};` +
    `const ${filenameVarName} = ${JSON.stringify(args.path)};` +
    `const ${importMetaUrlVarName} = ${JSON.stringify(
      pathToFileURL(args.path).href,
    )};`

  return {
    loader: args.path.endsWith('ts') ? 'ts' : 'js',
    contents: injectValues + contents,
  }
})

const dirnameVarName = '__vite_injected_original_dirname'
const filenameVarName = '__vite_injected_original_filename'
const importMetaUrlVarName = '__vite_injected_original_import_meta_url

对于当前的 resolve 项目,打包结果如下:

  • 其中 bundle.code 的内容会作为临时产物存放在 node_module/.vite-temp 下,有兴趣可以去自己看一下
  • bundle.dependencies 代表配置文件的依赖项,最终会存放到 configFileDependencies 中,vite 监听到该属性下文件变化后,会触发 HMR,刷新页面,保证页面运行处于最新的配置下

获取详细配置

过程发生在 loadConfigFromBundledFile 函数

该函数处理逻辑非常有意思,值得深究一下。

对于 ESM 规范,会先将 bundle.code 写入临时文件中,然后借助 esm import 进行动态导入,读取临时文件内容,获取到配置内容,再删除临时文件

这种先编译配置文件,再将产物写入临时目录,最后加载临时目录产物的做法,也被称作 AOT (Ahead Of Time)编译技术。

单步调试的时候,可以多关注,node_module/.vite_temp 文件夹 TLFeUnezJp

const hash = `timestamp-${Date.now()}-${Math.random().toString(16).slice(2)}`
const tempFileName = nodeModulesDir
  ? path.resolve(
      nodeModulesDir,
      `.vite-temp/${path.basename(fileName)}.${hash}.mjs`,
    )
  : `${fileName}.${hash}.mjs`
// 写入配置文件
await fsp.writeFile(tempFileName, bundledCode)
try {
  // 加载配置信息
  return (await import(pathToFileURL(tempFileName).href)).default
} finally {
  fs.unlink(tempFileName, () => {}) // Ignore errors
}

对于 CJS 规范,通过拦截原生的 require.extensions 的加载函数来实现对 bundle 配置的加载。

async function loadConfigFromBundledFile(
  fileName: string,
  bundledCode: string
): Promise<UserConfig> {
  const extension = path.extname(fileName)
  
  // 默认加载器
  const defaultLoader = require.extensions[extension]!
  require.extensions[extension] = (module: NodeModule, filename: string) => {
    // 针对于 vite 配置文件的加载特殊处理
    if (filename === fileName) {
      ;(module as NodeModuleWithCompile)._compile(bundledCode, filename)
    } else {
      defaultLoader(module, filename)
    }
  }
  // 清除 require 缓存
  delete require.cache[require.resolve(fileName)]
  const raw = require(fileName)
  const config = raw.__esModule ? raw.default : raw
  require.extensions[extension] = defaultLoader
  return config
}

原生 require.extensions['js'] 处理思路是先读取 文件内容,然后进行模块编译,本质上等同下面的形式,想要更深入的了解,可以看深入解析require源码,知其根,洞其源

;(function (exports, require, module, __filename, __dirname) {
  // 执行 module._compile 方法中传入的代码
  // 返回 exports 对象
})

module._compile编译配置代码后,再执行一次 require,就可以获取到配置信息。

CJS 规范是在运行时加载 TS 配置,这种被称作 JIT(即时编译),与 AOT 最大的区别在于不会将内存中计算出来的 js 代码写入磁盘再加载,而是通过拦截 Node.js 原生 require.extension 方法实现即时加载。

解析用户插件

对于用户插件的处理,主要有下面几个过程

  1. Vite 插件支持 apply 参数指定插件生效环境,例如 build 或者 serve,更进一步的可以配置为函数,来自定义插件生效条件,因此需要根据 apply 参数过滤出当前需要生效的插件。
const filterPlugin = (p: Plugin | FalsyPlugin): p is Plugin => {
    if (!p) {
      return false
    } else if (!p.apply) {
      return true
    } else if (typeof p.apply === 'function') {
      // apply 为函数
      return p.apply({ ...config, mode }, configEnv)
    } else {
      // 根据运行环境筛选插件
      return p.apply === command
    }
  }
  1. 根据 enforce 属性,筛选出 pre、normal 和 post 三类插件
const rawPlugins = (await asyncFlatten(config.plugins || [])).filter(
  filterPlugin,
)
// 根据 enforce 进行筛选
const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawPlugins)

const isBuild = command === 'build'
  1. 依次调用插件的 config 钩子,进行配置合并

mergeConfig 方法负责完成配置合并,有兴趣的可以阅读一下

const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
config = await runConfigHook(config, userPlugins, configEnv)

async function runConfigHook(
  config: InlineConfig,
  plugins: Plugin[],
  configEnv: ConfigEnv,
): Promise<InlineConfig> {
  let conf = config
  
  for (const p of getSortedPluginsByHook('config', plugins)) {
    const hook = p.config
    // 获取 config 钩子
    const handler = getHookHandler(hook)
    const res = await handler(conf, configEnv)
    if (res && res !== conf) {
      // 配置合并
      conf = mergeConfig(conf, res)
    }
  }

  return conf
}

加载环境变量

在阅读 vite 官方文档时,当时还看到过一个非常疑惑的点:

源码读到这里,未知开始才逐渐变成已知,环境变量读入过程时间线发生在配置文件加载之后,因此如果需要在配置文件中加载环境变量,需要执行 loadEnv 函数,该函数正是包含了整个环境变量加载的核心流程。

loadEnv 需要特别注意一下第三个参数,接受一个字符串前缀,用于筛选加载的环境变量

// 获取到根目录
const envDir = config.envDir
    ? normalizePath(path.resolve(resolvedRoot, config.envDir))
    : resolvedRoot
// 配置文件加载
const userEnv =
inlineConfig.envFile !== false &&
loadEnv(mode, envDir, resolveEnvPrefix(config))

loadEnv 函数的具体处理思路如下

  • 搜索环境变量配置文件,如果存在,读出内容。
[
  /** default file */ `.env`,
  /** local file */ `.env.local`,
  /** mode file */ `.env.${mode}`,
  /** mode local file */ `.env.${mode}.local`,
]

实现思路并不难,但是 Vite 还是把我惊叹到了,是这么实现的:首先判断环境变量文件是否存在,存在直接解析出内容;不存在,返回一个空数组;最后将所有文件的解析结果进行 flatten。nice 思路收获 +1。

const parsed = Object.fromEntries(
  envFiles.flatMap((filePath) => {
    if (!tryStatSync(filePath)?.isFile()) return []

    return Object.entries(parse(fs.readFileSync(filePath)))
  }),
)

注意会有一个特殊情形,如果 .env 文件中配置了 NODE_ENV 属性,则先挂到 process.env.VITE_USER_NODE_ENV,Vite 会优先通过这个属性来决定是否走生产环境的构建。

// 避免环境变量中的 NODE_ENV 被 process.env.NODE_ENV 覆盖
if (parsed.NODE_ENV && process.env.VITE_USER_NODE_ENV === undefined) {
  process.env.VITE_USER_NODE_ENV = parsed.NODE_ENV
}

// 根据 VITE_USER_NODE_ENV 环境变量决定环境
const userNodeEnv = process.env.VITE_USER_NODE_ENV
  if (!isNodeEnvSet && userNodeEnv) {
    if (userNodeEnv === 'development') {
      process.env.NODE_ENV = 'development'
    } else {
      // NODE_ENV=production is not supported as it could break HMR in dev for frameworks like Vue
      logger.warn(
        `NODE_ENV=${userNodeEnv} is not supported in the .env file. ` +
          `Only NODE_ENV=development is supported to create a development build of your project. ` +
          `If you need to set process.env.NODE_ENV, you can set it in the Vite config instead.`,
      )
    }
  }
  • 扫描 process.env 和 .env,提取指定前缀开头的属性(默认指定为 VITE_),写入 env 对象中,值得注意的是,env 对象最终会挂载到import.meta.env 这个全局对象上

生成插件流水线

插件流水线的细节非常多,后面会写一篇单独的文章进行讲解。

这里暂时只需要知道一些表现,通过 resolvePlugins 函数生成完整的插件列表,然后会调用每个插件的 configResolved 钩子函数。

const resolvedPlugins = await resolvePlugins(
  resolved,
  prePlugins,
  normalPlugins,
  postPlugins,
)
;(resolved.plugins as Plugin[]) = resolvedPlugins

await Promise.all(
  resolved
    .getSortedPluginHooks('configResolved')
    .map((hook) => hook(resolved)),
)

总结

本文围绕源码细节的解析了 Vite 的配置文件解析过程,对于一些比较细节或不太重要的属性(例如 baseUrl,ssr 环境等)进行了简略,核心针对于配置文件加载中 Esbuild 的使用、AOTJIT 编译技术应用和环境变、用户插件的处理,希望你能有所收获。