在阅读 Vite
官方文档时,配置方面整体阅读是非常顺畅的,但当时遗留了几个疑问,让我感到怪异和好奇,本以为随着对于 Vite
的深入使用,疑惑都会释然解开,但不曾想,日益加深,因此还是决定静下心来,从源码中找到答案。
阅读本文,你将学到:
-
掌握
Vite
配置文件解析的过程 -
收获
Esbuild
在Vite
中的使用 +1 -
学会用户插件和环境变量的解析过程
-
了解
AOT
和JIT
两种编译技术的区别以及应用
流程梳理
Vite 配置文件整体还是特别复杂的,考虑的情形、边界条件很多,但是从大方向上可以划分为四个阶段:
- 配置文件加载
- 用户插件解析
- 加载环境变量
- 插件流水线生成
接下来,先进行源码前的一些准备工作:
准备工作
下载 vite 源码,使用 vite/playground/resolve
项目作为配置文件的调试项目,执行下列命令:
bash
git clone [email protected]:vitejs/vite.git
cd playground/resolve
pnpm i
启动 VSCode
的调试功能,配置 launch.json
如下
- program 指向当前项目的 vite 命令(注意:vite 命令等同于 vite dev 和 vite serve)
- cwd 指定 vite 命令执行的项目
- 若需要指定 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
}
}
入口文件寻址
- 首先会寻找配置文件的
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
}
}
- 加载配置文件的内容
命令行没有传入 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 插件基础知识
- 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,
}
- 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 方法实现即时加载。
解析用户插件
对于用户插件的处理,主要有下面几个过程
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
}
}
- 根据
enforce
属性,筛选出 pre、normal 和 post 三类插件
ts
const rawPlugins = (await asyncFlatten(config.plugins || [])).filter(
filterPlugin,
)
// 根据 enforce 进行筛选
const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawPlugins)
const isBuild = command === 'build'
- 依次调用插件的
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
的使用、AOT
和 JIT
编译技术应用和环境变、用户插件的处理,希望你能有所收获。