dotenv 与 loadEnv

已修订更新于

首发于

本地跑 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 几乎没接触过,建议先读官方 Env Variables and Modes,再回来会更顺。

暴露与注入

很多人把「Vite 加载 .env」和「变量出现在 process.env」混为一谈。它们不是一回事。

1. 默认前缀:客户端只认 VITE_

Vite 读 .env* 时,注入到客户端代码(import.meta.env)的变量,默认要带 envPrefix 前缀。默认前缀是 VITE_。

所以:

前缀可以改,例如同时暴露 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 做字符串替换,但:

真正需要「像 Node 一样」读 process.env 的场景,通常是:

这些场景要的是:把 .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:

若仓库里已经有 Vite,直接用它的 loadEnv 更省事(见文末)。


monorepo:文件在哪?不要写死 ../../.env

在 packages/db/drizzle.config.ts 里写:

dotenv.config({ path: '../../.env' });

脆弱之处在于:

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 的文件在祖先目录的哪一层」,不想自己写循环。

注意:

两种做法可以组合:先定位 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 优先

一条推荐决策路径

  1. 只有单个 .env、cwd 稳定 → dotenv.config() 即可
  2. 少量固定文件要合并(例如包级 + 根 .env)→ dotenv.config({ path: [...] });注意默认「先出现的赢」
  3. 需要 .env.local / .env.[mode] 整套阶梯 → dotenv-flow,或 vite 的 loadEnv(不要自己拼 mode 文件名)
  4. monorepo 路径不稳 → find-config 找文件名,或向上找 pnpm-workspace.yaml 定根,再拼 .env 路径;避免写死 ../../.env
  5. 仓库里已经有 Vite → 优先 loadEnv(mode, monorepoRoot, '') + ??=,充分复用依赖,和前端构建行为对齐
  6. 需要进浏览器的变量 → 继续用前缀(VITE_ / 自定义 envPrefix);密钥只留在服务端 / Workers secrets,不要批量 define 进前端

以后再搜「Vite 只认 VITE_ 前缀吗」「dotenv path 数组」「monorepo 找根 .env」「复用 loadEnv」,按本文从上到下回看即可。

评论

可对整篇留言,或划选正文中的句子后评论该段。

登录后即可发表评论。

  • 还没有公开评论。
↑