本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
这是源码共读的第40期 | Vite 是如何解析用户配置的 .env 的
前言&咸鱼想法
间隔上一篇源码学习文章已经过去了三个月,历经了无底洞般加班的三个月,每天不停的搬砖。。改不合理的需求。。搬砖。。秃头了不少
学习目标
本篇笔记将:
- 学习cac的使用
- loadEnv函数学习
- Vite 读取Env变量流程
cac学习&准备
一个用于命令行的CLI包,特点是上手容易,且提示友好
基本使用
- option 用于定义可执行的选项
- command 用于定义可交互的命令
- action 接在option和command之后,执行对应的命令选项的动作
- help 提供帮助选项提示
- version 定义命令行版本
在命令中使用括号时,尖括号表示必需的命令参数,而方括号表示可选参数。
在选项中使用括号时,尖括号表示需要字符串/数字值,而方括号表示该值也可以为true
例子1 多参数命令
import { cac } from 'cac'
const cli = cac('Harexs')
cli.command('lint [...files]', 'Lint files').action((files, options) => {
console.log(files, options)
})
cli.help() //提供帮助命令
cli.version('0.1.1') //定义版本号
const parsed = cli.parse()
例子2 多选项命令
import { cac } from 'cac'
const cli = cac('Harexs')
cli.command('rm [dir]', 'Remove a dir')
.option('-r, --recursive', 'Remove recursively')
.option('--harexs-cli', 'Remove recursively')
.action((dir, options) => {
console.log(options)
console.log('remove ' + dir + (options.recursive ? ' recursively' : ''))
})
cli.help() //提供帮助命令
cli.version('0.1.1') //定义版本号
const parsed = cli.parse()
完整Demo
import { cac } from 'cac'
//初始化执行 用于定义整个函数的执行
const cli = cac('Harexs')
/**
在命令中使用括号时,尖括号表示必需的命令参数,而方括号表示可选参数。
在选项中使用括号时,尖括号表示需要字符串/数字值,而方括号表示该值也可以为true
*/
// //用于定义会被执行的--命令, 它会被赋值到 options参数中,--type的key就是type命名
cli.option('--type [type]', 'Choose a project type')
cli.option('--name <name>', 'Provide your name')
// action定义在对应命令被执行后做的事情 files是对应 lint 命令的 argvs, ooptions是其他参数
cli.command('lint [...files]', 'Lint files').action((files, options) => {
console.log(files, options)
})
// //给命令 附加选项 命令执行时候只会抓取相关的选项 以及第一个 argvs
cli.command('rm [dir]', 'Remove a dir')
.option('-r, --recursive', 'Remove recursively')
.option('--harexs-cli', 'Remove recursively')
.action((dir, options) => {
console.log(options)
console.log('remove ' + dir + (options.recursive ? ' recursively' : ''))
})
// //展开语法 读取多个值
cli.command('build <entry> [...otherFile]', 'build your app')
.option('--foo', 'foo options')
.action((entry, otherFiles, options) => {
console.log(entry, otherFiles, options)
})
// //展开语法 默认 合并值操作
cli.command('[...files]', 'build files')
.option('--mini', 'mini option')
.action((files, option) => {
console.log(files, option)
})
//Vite 模板
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
.option('--host [host]', `[string] specify hostname`)
.option('--port <port>', `[number] specify port`)
.option('--https', `[boolean] use TLS + HTTP/2`)
.option('--open [path]', `[boolean | string] open browser on startup`)
.option('--cors', `[boolean] enable CORS`)
.option('--strictPort', `[boolean] exit if specified port is already in use`)
.option(
'--force',
`[boolean] force the optimizer to ignore the cache and re-bundle`,
).action((root, option) => {
console.log(root, option)
})
cli.help()
cli.version('0.1.1')
const parsed = cli.parse()
// console.log(JSON.stringify(parsed, null, 2)) 查看完整解析后的JSON对象
源码&流程
package.json
命令执行入口
"bin": {
"vite": "bin/vite.js"
}
if (profileIndex > 0) {
process.argv.splice(profileIndex, 1)
const next = process.argv[profileIndex]
if (next && !next.startsWith('-')) {
process.argv.splice(profileIndex, 1)
}
const inspector = await import('node:inspector').then((r) => r.default)
const session = (global.__vite_profile_session = new inspector.Session())
session.connect()
session.post('Profiler.enable', () => {
session.post('Profiler.start', start)
})
} else {
start()
}
//核心执行
function start() {
return import('../dist/node/cli.js')
}
通过bin的文件指向,以及函数调用, 确定最终会去执行 node/cli这个文件,但我们源代码是没有这个文件夹的,它是在打包后才会产生的输出目录, 接下来我们要去 rollup中找到相关的配置
//rollup.config.ts
function createNodeConfig(isProduction: boolean) {
return defineConfig({
...sharedNodeOptions,
input: {
index: path.resolve(__dirname, 'src/node/index.ts'),
cli: path.resolve(__dirname, 'src/node/cli.ts'),
constants: path.resolve(__dirname, 'src/node/constants.ts'),
},
output: {
...sharedNodeOptions.output,
sourcemap: !isProduction,
},
external: [
'fsevents',
...Object.keys(pkg.dependencies),
...(isProduction ? [] : Object.keys(pkg.devDependencies)),
],
plugins: createNodePlugins(
isProduction,
!isProduction,
// in production we use api-extractor for dts generation
// in development we need to rely on the rollup ts plugin
isProduction ? false : './dist/node',
),
})
}
node/cli.ts
在这个文件中,你会发现就是cac这个包的使用和对应的函数执行
// dev
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
.option('--host [host]', `[string] specify hostname`)
.option('--port <port>', `[number] specify port`)
.option('--https', `[boolean] use TLS + HTTP/2`)
.option('--open [path]', `[boolean | string] open browser on startup`)
.option('--cors', `[boolean] enable CORS`)
.option('--strictPort', `[boolean] exit if specified port is already in use`)
.option(
'--force',
`[boolean] force the optimizer to ignore the cache and re-bundle`,
)
.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,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
optimizeDeps: { force: options.force },
server: cleanOptions(options),
})
if (!server.httpServer) {
throw new Error('HTTP server not available')
}
await server.listen()
const info = server.config.logger.info
const viteStartTime = global.__vite_start_time ?? false
const startupDurationString = viteStartTime
? colors.dim(
`ready in ${colors.reset(
colors.bold(Math.ceil(performance.now() - viteStartTime)),
)} ms`,
)
: ''
info(
`\n ${colors.green(
`${colors.bold('VITE')} v${VERSION}`,
)} ${startupDurationString}\n`,
{ clear: !server.config.logger.hasWarned },
)
server.printUrls()
bindShortcuts(server, {
print: true,
customShortcuts: [
profileSession && {
key: 'p',
description: 'start/stop the profiler',
async action(server) {
if (profileSession) {
await stopProfiler(server.config.logger.info)
} else {
const inspector = await import('node:inspector').then(
(r) => r.default,
)
await new Promise<void>((res) => {
profileSession = new inspector.Session()
profileSession.connect()
profileSession.post('Profiler.enable', () => {
profileSession!.post('Profiler.start', () => {
server.config.logger.info('Profiler started')
res()
})
})
})
}
},
},
],
})
} catch (e) {
const logger = createLogger(options.logLevel)
logger.error(colors.red(`error when starting dev server:\n${e.stack}`), {
error: e,
})
stopProfiler(logger.info)
process.exit(1)
}
})
// build
cli
.command('build [root]', 'build for production')
.option('--target <target>', `[string] transpile target (default: 'modules')`)
.option('--outDir <dir>', `[string] output directory (default: dist)`)
.option(
'--assetsDir <dir>',
`[string] directory under outDir to place assets in (default: assets)`,
)
.option(
'--assetsInlineLimit <number>',
`[number] static asset base64 inline threshold in bytes (default: 4096)`,
)
.option(
'--ssr [entry]',
`[string] build specified entry for server-side rendering`,
)
.option(
'--sourcemap [output]',
`[boolean | "inline" | "hidden"] output source maps for build (default: false)`,
)
.option(
'--minify [minifier]',
`[boolean | "terser" | "esbuild"] enable/disable minification, ` +
`or specify minifier to use (default: esbuild)`,
)
.option('--manifest [name]', `[boolean | string] emit build manifest json`)
.option('--ssrManifest [name]', `[boolean | string] emit ssr manifest json`)
.option(
'--force',
`[boolean] force the optimizer to ignore the cache and re-bundle (experimental)`,
)
.option(
'--emptyOutDir',
`[boolean] force empty outDir when it's outside of root`,
)
.option('-w, --watch', `[boolean] rebuilds when modules have changed on disk`)
.action(async (root: string, options: BuildOptions & GlobalCLIOptions) => {
filterDuplicateOptions(options)
const { build } = await import('./build')
const buildOptions: BuildOptions = cleanOptions(options)
try {
await build({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
optimizeDeps: { force: options.force },
build: buildOptions,
})
} catch (e) {
createLogger(options.logLevel).error(
colors.red(`error during build:\n${e.stack}`),
{ error: e },
)
process.exit(1)
} finally {
stopProfiler((message) => createLogger(options.logLevel).info(message))
}
})
这两部分对应的就是 vite dev 和build的执行,接着我们去看 createServer函数的执行逻辑 github1s.com/vitejs/vite…
export async function _createServer(
inlineConfig: InlineConfig = {},
options: { ws: boolean },
): Promise<ViteDevServer> {
const config = await resolveConfig(inlineConfig, 'serve')
resolveConfig函数内部会对 env变量进行读取解析部分 github1s.com/vitejs/vite…
// load .env files
const envDir = config.envDir
? normalizePath(path.resolve(resolvedRoot, config.envDir))
: resolvedRoot
const userEnv =
inlineConfig.envFile !== false &&
loadEnv(mode, envDir, resolveEnvPrefix(config))
loadEnv
loadEnv(环境变量,要解析的环境变量文件目录,自定义前缀)
Object.fromEntries / Object.entries
将将可迭代的键值对列表转换为一个对象 / 将对象中的可枚举属性转换为数组键值对
const entries = new Map([
['foo', 'bar'],
['baz', 42]
]);
const obj = Object.fromEntries(entries); //{ foo: "bar", baz: 42 }
const object1 = {
a: 'somestring',
b: 42
};
console.log(Object.entries(object1))
for (const [key, value] of Object.entries(object1)) {
console.log(`${key}: ${value}`);
}
loadEnv函数 会读取本地的环境变量文件, 通过 dotenv 提供的 parse解析内容
const env: Record<string, string> = {}
const envFiles = [
/** default file */ `.env`,
/** local file */ `.env.local`,
/** mode file */ `.env.${mode}`,
/** mode local file */ `.env.${mode}.local`,
]
const parsed = Object.fromEntries(
envFiles.flatMap((file) => {
const filePath = path.join(envDir, file)
if (!tryStatSync(filePath)?.isFile()) return []
return Object.entries(parse(fs.readFileSync(filePath)))
}),
)
expand({ parsed })
// only keys that start with prefix are exposed to client
for (const [key, value] of Object.entries(parsed)) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = value
}
}
//将环境变量中 存在的指定前缀的变量也读取出来
// check if there are actual env variables starting with VITE_*
// these are typically provided inline and should be prioritized
for (const key in process.env) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = process.env[key] as string
}
}
return env //返回解析后的对象
resolvedConfig
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,
},
//...最终在resolvedConfig中 env属性存储并返回
}
resolved / resolvePlugins
核心函数调用, resolvePlugins plugins内部会把我们的env对象注入到process.env中
const resolved: ResolvedConfig = {
...config,
...resolvedConfig,
}
;(resolved.plugins as Plugin[]) = await resolvePlugins(
resolved,
prePlugins,
normalPlugins,
postPlugins,
)
resolvePlugins
return [
...(isDepsOptimizerEnabled(config, false) ||
isDepsOptimizerEnabled(config, true)
? [
isBuild
? optimizedDepsBuildPlugin(config)
: optimizedDepsPlugin(config),
]
: []),
isWatch ? ensureWatchPlugin() : null,
isBuild ? metadataPlugin() : null,
watchPackageDataPlugin(config.packageCache),
preAliasPlugin(config),
aliasPlugin({ entries: config.resolve.alias }),
...prePlugins,
modulePreload === true ||
(typeof modulePreload === 'object' && modulePreload.polyfill)
? modulePreloadPolyfillPlugin(config)
: null,
resolvePlugin({
...config.resolve,
root: config.root,
isProduction: config.isProduction,
isBuild,
packageCache: config.packageCache,
ssrConfig: config.ssr,
asSrc: true,
getDepsOptimizer: (ssr: boolean) => getDepsOptimizer(config, ssr),
shouldExternalize:
isBuild && config.build.ssr && config.ssr?.format !== 'cjs'
? (id) => shouldExternalizeForSSR(id, config)
: undefined,
}),
htmlInlineProxyPlugin(config),
cssPlugin(config),
config.esbuild !== false ? esbuildPlugin(config) : null,
jsonPlugin(
{
namedExports: true,
...config.json,
},
isBuild,
),
wasmHelperPlugin(config),
webWorkerPlugin(config),
assetPlugin(config),
...normalPlugins,
wasmFallbackPlugin(),
definePlugin(config),
cssPostPlugin(config),
isBuild && buildHtmlPlugin(config),
workerImportMetaUrlPlugin(config),
assetImportMetaUrlPlugin(config),
...buildPlugins.pre,
dynamicImportVarsPlugin(config),
importGlobPlugin(config),
...postPlugins,
...buildPlugins.post,
// internal server-only plugins are always applied after everything else
...(isBuild
? []
: [clientInjectionsPlugin(config), importAnalysisPlugin(config)]),
].filter(Boolean) as Plugin[]
definePlugin(config) 会在 Build模式下 进行环境变量注入
if (isBuild) {
// set here to allow override with config.define
importMetaKeys['import.meta.hot'] = `undefined`
for (const key in config.env) {
importMetaKeys[`import.meta.env.${key}`] = JSON.stringify(config.env[key])
}
Object.assign(importMetaFallbackKeys, {
'import.meta.env.': `({}).`,
'import.meta.env': JSON.stringify({
...config.env,
SSR: '__vite__ssr__',
...userDefineEnv,
}).replace(
/"__vite__define__(.+?)"([,}])/g,
(_, val, suffix) => `${val.replace(/(^\\")|(\\"$)/g, '"')}${suffix}`,
),
})
}
而如果处于Dev 命令执行的模式下,define.ts插件不会执行 env环境变量的注入,整个代码最终会走到最后一行的[clientInjectionsPlugin(config), importAnalysisPlugin(config)], 核心就是importAnalysisPlugin插件
最终你可以在函数内部看到此处的 环境变量注入
if (hasEnv) {
// inject import.meta.env
str().prepend(getEnv(ssr))
}
总结
推荐一个做笔记很方便的Vscode插件 Bookmarks , 在关键位置可以进行标记,方便回来阅读。
- 从
bin/vite.js可得知 命令执行的入口在/src/node/cli.ts中 - 不管是执行
vite build还是vite dev,在 build/_createServer 函数中都会去执行 config.ts 中的resolveConfig 函数 - resolveConfig 内部会调用 lodeEnv 函数解析命令执行目录下的 .env.xxx文件解析符合条件的环境变量对象, 并执行到resolvePlugins函数中,通过插件机制注入环境变量
- 在
/src/node/plugins/index.tsresolvePlugins 函数中,会通过 definePlugin 注入执行build模式下的环境变量,通过importAnalysisPlugin 注入dev 模式下的环境变量
一开始读还是很懵逼的,因为除了cli部分,后续函数的调用太多,甚至一度以为环境变量注入都是在 define 这个plugin完成的, 实际当你执行的是默认的 vite命令 即 vite dev模式, define内的判断会让环境变量注入不会执行。开发模式下最终的代码注入是在 resolvePlugins => importAnalysisPlugin