Vite配置解析是怎么做的?
-
本文为笔者学习
Vite
源码时的一些笔记,如有错误,请指出✊ -
也就是 怎么解析 我们写的
vite.config.ts
等的vite配置文件 -
这一步是由 vite配置解析的
resolveConfig
函数来做的 -
export async function resolveConfig( inlineConfig: InlineConfig, command: 'build' | 'serve', defaultMode = 'development', defaultNodeEnv = 'development', ): Promise<ResolvedConfig>
1. 加载配置文件
-
大概思路是首先加载,解析配置文件,然后 合并命令行的配置
-
let { configFile } = config // config 是 resolveConfig 的参数 inlineConfig if (configFile !== false) { // 默认会走到这里 除非显示指定conFile为false const loadResult = await loadConfigFromFile( configEnv, configFile, config.root, config.logLevel, ) if (loadResult) { // 解析配置后 应该与命令行的配置合并 config = mergeConfig(loadResult.config, config) configFile = loadResult.path /* * 因为配置文件代码可能会有第三方库的依赖,所以当第三方库依赖的代码更改时,Vite * 可以通过 HMR 处理逻辑中记录的configFileDependencies检测到更改,再重启 * DevServer ,来保证当前生效的配置永远是最新的 */ configFileDependencies = loadResult.dependencies } }
-
loadConfigFromFile
函数这里先不做详细介绍,他的主要作用是加载,解析配置文件
2. 解析用户插件
- 这一步主要干了2件事:
根据apply参数,剔除不生效的插件, 给插件排好顺序
- 有些插件只在开发阶段生效,或者说只在生产环境生效,我们可以通过
apply: 'serve' 或 'build'
来指定它们,同时也可以将apply
配置为一个函数,来自定义插件生效的条件 - 因为插件执行时机不一样,所以需要排序,顺便合并插件的配置
// user config may provide an alternative mode. But --mode has a higher priority
// 优先级为 命令行 > 配置文件声明 > 默认
mode = inlineConfig.mode || config.mode || mode
configEnv.mode = mode
const filterPlugin = (p: Plugin) => {
if (!p) {
return false
} else if (!p.apply) {
// 没有显示声明apply,默认都执行
return true
} else if (typeof p.apply === 'function') {
// 如果为函数的话 则执行这个函数 用函数来定义apply的话可以自定义插件生效时机
return p.apply({ ...config, mode }, configEnv)
} else {
return p.apply === command
}
}
......
// resolve plugins
const rawUserPlugins = (
(await asyncFlatten(config.plugins || [])) as Plugin[]
).filter(filterPlugin)
// 这里干了两件事 排序 + 过滤
const [prePlugins, normalPlugins, postPlugins] =
sortUserPlugins(rawUserPlugins)
调用 插件的 config 钩子,进行配置合并
// run config hooks
// 这一步操作由runConfigHook这个函数内部实现
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
config = await runConfigHook(config, userPlugins, configEnv)
解析root参数,alias参数
-
如果在配置文件内没有指定的话,默认root解析的是
process.cwd()
-
解析alias时,需要加上一些内置的 alias 规则,如
@vite/env
、@vite/client
这种直接重定向到 Vite 内部的模块 -
// resolve root const resolvedRoot = normalizePath( config.root ? path.resolve(config.root) : process.cwd(), ) // 内置alias规则 const clientAlias = [ { find: /^\/?@vite\/env/, replacement: path.posix.join(FS_PREFIX, normalizePath(ENV_ENTRY)), }, { find: /^\/?@vite\/client/, replacement: path.posix.join(FS_PREFIX, normalizePath(CLIENT_ENTRY)), }, ] // resolve alias with internal client alias const resolvedAlias = normalizeAlias( mergeAlias(clientAlias, config.resolve?.alias || []), )
3. 加载环境变量
-
没有指定
envDir
的话,默认扫描process.cwd()
目录下的.env文件 -
loadEnv
函数会去扫描process.env
与.env
文件,解析出 env 对象,这个对象的属性最终会被挂载到import.meta.env
这个全局对象上 -
// load .env files const envDir = config.envDir ? normalizePath(path.resolve(resolvedRoot, config.envDir)) : resolvedRoot /* * loadEnv的具体步骤(详细代码在src/node/env.ts文件): * 1. 遍历 process.env 的属性,拿到指定前缀开头的属性(默认指定为VITE_),并挂载 * 在 env 对象上 * 2. 遍历 .env 文件,解析文件,然后往 env 对象挂载那些以指定前缀开头的属性。遍历的 * 文件先后顺序如下: * .env.${mode}.local * .env.${mode} * .env.local * .env */ const userEnv = inlineConfig.envFile !== false && loadEnv(mode, envDir, resolveEnvPrefix(config))
-
特殊情况: 如果在加载过程中遇到 NODE_ENV 属性,则挂到
process.env.VITE_USER_NODE_ENV
,Vite 会优先通过这个属性来决定是否走生产环境
的构建 -
其他一些附带操作
-
/* * 解析资源公共路径 base * 关键在于 resolvebaseUrl 函数,里面的细节主要有: * 空字符或者 ./ 在开发阶段特殊处理,全部重写为/ * .开头的路径,自动重写为 / * 以http(s)://开头的路径,在开发环境下重写为对应的 pathname * 确保路径开头和结尾都是/ */ // During dev, we ignore relative base and fallback to '/' // For the SSR build, relative base isn't possible by means // of import.meta.url. const resolvedBase = relativeBaseShortcut ? !isBuild || config.build?.ssr ? '/' : './' : resolveBaseUrl(config.base, isBuild, logger) ?? '/' // 解析生产环境的构建配置 const resolvedBuildOptions = resolveBuildOptions( config.build, logger, resolvedRoot, ) // 对cacheDir的解析,这个路径相对于在 Vite 预编译时写入依赖产物的路径 // resolve cache directory const pkgDir = findNearestPackageData(resolvedRoot, packageCache)?.dir /* * 当显示指定cacheDir时,cache directory为配置文件中指定的位置 * 否则 判断 pkgDir 是否存在 * 存在的话 指定为 pkgDir下的 node_modules/.vite * 不存在 则为 root 位置下的 .vite */ const cacheDir = normalizePath( config.cacheDir ? path.resolve(resolvedRoot, config.cacheDir) : pkgDir ? path.join(pkgDir, `node_modules/.vite`) : path.join(resolvedRoot, `.vite`), ) // 处理用户配置的assetsInclude,将其转换为一个过滤器函数: // Vite 在最终整理所有配置阶段,会将用户传入的 assetsInclude 和内置的规则合并 // 这个配置决定是否让 Vite 将对应的后缀名视为静态资源文件(asset)来处理 const assetsFilter = config.assetsInclude && (!Array.isArray(config.assetsInclude) || config.assetsInclude.length) ? createFilter(config.assetsInclude) : () => false // 最终所有配置会被合并为这个对象 const resolvedConfig: ResolvedConfig = { ...... assetsInclude(file: string) { return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file) }, ...... }
-
4. 定义路径解析器工厂
主流程
-
这里所说的
路径解析器
,是指调用插件容器进行路径解析
的函数 -
// create an internal resolver to be used in special scenarios, e.g. // optimizer & handling css @imports const createResolver: ResolvedConfig['createResolver'] = (options) => { let aliasContainer: PluginContainer | undefined let resolverContainer: PluginContainer | undefined // 返回了一个函数 这个函数就是路径解析器 return async (id, importer, aliasOnly, ssr) => { let container: PluginContainer if (aliasOnly) { // 新建 aliasPlugin container = aliasContainer || (aliasContainer = await createPluginContainer({ ...resolved, plugins: [aliasPlugin({ entries: resolved.resolve.alias })], })) } else { // 新建 resolvePlugin container = resolverContainer || (resolverContainer = await createPluginContainer({ ...resolved, plugins: [ aliasPlugin({ entries: resolved.resolve.alias }), resolvePlugin({ ...resolved.resolve, root: resolvedRoot, isProduction, isBuild: command === 'build', ssrConfig: resolved.ssr, asSrc: true, preferRelative: false, tryIndex: true, ...options, idOnly: true, }), ], })) } return ( await container.resolveId(id, importer, { ssr, scan: options?.scan, }) )?.id } } // 这里有 aliasContainer 和 resolverContainer 两个工具对象,它们都含有 resolveId 这个专门解析路径的方法,可以被 Vite 调用来获取解析结果 // container 的类型是 PluginContainer 这个我们后续在插件机制那块会讲到
-
这个解析器 未来会用于依赖预构建过程
const resolve = config.createResolver() // 调用以拿到 react 路径 rseolve('react', undefined, undefined, false)
解析 public 参数
// 顺带解析了 public 参数 -> 静态资源目录
const { publicDir } = config
const resolvedPublicDir =
publicDir !== false && publicDir !== ''
? path.resolve(
resolvedRoot,
typeof publicDir === 'string' ? publicDir : 'public',
)
: ''
最终阶段
-
对上面所有解析结果进行合并
-
// 上述的解析 只列举了几个 详细的所有配置解析 可以自行查看源码 const resolvedConfig: ResolvedConfig = { configFile: configFile ? normalizePath(configFile) : undefined, configFileDependencies: configFileDependencies.map((name) => normalizePath(path.resolve(name)), ), inlineConfig, root: resolvedRoot, base: resolvedBase.endsWith('/') ? resolvedBase : resolvedBase + '/', rawBase: resolvedBase, resolve: resolveOptions, publicDir: resolvedPublicDir, cacheDir, command, mode, ssr, isWorker: false, mainConfig: null, isProduction, plugins: userPlugins, esbuild: config.esbuild === false ? false : { jsxDev: !isProduction, ...config.esbuild, }, server, build: resolvedBuildOptions, preview: resolvePreviewOptions(config.preview, server), envDir, env: { ...userEnv, BASE_URL, MODE: mode, DEV: !isProduction, PROD: isProduction, }, assetsInclude(file: string) { return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file) }, logger, packageCache, createResolver, optimizeDeps: { disabled: 'build', ...optimizeDeps, esbuildOptions: { preserveSymlinks: resolveOptions.preserveSymlinks, ...optimizeDeps.esbuildOptions, }, }, worker: resolvedWorkerOptions, appType: config.appType ?? (middlewareMode === 'ssr' ? 'custom' : 'spa'), experimental: { importGlobRestoreExtension: false, hmrPartialAccept: false, ...config.experimental, }, getSortedPlugins: undefined!, getSortedPluginHooks: undefined!, } const resolved: ResolvedConfig = { ...config, ...resolvedConfig, }
5. 生成插件流水线
-
// 先生成完整插件列表传给resolve.plugins // 细节都在 resolvePlugins 函数内部 后续会详细研究这个函数 ;(resolved.plugins as Plugin[]) = await resolvePlugins( resolved, prePlugins, normalPlugins, postPlugins, ) ...... // call configResolved hooks // 调用每个插件的 configResolved 钩子函数 await Promise.all([ ...resolved .getSortedPluginHooks('configResolved') .map((hook) => hook(resolved)), ...resolvedConfig.worker .getSortedPluginHooks('configResolved') .map((hook) => hook(workerResolved)), ]) ......
-
最后 这个
resolvedConfig
函数会 返回 最终的 配置结果 ->resolved
加载配置文件中 的关键函数 loadConfigFromFile
-
// 定义部分 接受四个参数 export async function loadConfigFromFile( configEnv: ConfigEnv, configFile?: string, configRoot: string = process.cwd(), logLevel?: LogLevel, ): Promise<{ path: string config: UserConfig dependencies: string[] } | null>
主要思路
-
既然是 加载配置文件,那么就需要处理 不同的配置文件类型,主要有以下四种
TS + ESM
TS + CJS
JS + ESM
JS + CJS
-
所以,要做的就首先识别 配置文件的类型,然后根据不同的类型,进行解析
1. 寻找配置文件路径
-
// node/contants.ts export const DEFAULT_CONFIG_FILES = [ 'vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.cjs', 'vite.config.mts', 'vite.config.cts', ] // node/config.ts let resolvedPath: string | undefined // configfile 就是 传入的参数 也就是 在命令行启动 vite 的时候指定的参数 if (configFile) { // explicit config path is always resolved from cwd // configFile 存在的话 则用这个路径来 resolve resolvedPath = path.resolve(configFile) } else { // implicit config file loaded from inline root (if present) // otherwise from cwd // 否则的话 从默认的 跟路径 process.cwd() 来resolve for (const filename of DEFAULT_CONFIG_FILES) { const filePath = path.resolve(configRoot, filename) if (!fs.existsSync(filePath)) continue resolvedPath = filePath break } } // 这不到 则返回 null ,同时,给出提示 if (!resolvedPath) { debug?.('no config file found.') return null }
2. 识别配置文件的类别
-
let isESM = false // vite 首先会 检查 这个跟路径的命名,是否包含 mjs , cjs 的后缀, // 如果有的话,会修改isESM 的标识 if (/\.m[jt]s$/.test(resolvedPath)) { isESM = true } else if (/\.c[jt]s$/.test(resolvedPath)) { isESM = false } else { // check package.json for type: "module" and set `isESM` to true // 没有的话 会查看 package.json 文件, // 如果有 type: "module"则打上 isESM 的标识 try { const pkg = lookupFile(configRoot, ['package.json']) isESM = !!pkg && JSON.parse(fs.readFileSync(pkg, 'utf-8')).type === 'module' } catch (e) {} }
3. 利用 esbuild 打包,解析 配置文件
-
try { // 首先 用 esbuild 将配置文件 编译,打包为为 js 文件 (因为 可能为 ts 格式 所以需要先转一下) const bundled = await bundleConfigFile(resolvedPath, isESM) // 解析 打包后的配置文件 这个函数 详细信息在下面, // 主要就是 分为 esm cjs 格式去做不同的解析 const userConfig = await loadConfigFromBundledFile( resolvedPath, bundled.code, isESM, ) debug?.(`bundled config file loaded in ${getTime()}`) // 读取 配置文件后, 处理 是函数的情况 const config = await (typeof userConfig === 'function' ? userConfig(configEnv) : userConfig) if (!isObject(config)) { throw new Error(`config must export or return an object.`) } // 接下来返回最终的配置信息 return { path: normalizePath(resolvedPath), config, // esbuild 打包过程中收集的依赖信息 dependencies: bundled.dependencies, } } catch (e) { createLogger(logLevel).error( colors.red(`failed to load config from ${resolvedPath}`), { error: e }, ) throw e } ...... // loadConfigFromBundledFile 函数 // 创建 require 函数 用于 下面的 cjs 格式配置文件处理 // 这个 createRequire 方法 来自于 node:module const _require = createRequire(import.meta.url) async function loadConfigFromBundledFile( fileName: string, bundledCode: string, isESM: boolean, ): Promise<UserConfigExport> { // for esm, before we can register loaders without requiring users to run node // with --experimental-loader themselves, we have to do a hack here: // write it to disk, load it with native Node ESM, then delete the file. // 如果是 ESM格式,Vite 会将编译后的 js 代码写入临时文件,通过 Node 原生 ESM Import 来读取这个临时的内容,以获取到配置内容,再直接删掉临时文件 if (isESM) { // import 路径结果要加上时间戳 query,是因为 // 为了让 dev server 重启后仍然读取最新的配置,避免缓存 const fileBase = `${fileName}.timestamp-${Date.now()}-${Math.random() .toString(16) .slice(2)}` const fileNameTmp = `${fileBase}.mjs` const fileUrl = `${pathToFileURL(fileBase)}.mjs` await fsp.writeFile(fileNameTmp, bundledCode) try { // 通过 Node 原生 ESM Import 来读取这个临时的内容,以获取到配置内容 return (await dynamicImport(fileUrl)).default } finally { // 最后直接 删掉临时文件 fs.unlink(fileNameTmp, () => {}) // Ignore errors } } // for cjs, we can register a custom loader via `_require.extensions` // 如果是 cjs 格式,那么主要的思路是 // 通过拦截原生 require.extensions 的加载函数来实现对 bundle 后配置代码的加载 else { // 默认加载器 const extension = path.extname(fileName) // We don't use fsp.realpath() here because it has the same behaviour as // fs.realpath.native. On some Windows systems, it returns uppercase volume // letters (e.g. "C:\") while the Node.js loader uses lowercase volume letters. // See https://github.com/vitejs/vite/issues/12923 // 拿到 promisifyed 过的真实的文件名字 const realFileName = await promisifiedRealpath(fileName) // 默认 拦截原生 require 对于 js 文件的加载 const loaderExt = extension in _require.extensions ? extension : '.js' // 先保存 一份 原来的 加载器 -> loader const defaultLoader = _require.extensions[loaderExt]! // 这里 进行 拦截,重写 _require.extensions[loaderExt] = (module: NodeModule, filename: string) => { // 如果加载的文件 是 该配置文件 则 调用 module._compile 方法进行编译 if (filename === realFileName) { ;(module as NodeModuleWithCompile)._compile(bundledCode, filename) } else { defaultLoader(module, filename) } } // clear cache in case of server restart delete _require.cache[_require.resolve(fileName)] // 编译后 再 进行一次手动的 require 即可拿到配置对象 const raw = _require(fileName) // 恢复原生的加载方法 _require.extensions[loaderExt] = defaultLoader return raw.__esModule ? raw.default : raw } } // node/utils.ts // 这里 注释已经给的很明显了 在非 jest 下 dynamicImport 返回的是 // new Function('file', 'return import(file)') // @ts-expect-error jest only exists when running Jest export const usingDynamicImport = typeof jest === 'undefined' /** * Dynamically import files. It will make sure it's not being compiled away by TS/Rollup. * * As a temporary workaround for Jest's lack of stable ESM support, we fallback to require * if we're in a Jest environment. * See https://github.com/vitejs/vite/pull/5197#issuecomment-938054077 * * @param file File path to import. */ // 为什么不直接 import, 而是要用 new Function 包裹? // 这是为了避免打包工具处理这段代码,比如 Rollup 和 TSC,类似的手段还有 eval export const dynamicImport = usingDynamicImport ? new Function('file', 'return import(file)') : _require
-
在处理
ESM
类型的配置文件时,采用的是将bundle(打包编译)
后的js
代码写入临时文件
,通过 Node 原生ESM Import
来读取这个临时的内容,以获取到配置内容,再直接删掉临时文件- 这种先编译配置文件,再将产物写入临时目录,最后加载临时目录产物的做法,也是 AOT (Ahead Of Time)编译技术的一种具体实现
-
在处理
CJS
类型的配置文件时, 采用的是拦截原生require.extensions
的加载函数来实现对bundle(打包编译)
后的js
代码的加载- 这种运行时加载
JS
配置的方式,也叫做JIT
(即时编译),这种方式和AOT
最大的区别在于不会将内存中计算出来的js
代码写入磁盘再加载,而是通过拦截 Node.js 原生require.extension
方法实现即时加载
- 这种运行时加载
总结
-
主要梳理了 Vite 配置解析的整体流程
和
加载配置文件的方法 -
Vite 配置文件解析的逻辑由
resolveConfig
函数统一实现- 经历了加载配置文件、解析用户插件、加载环境变量、创建路径解析器工厂和生成插件流水线这几个主要的流程
-
在
加载配置文件
的过程中,Vite 需要处理四种类型的配置文件((TS, JS)-(ESM, CJS)
)- 首先先 用
esbuild
将TS
代码 打包编译为JS
代码 - 其中对于
ESM
和CJS
两种格式文件,分别采用了AOT
和JIT
两种编译技术实现了配置加载
- 首先先 用
-
学习链接
- 掘金小测-《深入浅出Vite》
- Vite
node/config.ts
等文件 - 互联网上其他关于Vite一些文章,由于笔者学习的时候,没有记录,特此感谢🙏