跳到正文

环境变量怎么管理:共用配置、个人覆盖与框架差异

两个人开发同一个项目,API 地址和功能开关大体相同,代理端口、调试服务和测试账号却各有各的设置。如果这些值都放在一份受 Git 追踪的 .env 里,改一次本机代理就会出现一个不该提交的 diff;顺手提交后,另一位开发者拉取代码,还要把自己的配置改回来。

把所有 .env 文件都忽略,又会遇到另一种麻烦:项目新增一个共用配置,代码已经同步,配置却还要另行通知。.env.example 如果只在初始化时复制一次,也可能慢慢落后于应用实际需要的字段。

我更倾向于先按维护责任区分配置:需要协作者共同维护的默认值随代码追踪,只对某个人生效的覆盖值留在本机。这个原则在 Vite、Nuxt、Next.js,甚至不使用 .env 的框架里都成立;不同的是,共用配置可能放在 .env,也可能放在配置模块、schema 或其他默认配置文件中。

文件名本身还回答不了全部问题。.env.local 是否会被读取、谁覆盖谁、变量是否进入浏览器,以及上线后能不能修改,都由后续工具链决定。

先区分配置归属和敏感性

这里的「个人配置」指配置只服务于某位开发者,例如本机代理地址、个人调试端口和临时连接目标;「服务端私密配置」指值不能向浏览器或无权访问的人公开,例如数据库密码。两者可能重合,也可能完全不同。

整个团队共用的一把服务端密钥,在协作范围上是共用的,在敏感性上仍然是秘密。个人选择的界面调试开关,则可以完全不敏感,只是不该影响同事。

配置归属与敏感性是两个独立维度

图中右上角的共用密钥,是最容易被漏掉的一类。团队需要有共同的获取和授权机制,但这不自动要求把明文值提交到 Git。没有特殊约定时,可以共享字段定义,把真实值通过部署平台、本机文件或密钥管理系统提供。

所以,更准确的约定是:

Git 中维护需要共同评审、同步和复现的配置;本机覆盖文件维护个人差异。敏感值是否适合进入仓库,再按它的权限、存储方式和项目约定单独判断。

这也解释了为什么「没被 Git 追踪」不能直接等同于「秘密」。.gitignore 管的是版本管理,无法阻止构建工具把某个值写进公开的 JavaScript。

.env 文件由谁读取

严格说,环境变量是进程运行环境中的键值;.env 是一种保存这些键值的文本文件。创建文件之后,还需要有加载器解析它,再交给进程环境、配置对象或构建器。

例如,同样是 Node.js 程序,以下两个命令就不同:

# 第一个命令不主动加载 .env;第二个显式加载
node server.mjs
node --env-file=.env server.mjs

Node.js 的 --env-file 文档还规定:启动前已经存在的环境变量优先;指定多个文件时,后面的文件覆盖前面的文件。同一个文件名,换一个加载器,优先级就可能改变。

对一个具体项目,至少要分别确认这些约定:

  • 哪些配置需要共同维护,哪些属于个人覆盖。
  • 实际启动入口读取哪些文件,以什么顺序合并。
  • 哪些值只在服务端使用,哪些会公开给浏览器。
  • 哪些值在构建时确定,哪些在服务启动或处理请求时读取。

文件命名只是这些约定的表达方式之一。

Vite 与 Nuxt 的差异发生在哪里

Nuxt 默认使用 Vite 构建前端,但 Nuxt 的配置初始化、服务端运行和客户端配置传递都有自己的入口。使用 Vite,并不意味着应用的所有配置都由 Vite 的 .env 加载器管理。

Vite 按模式合并文件

Vite 的环境文件规则覆盖四类文件:.env、.env.local、.env.[mode] 和 .env.[mode].local。默认开发模式是 development,默认构建模式是 production;指定其他模式时使用 --mode。

这里有一个很容易记反的细节:Vite 的模式文件优先于通用本机文件。.env.production 中的同名字段,会覆盖 .env.local。要覆盖某个模式的配置,应使用对应的 .env.production.local,或在启动前注入环境变量。

Next.js 也支持这些文件名,但中间两层的优先级不同。

Vite 与 Next.js 在 production 环境下的环境变量优先级

图中从上往下查找同名变量,最先找到的值生效。Vite 与 Next.js 的 .env.local 位置不同,所以不能把一张优先级表套到所有项目。

文件名中的 production 也需要写完整。Vite 默认生产构建读取 .env.production;.env.prod 是自定义模式文件,要配合 vite build --mode prod 使用。mode 与 NODE_ENV 是两个概念,不能仅通过把 NODE_ENV 改成 prod 来替代模式选择。

