本文参加了由公众号@若川视野 发起的每周源码共读活动 ,点击了解详情一起参与。
本篇是源码共读第40期 | vite 是如何解析用户配置的 .env 的,点击了解本期详情
我们在项目开发过程中,会使用一些 环境变量, webpack 通过 全局注册到 process.env 上, vite 通过读取env配置文件, 挂载到 import.meta.xxx上, 我们现在调试 vite 源码,学习整个过程
简介
整个env的解析涉及如下文件
- packages/vite/bin/vite.js 入口文件
- packages/vite/src/node/cli.ts
cac
包的action 执行的对应serve
或者build
动作, 定义vite dev
或者vite build
命令的地方 - packages/vite/src/node/config.ts 调用 env.ts里面的方法解析结果
- packages/vite/src/node/env.ts 具体解析env的实现步骤
我们要简单的了解一下 cac
这个工具, 类似 commander
是命令行工具
shell
vite dev
vite serve
vite
这3个命令都对应同一个
基础环境
vite@5.x 现在需要 node 18
或者 20 +
版本才可以运行
pnpm 作为包管理工具
本次调试代码为 vite@5.0.10
bash
git clone https://github.com/vitejs/vite.git
安装依赖
shell
pnpm i
pnpm run build
安装以后,生成构建产物
如果改动了源代码, 可以采用 pnpm run dev 使用 开发模式,监听文件变动
现在,我们查看 vite的入口文件, 项目根目录的 package.json
json
{
"bin": {
"vite": "bin/vite.js"
},
}
在项目根目录,执行
shell
pnpm run dev
调试
- 打断点
入口从 start
函数开始, 它加载的是 dist/node/cli.js
文件, 这个文件我们前面已经 使用 build 命令将产物进行了构建输出
在 packages/vite/src/node/cli.ts
中的 命令里面加一个断点, 我们在VS code
按 F5
, 调试,会跳入这里, 本质还是 sourcemap
起作用,才从js映射到 ts文件
packages/vite/src/node/config.ts 会调用 loadEnv文件
默认 vite 的 mode 是 development
先设定 env 里面 要解析的前缀
packages/vite/src/node/env.ts
js
export function resolveEnvPrefix({
envPrefix = 'VITE_',
}) {
envPrefix = arraify(envPrefix)
if (envPrefix.includes('')) {
throw new Error(
`envPrefix option contains value '', which could lead unexpected exposure of sensitive information.`,
)
}
return envPrefix
}
以 VITE_ 前缀的,才解析
我们看看vite 官方基础用法, 定义 vite.config.js
js
import {defineConfig} from 'vite'
export default defineConfig({
envPrefix: 'VITE_'
})
export default defineConfig({
envPrefix: ['VITE_', 'TEST_']
})
可以修改 env的前置, 可以传一个字符串,或者一个数组, 表示只要是这个前缀的, vite 都解析 env里面的内容,并添加到 import.meta.env 上
注意: envPrefix 不能为空字符串, 否则会报错
进入到 loadEnv 的具体实现
现在断点断住了,下面是 根据 mode , 拿到要解析的 .env文件
我们知道官网加载env规则如下
也就是说, 在 development
模式下,会加载4个文件, 如果这4个配置文件都存在的话。
js
[
'.env',
'.env.local',
'.env.development',
'.env.development.local'
]
具体实现如下:
根据 mode , 拿到要解析的 .env文件, packages/vite/src/node/env.ts
js
export function getEnvFilesForMode(mode: string): string[] {
return [
/** default file */ `.env`,
/** local file */ `.env.local`,
/** mode file */ `.env.${mode}`,
/** mode local file */ `.env.${mode}.local`,
]
}
此时 mode 为 development , 则 会解析4个文件
js
[
'.env',
'.env.local',
'.env.development',
'.env.development.local'
]
现在需要读取并解析 4个文件中的内容, 其实本质就是读取文件,拿到的就是字符串, 另外,还需要判断这4个文件是否存在,如果不存在,则不管,如果存在, 则使用 dotenv 解析成键值对
js
import { parse } from 'dotenv'
import { expand } from 'dotenv-expand'
/**
如果是数组,则返回原始值, 否则包装成数组
*/
export function arraify(target) {
return Array.isArray(target) ? target: [target]
}
export function loadEnv(mode, envDir, prefixes = 'VITE_') {
// 让 环境变量中的前缀,支持多个, 一般是 VITE_ 开头
// 可以配置成 数组 ["VITE_", "TEST_"] 配置多个
prefixes = arraify(prefixes)
const env = {}
const envFiles = getEnvFilesForMode(mode)
// 这里是几个步骤
/**
1. 遍历目标对象 envFiles
2. 拼接完整的 文件路径
3. 读取文件是否存在,如果.env 配置文件在用户电脑不存在,则跳过,什么都不做
4. 文件存在, 读取文件内容,fs.readFileSync 是buffer
5. 将 读取到的 buffer内容给 dotenv 包的 parse 函数, 解析成对象
*/
const parsed = Object.fromEntries(
envFiles.flatMap((file) => {
const filePath = path.join(envDir, file)
if (!tryStatSync(filePath)?.isFile()) return []
// 使用 dotenv提供的parsed 解析内容
return Object.entries(parse(fs.readFileSync(filePath)))
}),
)
}
// 使用dotenv-expand 扩展 env文件里面的变量,支持变量
expand({ parsed })
这里涉及到几个函数 Object.fromEntries
, 还有 Object.entries
js
let t1 = {
VITE_PORT: '3000',
VITE_BASE_URL: 'http://www.baidu.com',
VITE_OA_URL: '${VITE_BASE_URL}/api/oa',
test: '123'
}
let res = Object.entries(t1)
res 的结果
js
[
[
"VITE_PORT",
"3000"
],
[
"VITE_BASE_URL",
"http://www.baidu.com"
],
[
"VITE_OA_URL",
"${VITE_BASE_URL}/api/oa"
],
[
"test",
"123"
]
]
现在需要变成 对象形式, 使用 Object.fromEntries
js
let r1 = [
[ 'VITE_PORT', '3000' ],
[ 'VITE_BASE_URL', 'http://www.baidu.com' ],
[ 'VITE_OA_URL', '${VITE_BASE_URL}/api/oa' ],
[ 'test', '123' ],
[ 'VITE_DIR', 'src' ]
]
let result = Object.fromEntries(r1)
拿到的结果:
js
{
"VITE_PORT": "3000",
"VITE_BASE_URL": "http://www.baidu.com",
"VITE_OA_URL": "${VITE_BASE_URL}/api/oa",
"test": "123",
"VITE_DIR": "src"
}
所以,我们在 env 文件中定义的字段,不要重复
现在,只是纯值, 里面还有变量 ,需要替换为真实值, 我们使用 dotenv-expand
扩展 dotenv
的功能
js
import { expand } from 'dotenv-expand'
expand({ parsed })
现在内容都解析出来了, 还需要对内容进行过滤, 只过滤出 符合要求前缀的字段
js
for (const [key, value] of Object.entries(parsed)) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = value
}
}
到此, 我们已经完整的分析了, 从入口文件
到 cli
到 cac
命令,再到 读取 env文件,以及解析 env文件内容,再进一步根据前缀
进行过滤, 最后拿到解析后的 env
对象
完整独立代码
shell
node 20.10.0
pnpm
上面是我们基于vite源码进行分析, 现在我们单独起一个项目,实现一下整个过程,包含完整的日志输出
目录结构
运行
js
node .\vite\resolveenv.mjs
vite/resolveenv.mjs
js
import path from 'path'
import fs from 'node:fs'
// 解析 env文件
import { parse } from 'dotenv'
import { expand } from 'dotenv-expand'
const envList = [
'.env',
'.env.development',
'.env.development.local'
]
// 判断文件是否存在
function tryStatSync (file) {
try {
return fs.statSync(file, { throwIfNoEntry: false })
} catch (error) {
// 忽略错误
}
}
// vite 源码中是 连贯嵌套写法,这里只是将每一个步骤拆分,可以打印输出查看内容
// packages/vite/src/node/env.ts 中 loadEnv 方法
let r1 = envList.flatMap(file => {
/**
* 拿到完整的文件路径
* D:\owner\node-base-example\cac-demo\vite\.env
D:\owner\node-base-example\cac-demo\vite\.env.development
D:\owner\node-base-example\cac-demo\vite\.env.development.local
*/
const filePath = path.join(process.cwd(), 'vite', file)
// 尝试解析文件,读取文件是否存在
// console.log(tryStatSync(filePath));
/**
* Stats {
dev: 2793138336,
mode: 33206,
nlink: 1,
uid: 0,
gid: 0,
rdev: 0,
blksize: 4096,
ino: 562949958349813,
size: 0,
blocks: 0,
atimeMs: 1704270080630.521,
mtimeMs: 1704270080630.521,
ctimeMs: 1704270080630.521,
birthtimeMs: 1704270080630.521,
atime: 2024-01-03T08:21:20.631Z,
mtime: 2024-01-03T08:21:20.631Z,
ctime: 2024-01-03T08:21:20.631Z,
birthtime: 2024-01-03T08:21:20.631Z
}
*/
// 如果不是文件,就跳过
if (!tryStatSync(filePath)?.isFile()) return []
// 符合配置的.env文件,也就是项目根目录配置了这个文件的,就进入这个流程
let content = fs.readFileSync(filePath)
// console.log(content);
// return Object.entries(parse())
// 读取到的 content 是二进制内容 buffer
// 使用 dotenv包进行解析
let t1 = parse(content)
// console.log('t1');
// console.log(t1);
// { VITE_PORT: '3000' }
// { VITE_DIR: 'src' }
// console.log(t1);
return Object.entries(t1)
})
// [ [ 'VITE_PORT', '3000' ], [ 'VITE_DIR', 'src' ] ]
console.log('r1');
console.log(r1);
// 将数组对象变成 纯对象
const parsed = Object.fromEntries(r1)
/**
{
VITE_PORT: '3000',
VITE_BASE_URL: 'http://www.baidu.com',
VITE_OA_URL: '${VITE_BASE_URL}/api/oa',
VITE_DIR: 'src'
}
*/
// console.log(parsed);
// 现在上述的 parsed 只是一个对象,并不具备 在 env中支持变量的行为
let t2 = expand({ parsed: parsed })
// console.log(t2);
/**
* t2 = {
parsed: {
VITE_PORT: '3000',
VITE_BASE_URL: 'http://www.baidu.com',
VITE_OA_URL: 'http://www.baidu.com/api/oa',
VITE_DIR: 'src'
}
}
*/
// expand 修改的是 parsed 变量的引用, 所以,可以不定义 t2接收值
/**
解析以后,就是将变量进行了替换
{
VITE_PORT: '3000',
VITE_BASE_URL: 'http://www.baidu.com',
VITE_OA_URL: 'http://www.baidu.com/api/oa',
VITE_DIR: 'src'
}
*/
console.log(parsed);
// 检查env环境变量里面的前缀, 如果前缀以 VITE_ 开头的, 则保存到 env = {} 对象中
const env = {}
// 这里写死, vite源码中是默认值是 VITE_, 用户可以在 vite.config.js 中 修改配置 envPrefix 传递多个前缀
const prefixes = ['VITE_']
for (const [key, value] of Object.entries(parsed)) {
// 如果 解析后的前缀,符合 前缀要求,则存储到 env 对象上
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = value
}
}
console.log('最终解析的结果');
console.log(env);
测试用的env文件
vite/.env
ini
VITE_PORT = 3000
VITE_BASE_URL = 'http://www.baidu.com'
VITE_OA_URL = ${VITE_BASE_URL}/api/oa
test = 123
vite/.env.development
ini
VITE_DIR = src
最终解析的结果
css
{
VITE_PORT: '3000',
VITE_BASE_URL: 'http://www.baidu.com',
VITE_OA_URL: 'http://www.baidu.com/api/oa',
VITE_DIR: 'src'
}
独立代码仅仅是对 vite源码进行了细化,日志输出,增加注释等。 其实思路是一模一样的。