骚年,是时候深入了解一下 Vite 的配置解析服务了

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

阅读本文,你将学到:

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

  2. 收获 EsbuildVite 中的使用 +1

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

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

流程梳理

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

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

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

准备工作

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

bash 复制代码
git clone [email protected]: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" 配置
json 复制代码
{
    "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,会有以下几种情形

js 复制代码
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 函数

ts 复制代码
// 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 进行尝试
ts 复制代码
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 函数

ts 复制代码
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 返回
ts 复制代码
if (
    kind === 'entry-point' ||
    path.isAbsolute(id) ||
    isNodeBuiltin(id)
  ) {
    return
  }
  • 判断是否为类node内置模块,返回 external: true
ts 复制代码
const nodeLikeBuiltins = [
  ...nodeBuiltins,
  new RegExp(`^${NODE_BUILTIN_NAMESPACE}`), // node:
  new RegExp(`^${NPM_BUILTIN_NAMESPACE}`),  // npm:
  new RegExp(`^${BUN_BUILTIN_NAMESPACE}`),  // bun:
]
  • 其他情形,通过 tryNodeResolve 方法寻找模块路径
ts 复制代码
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 变量

ts 复制代码
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

ts 复制代码
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 配置的加载。

ts 复制代码
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源码,知其根,洞其源

ts 复制代码
;(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 参数过滤出当前需要生效的插件。
ts 复制代码
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 三类插件
ts 复制代码
const rawPlugins = (await asyncFlatten(config.plugins || [])).filter(
  filterPlugin,
)
// 根据 enforce 进行筛选
const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawPlugins)

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

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

ts 复制代码
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 需要特别注意一下第三个参数,接受一个字符串前缀,用于筛选加载的环境变量

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

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

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

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

ts 复制代码
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 会优先通过这个属性来决定是否走生产环境的构建。

ts 复制代码
// 避免环境变量中的 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 钩子函数。

ts 复制代码
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 编译技术应用和环境变、用户插件的处理,希望你能有所收获。

相关推荐
blzlh13 分钟前
春招面试万字整理,全程拷打,干货满满(2)
前端·vue.js·面试
hollyhuang13 分钟前
div元素滚动,子元素出现跳动,怎么解决?
前端·css
崔璨18 分钟前
实现一个精简React -- 实现useEffect(10)
前端·react.js
Au_ust29 分钟前
React类的生命周期
前端·react.js·前端框架
Georgewu1 小时前
【HarmonyOS Next】鸿蒙中自定义弹框OpenCustomDialog、CustomDialog与DialogHub的区别详解
前端·华为·harmonyos
11在上班1 小时前
剖析initData在水合中的设计哲学
前端·设计模式
TitusTong1 小时前
使用 <think> 标签解析 DeepSeek 模型的推理过程
前端·ollama·deepseek
Hsuna1 小时前
一句配置让你的小程序自动适应Pad端
前端·javascript
curdcv_po1 小时前
Vue3移动电商实战 —— 外卖移动端轮播图实现
前端