Nuxt 先加载自己的配置

Nuxt 4 的 .env 文档规定,开发、构建和生成阶段默认加载根目录 .env。选择其他文件时,可以显式运行:

# 在项目根目录运行,选择本次开发使用的配置文件
pnpm exec nuxt dev --dotenv .env.local

这里的 --dotenv 是选择文件,不承诺把 .env 和 .env.local 自动叠加。

对应的 Nuxt v4.6.2 配置加载源码会先调用 c12 的 setupDotenv,填充进程环境,再继续解析配置。以下保留函数外层和环境文件加载分支,省略了 layer 扫描、schema 解析及后续合并逻辑:

// nuxt/nuxt@v4.6.2/packages/kit/src/loader/config.ts:239-270(节选)
export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise<NuxtOptions> {
  // ...省略 layer 扫描与配置合并准备
  if (opts.dotenv !== false) {
    await setupDotenv({
      cwd: opts.cwd || process.cwd(),
      ...(typeof opts.dotenv === 'object' ? opts.dotenv : {}),
    })
  }
  // ...省略 schema 解析与后续配置加载
}

Vite 的环境文件加载与 Nuxt 的配置初始化入口

图中比较的是两个配置入口。Nuxt 后续仍会使用 Vite 编译应用;模块或自定义 Vite 配置也可能再引入其他环境文件。Nuxt 的配置初始化有独立加载流程,因此不能把 Vite 的自动合并规则当作 Nuxt runtimeConfig 的默认契约。

用同一组文件检查结果

下面的隔离实验使用 Node.js 26.12.0、Vite 8.3.4 和 Nuxt 4.6.2。所有值都是演示字符串;Vite 侧调用 loadEnv('production', ...),Nuxt 侧调用配置初始化使用的 loadNuxtConfig,每个场景运行在独立子进程中。

文件 演示字段
.env PROBE_VALUE=base、PROBE_BASE=shared
.env.local PROBE_VALUE=personal、PROBE_LOCAL=personal-only
.env.production PROBE_VALUE=production
.env.production.local PROBE_VALUE=production-personal

结果中只保留这三个演示字段,null 表示没有读到该字段:

加载入口 PROBE_VALUE PROBE_BASE PROBE_LOCAL
Vite,production 模式 production-personal shared personal-only
Vite,启动前注入同名变量 injected shared personal-only
Nuxt,默认配置加载 base shared null
Nuxt,显式选择 .env.local personal null personal-only
Node,显式依次加载基础与本机文件 personal shared personal-only

Nuxt 改选本机文件后,PROBE_BASE 消失了。这是「选择一份文件」与「合并多份文件」的可观察区别。反过来,如果本机文件只填写个人密钥,而基础文件保存共用 API 地址,不能只加一个 --dotenv .env.local 就认为两者都会生效。

实验输入与结果快照保留了版本、输入和输出。这次验证的是加载函数与 Node CLI,没有启动完整应用,也不代表验证了每个平台适配器的生产行为。

框架的文件加载、变量注入和模板 Git 忽略规则需要分别核对;Vue 或 React 本身不为所有应用规定同一套 env 协议。完整内容见前端框架的环境配置对照弹窗。

后端也要表达共用默认值与部署差异,但可以使用配置模块、profile 或 settings 文件,不必照搬同一组 dotenv 文件名。完整内容见后端框架的配置加载对照弹窗。

构建时确定的值,上线后不会自动重读

.env.local 中的 local 表达本机归属,不能直接推导变量在运行时读取。Vite 的客户端公开变量即使来自本机文件,也可能在构建时写成字符串;静态产物被部署到哪里,这个字符串就跟到哪里。

客户端构建变量与服务端运行时变量的读取时机

如果目标是同一份产物部署到测试和生产环境,服务端动态读取宿主环境通常更合适。浏览器需要的非敏感配置,可以由服务端白名单接口或页面初始化数据提供;若项目只有静态文件,则需要构建不同产物,或另外设计公开的运行时配置文件及缓存策略。

公开前缀也不是安全包装。VITE_*、NEXT_PUBLIC_*、PUBLIC_*、Nuxt runtimeConfig.public,以及显式声明的客户端字段,表达的都是让客户端获得值的意图。把真实密钥移到 .env.local,不会撤销这个暴露意图。

项目里怎样落地这套约定

我会先检查项目已有加载方式,再决定文件名。对于已经采用 Vite 四文件模式、且允许追踪非敏感共用配置的项目,可以使用下面的划分:

