本地跑 Vite 时,.env 似乎「自动就有了」。可一旦跳出浏览器,问题就会跟着来:
Drizzle、脚本、Workers 本地注入——它们要的是 process.env,不是 import.meta.env。
你下意识去翻 Vite 文档,又会被默认的 VITE_ 前缀绊住:是不是只有带这个前缀的键才会被加载?那我怎么把别的键写进进程环境?
顺着这个疑问往下想,单仓库还好办;一进 monorepo,cwd 和配置文件所在目录就分家了——.env 到底在哪一层?写死 ../../.env 靠谱吗?
再往下:.env.local、.env.development 谁覆盖谁?dotenv 的 path 数组和 override 怎么配合?Vite 的 mode 阶梯要不要自己复刻?
这篇文章按这条思考链往下走:先认清单文件 dotenv,再看多文件合并与覆盖语义,接着解决 monorepo 里「文件在哪」,最后——既然仓库里已经有 Vite——直接复用 loadEnv,把同一套规则接到 Node 工具上。
读者背景
流畅阅读本文,至少需要:
- 熟悉 Vite 的环境变量加载机制(
.env*、envPrefix、import.meta.env) - 了解 monorepo 的基本结构与包间脚本怎么跑(例如 pnpm workspace / filter)
- 具备一定的前端工程化经验(本地脚本、进程环境、依赖复用这类取舍)
若对 Vite env 几乎没接触过,建议先读官方 Env Variables and Modes,再回来会更顺。
暴露与注入
很多人把「Vite 加载 .env」和「变量出现在 process.env」混为一谈。它们不是一回事。
1. 默认前缀:客户端只认 VITE_
Vite 读 .env* 时,注入到客户端代码(import.meta.env)的变量,默认要带 envPrefix 前缀。默认前缀是 VITE_。
所以:
.env里的VITE_API_URL=...→ 前端能通过import.meta.env.VITE_API_URL用到.env里的DATABASE_URL=...→ 不会自动暴露给浏览器代码(这是刻意的安全设计)
前缀可以改,例如同时暴露 VITE_ 和 PUBLIC_:
// vite.config.ts
export default defineConfig({
envPrefix: ['VITE_', 'PUBLIC_'],
});
结论:不是「Vite 只能加载 VITE_ 开头的键」。
Vite 会读完整的 .env* 文件;限制的是「哪些键会进客户端包」。Node / 配置侧可以用空前缀把全部键读出来(见后文 loadEnv)。
2. 前端里尽量用 import.meta.env,不是 process.env
浏览器打包产物里没有真正的 Node process.env。Vite 对 import.meta.env.XXX 做静态替换。若第三方库硬编码了 process.env.FOO,可以用 define 做字符串替换,但:
- 只能替换静态可分析的访问,不能指望
console.log(process.env)打印整个对象 - 批量把所有 env 塞进
define很容易把密钥打进前端包——不要这么干
真正需要「像 Node 一样」读 process.env 的场景,通常是:
vite.config.ts/ 插件- Drizzle Kit、脚本、CLI
- Cloudflare Workers 本地开发时,把变量再喂给运行时
这些场景要的是:把 .env* 解析结果写进(或补进)当前 Node 进程的 process.env。
起点:dotenv 加载单个 .env
最简单的 Node 写法:
pnpm add dotenv
import dotenv from 'dotenv';
dotenv.config(); // 默认读 process.cwd() 下的 .env
// 之后 process.env.FOO 可用
或指定路径:
dotenv.config({ path: '/abs/path/to/.env' });
也可以只解析、不写入:
import { parse } from 'dotenv';
import { readFileSync } from 'node:fs';
const map = parse(readFileSync('.env'));
单文件、单层配置时,这就够了。麻烦从「多个文件 + 优先级 + 已有环境变量」开始。
dotenv 的覆盖语义
dotenv.config() 默认不覆盖已有 process.env。多文件「优先级」靠 path 数组顺序和 override,不必手写 merge。
默认:已存在的键不覆盖
设计意图是:shell / CI / 云端 Secrets 注入的值,优先级高于文件。
这很好——但也意味着:若你分两次调用 config(),第一次已经把键写进 process.env,第二次再 config({ path: '.env.local' }),同名键默认改不动。
要用后写覆盖,得显式:
dotenv.config({ path: '.env' });
dotenv.config({ path: '.env.local', override: true });
顺序和 override 都搞错,结果就完全反了。
多文件:一次传入 path 数组
较新的 dotenv(本仓库用的是 17.x)支持:
dotenv.config({
path: ['.env.local', '.env'], // 数组按序解析;缺文件会跳过
// override: false(默认)→ 先出现的赢;true → 后出现的赢
quiet: true, // 关掉默认的 injected env 日志
});
和「已有 process.env」的关系:
override |
文件之间 | 与已有 process.env |
|---|---|---|
false(默认) |
先出现的赢 | 不覆盖已有键 |
true |
后出现的赢 | 会覆盖已有键 |
因此「包级 .env 优先于 monorepo 根 .env,且 Secrets 不被文件盖掉」可以写成:
import { config } from 'dotenv';
import { resolve } from 'node:path';
// 默认 override: false → 先出现的赢,且不碰已有 process.env
config({
path: [resolve(pkgDir, '.env'), resolve(monorepoRoot, '.env')],
quiet: true,
});
不必再手写 parse + Object.assign + assignMissingEnv。
它仍不理解 Vite 那套文件阶梯
Vite 约定(按模式 mode,例如 development / production)大致是:
| 优先级(高 → 低) | 文件 |
|---|---|
| 最高 | .env.[mode].local |
.env.[mode] |
|
.env.local(在 test 模式下通常不加载) |
|
| 最低 | .env |
原生 dotenv:
- 知道多文件合并(
path: string[]) - 不知道
mode,不会自动展开上述四个文件名 - 要自己拼路径(或换用
dotenv-flow/ Vite 的loadEnv)
若仓库里已经有 Vite,直接用它的 loadEnv 更省事(见文末)。
monorepo:文件在哪?不要写死 ../../.env
在 packages/db/drizzle.config.ts 里写:
dotenv.config({ path: '../../.env' });
脆弱之处在于:
- 从仓库根跑
pnpm --filter db db:generate时,process.cwd()往往是根目录 cd packages/db && npx drizzle-kit时,cwd 又是包目录- 相对路径相对的是「谁」——cwd 还是配置文件所在目录——一混就加载错文件或静默失败
process.cwd() vs 配置文件目录
process.cwd() |
__dirname / import.meta.url |
|
|---|---|---|
| 含义 | 启动命令时的工作目录 | 当前模块文件所在目录 |
| 根目录执行 filter | 常为 monorepo 根 | 仍是 packages/xxx |
| 进包子目录执行 | 常为包目录 | 仍是 packages/xxx |
「用根目录 script 找到执行时的根,再加载 .env」这条思路,本质是:用稳定标记向上找仓库根,而不是赌相对路径。
自己写向上查找也很常见(例如找 pnpm-workspace.yaml):
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
function findMonorepoRoot(startDir: string): string {
let dir = resolve(startDir);
for (;;) {
if (existsSync(resolve(dir, 'pnpm-workspace.yaml'))) return dir;
const parent = dirname(dir);
if (parent === dir) throw new Error('monorepo root not found');
dir = parent;
}
}
本仓库把「找根 + 加载包级 / 根 .env」收进了 loadMonorepoEnv(import.meta.url),给 Drizzle、Astro 等非 Vite 入口用。
find-config:专门帮你「向上找指定文件」
pnpm add find-config
import findConfig from 'find-config';
import dotenv from 'dotenv';
const envPath = findConfig('.env'); // 从 cwd 向上找名为 .env 的文件
if (envPath) dotenv.config({ path: envPath });
适合:只关心「名叫 .env 的文件在祖先目录的哪一层」,不想自己写循环。
注意:
- 默认从
process.cwd()起找;若脚本 cwd 不在仓库树内,会找不到 - 找到的是「第一个叫
.env的」,不一定是你想要的「workspace 根」——若中间某层也有.env,会被拦住 - 要「根标记文件」(
pnpm-workspace.yaml/package.jsonworkspaces)时,自写findMonorepoRoot更可控
两种做法可以组合:先定位 monorepo 根,再 path.join(root, '.env')。
已经有 Vite 时:直接复用 loadEnv
monorepo 里只要某个 app 用了 Vite,vite 包已经在依赖图里。Drizzle、脚本、其它包不必再叠一套 dotenv-flow,可以:
import { loadEnv } from 'vite';
const mode = process.env.NODE_ENV || 'development';
const monorepoRoot = /* findMonorepoRoot(...) */;
// 第三个参数是前缀过滤器:
// '' = 加载全部键(给 Node / 工具用)
// 'VITE_' = 只取客户端前缀
const fileEnv = loadEnv(mode, monorepoRoot, '');
// 写入 process.env:通常「已有键不覆盖」
for (const [key, value] of Object.entries(fileEnv)) {
process.env[key] ??= value;
}
loadEnv 会按 Vite 规则合并 .env、.env.local、.env.[mode]、.env.[mode].local,省掉手写优先级。
在 vite.config.ts 里同样模式很常见:配置阶段把根目录 env 补进进程,再交给插件 / Workers 本地运行时:
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const root = /* monorepo root */;
for (const [k, v] of Object.entries(loadEnv(mode, root, ''))) {
process.env[k] ??= v;
}
return {
envDir: root, // 让 Vite 自身也从根目录读 .env*
// envPrefix: ['VITE_', 'PUBLIC_'], // 仅影响客户端暴露
};
});
要点回顾:
| API | 作用 |
|---|---|
envPrefix / 默认 VITE_ |
客户端能看到哪些键 |
envDir |
Vite 从哪个目录读 .env* |
loadEnv(mode, dir, prefix) |
在任意 Node 代码里复用同一套合并规则;prefix === '' 拿全部键 |
写入 process.env |
??= 或 dotenv 默认 override: false;CI / Secrets 优先 |
一条推荐决策路径
- 只有单个
.env、cwd 稳定 →dotenv.config()即可 - 少量固定文件要合并(例如包级 + 根
.env)→dotenv.config({ path: [...] });注意默认「先出现的赢」 - 需要
.env.local/.env.[mode]整套阶梯 →dotenv-flow,或vite的loadEnv(不要自己拼 mode 文件名) - monorepo 路径不稳 →
find-config找文件名,或向上找pnpm-workspace.yaml定根,再拼.env路径;避免写死../../.env - 仓库里已经有 Vite → 优先
loadEnv(mode, monorepoRoot, '')+??=,充分复用依赖,和前端构建行为对齐 - 需要进浏览器的变量 → 继续用前缀(
VITE_/ 自定义envPrefix);密钥只留在服务端 / Workers secrets,不要批量define进前端
以后再搜「Vite 只认 VITE_ 前缀吗」「dotenv path 数组」「monorepo 找根 .env」「复用 loadEnv」,按本文从上到下回看即可。