文件 内容 版本管理
.env 共用默认值 追踪
.env.production 生产环境共用配置 追踪
.env.local 个人通用覆盖 忽略
.env.production.local 个人对生产模式的覆盖 忽略
.env.example 字段说明或占位示例 追踪

对应规则放进项目根目录 .gitignore:

# 本机环境覆盖
.env.local
.env.*.local

已有 *.local 且还需要忽略其他本机配置时,继续保留即可。共用文件中放 API 基址、非敏感开关和可复现的默认值;个人端口、代理和调试账号留在本机覆盖文件。服务端凭据按项目授权通过本机或部署渠道提供。

如果项目沿用 Nuxt、Next.js、Laravel 等模板的整体忽略策略,也可以继续保留,把共用默认值放在框架配置模块中,并维护 .env.example 或 schema。示例文件需要说明复制和加载方式;更新字段时同步更新配置契约,不能依赖每个人永远记得重新复制。

让启动命令承担加载约定

Nuxt 的 --dotenv 用于选择一份文件。需要叠加共用与本机配置时,应在应用入口之前完成合并,或者采用已有的加载工具,并在项目脚本中固定顺序。

例如,Node.js 22.9+ 可以显式执行下面的命令,基础文件必需,本机文件可选:

# server.mjs 是项目的服务端入口;后面的文件覆盖前面的文件
node --env-file=.env --env-file-if-exists=.env.local server.mjs

将实际入口写进 package.json 的启动脚本后,协作者只需执行统一命令。这个例子展示的是 Node 的加载机制;接入 Nuxt、测试工具或其他 CLI 时,还要检查后续是否存在第二次加载,以及它是否尊重已经注入的进程变量。

README 至少记录实际文件名、优先级、默认启动命令、必须提供的字段,以及部署环境在哪里注入。变量名可以作为契约,真实密钥不需要出现在说明和日志里。

检查 Git 规则确实命中

Git 的忽略机制只影响未追踪文件,不会把已经追踪的 .env.local 自动移出索引。验证时要同时检查共用文件没有被误忽略、本机文件确实被忽略:

# 在项目根目录执行;共用文件预期没有匹配输出
git check-ignore -v --no-index -- .env .env.production .env.example

# 本机覆盖预期输出命中的忽略规则
git check-ignore -v --no-index -- .env.local .env.production.local

这里第一条命令没有匹配时退出码为 1,属于预期结果。--no-index 能检查已追踪路径是否匹配规则;再用 git ls-files -- .env.local 确认索引状态。确实需要移出索引时,git rm --cached -- .env.local 会保留工作区文件,但对应提交仍需要按项目流程评审。

校验字段,避免静默回退

文件加载成功,只说明解析完成。端口范围、URL 格式、布尔值转换,以及生产环境必须提供的字段,还要在应用入口校验。字符串 "false" 转成 JavaScript 布尔值仍然为真,不能直接用 Boolean(process.env.FEATURE_ENABLED) 判断开关。

可以使用框架内建 schema,或项目已有的校验库;缺少必需凭据时明确启动失败或拒绝相应功能。诊断信息只输出字段名和来源状态,避免用整份 process.env 或完整配置对象排查问题。

总结

配置管理需要同时明确维护责任和读取契约:共用默认值随代码同步,个人差异留在本机;应用实际读取哪些文件、怎样覆盖、何时求值,则由启动入口和框架决定。同样叫 .env.local,不代表各框架都会自动加载或采用相同优先级,共用配置也可以放在配置模块或 schema 中。

敏感性和配置归属要分开判断。Git 忽略规则不能阻止值进入浏览器,构建时写入静态产物的值也不会因部署环境改变而自动重读。落地时应把加载顺序固定在启动入口,分别核对 Git 状态、公开范围与字段校验;文中的隔离加载实验没有覆盖完整应用和各部署适配器的生产行为。

前端框架的环境配置对照

主流前端框架分别怎样处理

以下规则按 2026-10-21 的官方文档和公开模板核对。框架升级可能改变 API,模板也可能随创建选项变化;文件加载规则和模板中的 Git 规则分别列出。

Vue / React 项目要先看构建工具

Vue 和 React 本身并不为所有应用规定一套 .env 文件加载协议。使用 Vite 初始化的项目,通常沿用 Vite 的模式文件和 VITE_ 客户端前缀;换成其他构建器,就要按对应工具读取。

create-vite 的 Vue TypeScript 模板与 create-vue 基础模板都包含 *.local,没有整体忽略 .env 或 .env.*。这允许项目把适合共用的 .env、.env.production 纳入 Git,把个人覆盖放进 .local 文件。

Rsbuild 的环境变量机制也区分基础、模式和本机文件,但默认客户端前缀是 PUBLIC_,自定义环境模式使用 --env-mode。所以,仅凭「这是 React 项目」,无法判断该写 VITE_API_URL、PUBLIC_API_URL 还是其他字段。

旧项目中的 Vue CLI和 Create React App也有自己的约定,客户端前缀分别为 VUE_APP_ 和 REACT_APP_。这两套旧工具链应按维护状态和现有项目处理,不作为新项目选型的默认推荐。

Nuxt 4:文件选择与运行时配置分开

Nuxt 的文件加载方式已经在实验中展示;Git 方面,Nuxt v4 starter默认忽略 .env 和 .env.*,保留 .env.example。

应用配置建议通过 runtimeConfig声明。下面的配置放在根目录 nuxt.config.ts;apiSecret 留在服务端,public.apiBase 可供浏览器读取:

// nuxt.config.ts:声明私密字段与公开字段
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: '',
    public: {
      apiBase: '/api',
    },
  },
})

对应的运行时覆盖名称是 NUXT_API_SECRET 和 NUXT_PUBLIC_API_BASE。它们需要匹配已声明的配置结构;不要把配置初始化时读取的任意 process.env.SOME_KEY,当成上线后也会自动重新解析的运行时映射。

纯静态生成后的页面已经没有 Nuxt 服务端来读取新环境变量;保留服务端的部署,才有相应的运行时配置注入入口。

Next.js 16:支持分层,模板默认全部忽略

Next.js 的加载顺序是进程环境最高,其次依次为模式本机文件、通用 .env.local、模式文件、基础 .env。测试环境跳过通用 .env.local,以减少个人覆盖对测试复现的影响。

它的 create-next-app 模板使用 .env*,连 .env.example 默认也会匹配。需要追踪示例或测试默认值时,要显式增加对应例外。官方环境变量文档本身也提到 .env.test 可以纳入仓库,.env.test.local 则留在本机。

客户端 NEXT_PUBLIC_ 字段通常在构建时内联,修改运行容器的变量不会改变已经生成的值。服务端变量若要在运行时读取,还需处于动态执行路径,不能把预渲染结果当作每次请求都会重新求值。

Astro 7:沿用模式文件,增加类型与访问边界

Astro 环境变量沿用 Vite 的环境文件机制,常规客户端字段使用 PUBLIC_。配置文件的执行时机另有边界:astro.config.mjs 在常规环境文件加载之前求值,需要读取 .env 时,可显式调用 Vite 的 loadEnv。

astro:env进一步通过 schema 声明类型、执行位置和访问方式。context: 'client' 与 context: 'server' 决定使用位置,access: 'public' 与 access: 'secret' 决定构建和读取方式;其中服务端 public 值也不等于浏览器公开值,判断值是否向浏览器公开时,需要同时确认 context 和 access。

Git 方面,核对到的 Astro 最小模板列了 .env 和 .env.production,并没有用 .env.* 忽略所有变体。添加 .env.local 后,应检查实际匹配结果,不能凭它的名字推断模板已经保护了它。

SvelteKit:3.x 与旧教程的 API 已有区别

SvelteKit 当前环境变量文档使用 src/env.ts 声明字段,通过 $app/env/private 和 $app/env/public 访问;字段可以显式设置 public: true、校验规则,以及是否 static。这些能力在 2.62 以实验选项引入,3.x 将旧 $env/* 模块标为弃用,不能只按旧版 PUBLIC_ 前缀推导当前显式字段的公开范围。

旧 $env/static/* 与 $env/dynamic/* 的使用方式仍可见于存量项目,具体迁移按 SvelteKit 3 指南处理。静态读取和动态读取也会影响部署后能否改变值。

环境文件仍需按开发、构建和部署入口理解。sv 的最小模板忽略 .env、.env.*,但显式保留 .env.example 和 .env.test。生产使用 Node adapter时,环境文件的读取也要按启动方式显式处理,不能把开发服务的便利行为直接外推到生产服务器。

Angular 22:常规入口是构建配置与文件替换

Angular CLI 的标准环境配置通过 ng generate environments 生成环境文件,再用 angular.json 的 fileReplacements 和 --configuration 选择不同文件。

// src/environments/environment.ts:可进入浏览器的共用配置
export const environment = {
  production: true,
  apiUrl: 'https://api.example.com',
}

这些 TypeScript 文件属于应用源码,通常随代码维护。个人覆盖需要另行约定文件和构建入口;Angular 开发服务器使用 Vite 的实现细节,不能代替 Angular CLI 对 .env 加载行为的公开约定。被前端代码引用的环境对象会进入浏览器,无法承担服务端密钥存储。

React Router / Remix 与 TanStack Start:同时看服务端和构建器

React Router 的框架构建命令使用 Vite;客户端公开变量要按 Vite 的暴露规则理解,服务端变量还要看启动命令和部署宿主。官方默认模板明确列了 .env,也需要项目补齐新引入的本机变体规则。

存量 Remix 2 项目可以参考它的环境变量说明:开发入口提供 dotenv 支持,生产值由部署宿主注入;需要交给浏览器的配置,通过服务端 loader 显式选择并返回。这段说明针对 Remix 2,不直接充当所有 React Router 版本的启动契约。

TanStack Start 的指南则直接举出了共用 .env 提交、个人 .env.local 忽略的做法。它同时支持不同构建器:Vite 路径的客户端前缀是 VITE_,Rsbuild 路径默认是 PUBLIC_。文件的精确优先级应回到选用构建器的规则,而不是把指南中的文件清单当作跨构建器统一顺序。

把模板的 Git 规则单独列出来

这些是上述固定提交中与环境文件有关的默认规则,不包含用户自己的全局忽略设置。表格只比较核对到的模板,不承诺覆盖同一框架的所有创建选项。

模板 默认忽略 显式保留的环境文件
create-vite / create-vue *.local 无需额外例外
Nuxt v4 starter .env、.env.* .env.example
create-next-app .env* 无
Astro minimal .env、.env.production 未整体忽略其他变体
sv minimal .env、.env.* .env.example、.env.test
React Router default .env 未整体忽略其他变体

加载器可以支持一组文件,模板却选择全部忽略;模板也可能只忽略其中几份文件。这两件事各有职责,检查项目时需要分别确认。

后端框架的配置加载对照

后端框架也在区分共用默认值与部署差异

前端生态常用文件后缀来表达覆盖关系,其他框架可能选择配置模块、profile 或专门的共享文件。只盯着 .env.local,反而会漏掉相同的协作需求。

NestJS:由配置模块显式选择文件

NestJS 项目引入并注册 @nestjs/config后,默认读取工作目录 .env,进程环境中的同名值优先。envFilePath 可以传数组,而且默认是数组前面的文件优先。

下面的配置放进根模块的 imports,所需依赖为 @nestjs/config;它显式选择本机覆盖与共用默认值:

// app.module.ts 的 imports 配置节选
ConfigModule.forRoot({
  envFilePath: ['.env.local', '.env'],
})

这套数组顺序与 Node 多个 --env-file 的后者优先不同。NestJS 的 TypeScript starter列了 .env,但没有因此保证项目后来新增的 .env.local 也已忽略。

Spring Boot:默认配置与 profile 文件

Spring Boot 的外部化配置支持 application.properties、YAML、profile 配置、操作系统环境变量和命令行参数等来源。常见的共用默认值放在 application.yml,特定环境放在 application-{profile}.yml,部署差异再由外部配置覆盖。

默认外部化配置机制并不是 Vite 式的四份 .env 自动合并。这里仍然需要决定哪些配置适合随代码维护、哪些属于本机或部署环境,只是表达方式换成了 profile 和外部配置源。

Laravel:忽略真实 .env,维护示例和配置默认值

Laravel 13 的配置机制使用 .env.example 提供字段和示例,安装时复制为 .env,再由 config/ 中的配置文件读取。真实 .env 默认不提交;环境文件也可以根据外部 APP_ENV 或命令参数选择,并非默认把 .env.local 叠加在基础文件之上。

启用 config:cache 后,常规请求不再重新加载 .env,应用应通过 config() 获取配置,env() 留在配置文件里使用。配置是否会变化,还与框架缓存有关。

Flask 与 Django:共享文件甚至可以换个名字

Flask CLI在安装 python-dotenv 后读取 .flaskenv 和 .env。文档建议将适合共享的启动设置放进可提交的 .flaskenv,个人私密值放进不提交的 .env;同名值优先级为命令行环境、.env、.flaskenv。

这和「共用配置追踪、个人覆盖忽略」的原则相通,但 .env 在这里承担了另一种角色。Flask 文档中适合共享的 public 设置,也不等于前端构建器里自动暴露给浏览器的 public 字段。

Django 的 settings本身是 Python 模块,通过 DJANGO_SETTINGS_MODULE 选择;如果项目采用 dotenv 或 django-environ,应把它们作为额外加载机制登记,而不当成 Django 原生的四文件协议。共用默认值也完全可以留在受 Git 管理的 settings 模块中。