ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vite环境变量与多环境打包:.env加载顺序、构建时替换和运行时配置

Vite环境变量与多环境打包:.env加载顺序、构建时替换和运行时配置

1. 别急着写配置:先把 Vite 环境变量这套机制捋直

Vite 项目里的环境变量(.env 文件)配合自定义打包,几乎是从「能跑」走到「能上线」必须跨的一道坎。同一套代码要跑本地、测试、预发、正式至少四套环境,接口域名、静态资源前缀、埋点开关、调试面板是否显示,全都不一样。很多同学第一次切到 Vite,以为在根目录放个 .env 写两行就完事,等到 build 出来丢到服务器上一看,接口地址还是http://localhost,回头再翻文档才发现:变量确实写了,但打包的时候根本没被读进去。这篇就把 Vite 里 .env 文件的加载规则、mode 的语义、import.meta.env的替换时机、多环境产物怎么分开出、运行时配置怎么兜底,以及我这一路踩过的坑,全部摊开讲一遍。适合已经上手 Vite、正准备做多环境部署的朋友,也适合刚接手一个满目录.env.xxx的老项目、看得一头雾水的同学——看完你至少能判断出别人的配置为什么这么写。

先说结论性的定位:Vite 的环境变量本质上不是「运行时从某个地方读配置」,而是构建时把字符串替换进代码。这条认知决定了后面所有的行为,也决定了哪些事能做、哪些事做了会出事故。很多人把 .env 当成后端那种「改配置文件重启一下就生效」的东西,思路一错,后面全是弯路。

1.1 一个真实场景:为什么直接改 .env 打包没生效

我遇到过最典型的一类问题:项目根目录躺着.env、.env.development、.env.production三个文件,本地npm run dev一切正常,执行npm run build之后产物里的接口地址依然是测试环境的。打开.env一看,VITE_API_BASE=http://127.0.0.1:8080明晃晃写着,问题出在哪?

出在加载顺序上。Vite 不是随便挑一个 .env 文件读,它按 mode 挑,而且.env.production的优先级高于.env。你在.env里改的那一行,被.env.production里的同名变量直接盖掉了,代码里读到的永远是.env.production那一份。这个机制本身没毛病,它就是为多环境设计的,问题在于很多人不知道有优先级这回事。

还有一类更隐蔽的:变量名没加前缀。代码里写import.meta.env.API_BASE,.env里写API_BASE=xxx,本地看着好像也没报错,只是值是undefined,接口请求直接打到当前域名下去了,开发环境因为配了代理反而能通,一上线就全挂。这就是VITE_前缀那条安全边界在起作用——不加前缀的变量根本不会被注入到客户端代码里。

最后还有一类,改完 .env 不重启。Vite 的 dev server 启动时就把 env 读进内存了,你改文件它不会热更新这一部分,必须 Ctrl+C 重来。这个坑新人踩得最多,改完刷新页面没变化,就开始怀疑人生。

提示:判断「是变量没读到」还是「是代码没写对」,最快的办法是在组件里console.log(import.meta.env)打一整坨出来看。如果打印出来的对象里压根没有你的键,问题在加载阶段;如果键在、值不对,问题在优先级;如果键在、值对,但业务没生效,那是代码逻辑问题。三步定位,别一上来就改配置。

1.2 构建时替换和运行时读取,是两件完全不同的事

这是整篇文章最核心的一个概念,值得单独拎出来讲。Vite 在打包时做的是文本替换:你的源码里写着import.meta.env.VITE_API_BASE,构建工具会把它原地替换成一个字符串字面量"https://api.example.com"。替换完之后,产物里已经不存在「读环境变量」这个动作了,只剩一个写死的字符串。

这个设计带来两个后果,一好一坏。

好处是产物极其干净,运行时零开销,也没有额外的配置文件请求,CDN 上一放就能跑,甚至连 Node 环境都不需要。坏处是同一份产物没法切换环境。你把dist打包好交给运维,运维说「这套先发测试环境」,不好意思,改不了,除非重新打包。想要一份产物跑多套环境,就必须走运行时配置的路子,这个后面单独讲。

理解了「构建时替换」,很多奇怪现象就顺了:为什么.env里的密钥会被打进 js 文件?因为是文本替换。为什么改了服务器上的 .env 没用?因为服务器上根本没有 .env,那个值早就固化在产物里了。为什么import.meta.env[key]这种动态取值拿不到?因为替换是基于静态文本匹配的,它不知道key运行时是什么。

2. .env 文件的加载顺序与命名规则,一张表说清

Vite 用 dotenv 和 dotenv-expand 处理环境变量文件,规则不复杂,但组合起来有四五种情况,光靠记忆容易出错。我习惯直接摆一张表,遇到问题对着表查,比翻文档快。

2.1 五种文件的优先级对照

文件名加载时机是否该提交仓库优先级
.env所有模式都加载是最低
.env.local所有模式加载(test 模式除外)否,必须进 .gitignore高于.env
.env.[mode]只在指定 mode 下加载是高于.env.local
.env.[mode].local只在指定 mode 下加载否,必须进 .gitignore最高
进程环境变量(shell / CI 注入)始终存在不涉及覆盖以上所有

优先级从低到高排下来是:.env<.env.local<.env.[mode]<.env.[mode].local< 已存在的进程环境变量。注意最后一条,已经存在于process.env里的键,不会被 .env 文件覆盖。这条在 CI 里特别有用:流水线上注入的值天然优先级最高,不会因为仓库里有个.env.production就失效。

.local后缀的设计意图很明确:放个人本地的覆盖值,比如你本机接口走的是127.0.0.1:3001而别人走3001以外的端口,就写进.env.local,不提交。团队里最常见的翻车是有人把.env.local提交了,结果把所有人的本地配置统一改掉,然后大家在群里互相怀疑对方改了代码。

2.2 mode 到底是个什么东西,别和 NODE_ENV 搞混

mode是 Vite 自己的概念,跟NODE_ENV不是一回事,这一点必须掰开说,因为它是多环境打包最容易出错的地方。

默认情况下,vite dev的 mode 是development,vite build的 mode 是production。你可以用--mode显式指定,比如vite build --mode staging。这时候会出现一个反直觉的组合:

  • import.meta.env.MODE的值是"staging"
  • import.meta.env.PROD的值是true
  • import.meta.env.DEV的值是false
  • process.env.NODE_ENV的值依然是"production"

为什么会这样?因为PROD/DEV这两个布尔值看的是「当前是不是 build 命令」,而不是 mode 字符串。build 命令默认把NODE_ENV设成 production,只要你没手动改,它就是 production。所以判断「是不是生产环境」千万别用import.meta.env.MODE === 'production',你打了个 staging 的包,逻辑就全错了。正确姿势是用import.meta.env.PROD判断「是否构建产物」,用import.meta.env.MODE判断「具体是哪套环境」。

反过来在 dev 阶段:vite --mode staging启动,MODE是 staging,但DEV是 true、PROD是 false,NODE_ENV是 development。这个组合是合理的——你在本地用测试环境的配置跑开发服务器,但代码还是开发模式,热更新照常。

我在一个项目里见过这样的判断:if (import.meta.env.MODE === 'production') { 关掉调试面板 }。结果 staging 包打出来调试面板大开着,被测试同学当成 bug 提了三个单子。改法很简单,换成if (import.meta.env.PROD) { 关掉 },一行搞定。

注意:如果你的 CI 里显式设置了NODE_ENV=staging这类非标准值,某些依赖库的行为可能不受控(它们只认 production/development)。别为了方便去改NODE_ENV,用 mode 就够了。

2.3 VITE_ 前缀不是摆设,是一条安全边界

默认情况下,只有以VITE_开头的变量会被注入到客户端代码。这条规则的存在意义,是防止你把不该暴露的东西打进 bundle。想象一下.env里同时写着DB_PASSWORD=xxx和VITE_API_BASE=xxx,如果没有前缀区分,两个都会被打进 js 文件,任何人打开浏览器 F12 就能看到数据库密码。

所以这个前缀实际上是「我愿意把这个值公开给全世界」的声明。凡是带VITE_的,都要默认它会被人看到。地图服务的 key、公开的 CDN 域名、埋点的应用 ID,这些没问题;签名密钥、后台接口的 token、私有服务的地址,一律不能加这个前缀。

前缀本身可以改,通过envPrefix配置:

// vite.config.js import { defineConfig } from 'vite' export default defineConfig({ envPrefix: ['VITE_', 'APP_'], })

有些团队会用APP_作为统一前缀,迁移老项目时用数组把两个都留着,过渡期更平滑。但我不太建议前缀搞太多,两三个就到顶了,再多容易出现「这个变量到底加没加前缀」的混乱。而且前缀越短越通用,撞名的概率也越高,VITE_这种带项目语义的反而更安全。

还有一个细节:envPrefix改完之后,老代码里读import.meta.env.VITE_XXX的地方如果变量改名了,会静默变成 undefined,不会报错。改这种配置的时候,全局搜一遍引用位置,别只改配置文件。

3. 自定义打包落地:一套代码出三套产物

理论讲完,进入能直接抄的部分。我一般按「三套环境」来设计:development 本地开发、staging 测试/预发、production 正式。够用,也不至于把配置搞得太碎。如果团队有独立的预发环境,就在 staging 和 production 之间再插一个,结构完全一样。

3.1 目录结构与 .env 文件的写法

project-root/ ├── .env # 所有环境共享的变量 ├── .env.development # 本地开发 ├── .env.staging # 测试 / 预发 ├── .env.production # 正式环境 ├── .env.example # 变量清单模板,提交仓库 ├── .gitignore # 里面要有 .env*.local └── vite.config.js

具体内容我按这个套路写:

# .env —— 共享部分,只放全环境一致的 VITE_APP_NAME=demo-console VITE_APP_VERSION=2.4.1 # .env.development VITE_API_BASE=http://127.0.0.1:8080/api VITE_ENABLE_MOCK=true VITE_ASSET_BASE=/ # .env.staging VITE_API_BASE=https://staging-api.example.com/api VITE_ENABLE_MOCK=false VITE_ASSET_BASE=https://staging-cdn.example.com/static/ # .env.production VITE_API_BASE=https://api.example.com/api VITE_ENABLE_MOCK=false VITE_ASSET_BASE=https://cdn.example.com/static/

几个书写上的坑先记一下。第一,所有值读出来都是字符串。VITE_ENABLE_MOCK=false拿到的是字符串"false",它是个真值,if (import.meta.env.VITE_ENABLE_MOCK)会进 true 分支。要么在代码里显式比较=== 'true',要么封一个转换函数统一处理,别指望它自动变布尔。第二,值里有空格、有#、有多行内容,用双引号包起来。第三,dotenv-expand 支持${VAR}引用别的变量,比如VITE_API_URL=${VITE_API_BASE}/v2,这个特性挺好用,但要注意被引用的变量必须在同一份或更高优先级的文件里已经定义过。

3.2 package.json 脚本与 --mode 的组合

{ "scripts": { "dev": "vite", "dev:staging": "vite --mode staging", "build:staging": "vite build --mode staging", "build": "vite build --mode production", "preview:staging": "vite preview --mode staging" } }

build:staging会加载.env、.env.staging,以及可能存在的.env.local、.env.staging.local,产物默认输出到dist。这里有个隐患:三套环境的产物都往 dist 里写,会互相覆盖。打包脚本连着跑的时候,后一个把前一个盖掉,运维拿到的可能是错的那份。

解决办法是让 outDir 跟着 mode 走:

// vite.config.js import { defineConfig, loadEnv } from 'vite' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { base: env.VITE_ASSET_BASE || '/', build: { outDir: `dist/${mode}`, sourcemap: mode !== 'production', }, } })

dist/development、dist/staging、dist/production三个目录并排,谁也不会踩谁。base也跟着环境走,staging 的资源走测试 CDN,production 走正式 CDN,一份配置全解决。sourcemap在非生产环境打开,线上关掉,既方便排查又不会把源码地图暴露出去。

这里有个值得说的取舍:为什么不用.env里的VITE_OUT_DIR去控制输出目录?因为build.outDir是构建配置,不是运行时变量。写在 .env 里虽然也能用loadEnv读到,但它天然属于「配置的配置」,混进业务变量里会让.env文件的语义变混乱——别人打开.env.production想看看接口地址,结果一堆构建参数糊在里头。我的原则是:影响产物的放vite.config.js,影响业务逻辑的放.env。

3.3 loadEnv 的正确用法与三个参数

loadEnv(mode, envDir, prefixes)这三个参数必须理解清楚,否则很容易读出空对象。

第一个mode就是当前模式字符串,直接传进来。第二个envDir是 .env 文件所在目录,一般process.cwd(),但如果你把配置文件挪到了别的目录,或者用 monorepo 结构,就需要显式指向项目根,或者用envDir配置项统一指定。第三个prefixes最容易被忽略:默认值是'VITE_',也就是说默认只返回带前缀的变量。

如果你在vite.config.js里想读一个不带前缀的变量(比如只在构建脚本里用的API_SECRET),必须传空字符串''才能全部拿到:

const env = loadEnv(mode, process.cwd(), '') console.log(env.API_SECRET) // 只有传 '' 才读得到

这个特性既是方便也是陷阱:你在配置文件里能读到密钥,但它不会被注入到客户端代码,因为客户端只认VITE_前缀。所以想用一个不带前缀的变量做代理配置,完全可以:

server: { proxy: { '/api': { target: env.PROXY_TARGET, changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ''), }, }, }

PROXY_TARGET不带前缀,只在 dev server 里用,永远不会出现在产物里。这是我认为最舒服的一种分工:带前缀的给客户端,不带前缀的给构建和开发服务器。

提示:loadEnv在配置文件顶层调用时,如果.env文件不存在,它返回空对象而不是报错。所以「配置写了但没生效」有时候是文件路径问题,打个console.log(Object.keys(env))就能确认到底读了几个键。

3.4 补上类型声明,别再让编辑器一片红

TypeScript 项目里访问import.meta.env.VITE_XXX,如果不做声明,编辑器会报「属性不存在」。补一个src/vite-env.d.ts:

/// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_APP_NAME: string readonly VITE_APP_VERSION: string readonly VITE_API_BASE: string readonly VITE_ENABLE_MOCK: string readonly VITE_ASSET_BASE: string } interface ImportMeta { readonly env: ImportMetaEnv }

写法上有个小技巧:布尔和数字类的变量,在 .env 里是字符串,但在声明里可以标成更精确的联合类型,比如readonly VITE_ENABLE_MOCK: 'true' | 'false',这样配合=== 'true'判断时编辑器能帮你兜一层。声明里的键名要和 .env 文件严格对齐,我一般会写个简单的 Node 脚本,读.env.example自动生成这段接口,变量多了以后省事很多,也不会漏。

4. 进阶场景:define、运行时配置与动态 base

三套环境跑顺了,接下来会遇到几个绕不开的定制需求。这些需求看着零散,但背后都是同一个问题——构建时替换的能力边界在哪。

4.1 define 注入非 VITE_ 前缀的常量

有些常量不适合放 .env,比如从package.json里读的版本号、构建时间戳、git 提交短哈希。这些值要么是动态算出来的,要么不该让人手动维护。用define直接注入最省事:

import { defineConfig } from 'vite' import { readFileSync } from 'node:fs' const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(pkg.version), __BUILD_TIME__: JSON.stringify(new Date().toISOString()), __BUILD_ENV__: JSON.stringify(process.env.BUILD_ENV || 'local'), }, })

配套的类型声明:

declare const __APP_VERSION__: string declare const __BUILD_TIME__: string declare const __BUILD_ENV__: string

define有三个必须知道的点。第一,值必须是 JSON 序列化后的字符串。你写__APP_VERSION__: pkg.version,如果版本号是2.4.1,替换后代码里会变成const v = 2.4.1,语法直接报错。必须JSON.stringify包一层变成"2.4.1"。第二,它是纯文本替换,会命中 node_modules 里的代码。所以键名一定要带特殊前缀(__这种),别用VERSION、DEBUG这种太通用的词,否则某个依赖里同名变量会被你一起替换掉,排查起来极其痛苦。第三,define 会在开发和生产都生效,不是只在打包时。开发时它也是字面量替换,所以别指望通过改 define 来做热切换。

4.2 一份产物跑多环境:运行时配置方案

前面说过,构建时替换的产物是固化的。如果运维要求「一套包,配置在服务器上改」,就得换成运行时读取。

思路很朴素:不打进 js,改成启动后异步拉一个 JSON。

第一步,把配置模板放public/config.json:

{ "apiBase": "https://api.example.com/api", "enableMock": false, "sentryDsn": "" }

Vite 会把public目录下的文件原样拷贝到产物根目录,不参与打包,不生成 hash,服务器上直接改就行。

第二步,在应用启动前加载它:

// src/config/runtime-config.js let runtimeConfig = null export async function loadRuntimeConfig() { const res = await fetch(`${import.meta.env.BASE_URL}config.json`, { cache: 'no-store', }) if (!res.ok) throw new Error(`加载运行时配置失败: ${res.status}`) runtimeConfig = await res.json() return runtimeConfig } export function getConfig(key) { if (!runtimeConfig) throw new Error('运行时配置尚未加载') return runtimeConfig[key] }

第三步,在main.js里改成先加载再挂载:

import { createApp } from 'vue' import { loadRuntimeConfig } from './config/runtime-config' import App from './App.vue' loadRuntimeConfig() .then(() => createApp(App).mount('#app')) .catch((err) => { document.body.innerHTML = `<pre>配置加载失败:${err.message}</pre>` })

几个实操细节。cache: 'no-store'不能省,否则浏览器或 CDN 缓存了旧配置,改完不生效又得怀疑人生。路径必须拼import.meta.env.BASE_URL,否则部署到子路径下会 404。加载失败要有兜底页面,直接白屏最难排查。这个方案牺牲了一次额外请求(几 KB,走 CDN 可以忽略),换来的是配置与产物解耦,灰度、回滚、多环境共包都能玩。

两种方案怎么选,我给一张对照表:

维度构建时注入(.env)运行时配置(config.json)
配置生效时机打包那一刻页面加载时
改配置要不要重新打包要不要
是否多一次网络请求否是(可缓存)
配置是否可见于源码会被替换进 bundle只存在于 json
适合的场景环境相关的资源前缀、开关需要运维改的接口地址、密钥类
出错时的表现值写死,错了要重发版本改错文件即可回滚

我的实际做法是两者混用:base、构建优化开关走 .env,接口域名这类运维可能调整的走运行时配置。别为了统一而统一,混着用反而不拧巴。

4.3 不同环境不同 base,路径拼接的三个坑

base这个配置看着简单,实际是多环境打包最容易翻车的地方。它决定了两件事:一是产物里引用 js/css 的路径前缀,二是import.meta.env.BASE_URL的值。

第一个坑:base必须以/结尾,或者干脆是/。写成https://cdn.example.com/static(少个斜杠),拼接出来的资源路径会变成https://cdn.example.com/static/assets/index-abc.js——咦,这个看着是对的?不对,是因为 Vite 内部会帮你补,但你自己在代码里手动拼BASE_URL + 'xxx'的时候,就变成staticxxx了。所以自己拼路径时,统一用new URL('relative/path', import.meta.env.BASE_URL),别手写字符串相加。

第二个坑:base是./(相对路径)的时候,BASE_URL的值就是./,在二级路由页面下手动拼路径会变成相对当前路由的路径,直接 404。相对 base 只适合纯静态的单页应用且没有嵌套路由的情况,其他一律用绝对路径。

第三个坑:.env里的VITE_ASSET_BASE空值处理。写VITE_ASSET_BASE=读出来是空字符串,base: env.VITE_ASSET_BASE会让 base 变成空字符串,Vite 的行为是按/处理,但不如显式写|| '/'稳妥。我一般在 config 里统一做一次兜底,避免各个环境行为不一致。

5. 踩坑实录:那些文档里不会写的排查经验

这一节是我这些年攒下来的问题清单,基本覆盖了 .env 相关的绝大多数故障。遇到「变量不生效」先对着查,比漫无目的地试快得多。

5.1 变量读不到的四类原因与排查顺序

按排查成本从低到高排:

现象最可能的原因验证方式解决
import.meta.env里没有这个键变量名没加VITE_前缀打印整个对象看键是否存在加前缀,或配置envPrefix
键存在但值是 undefined文件名和 mode 对不上vite --debug看加载了哪些文件检查--mode拼写
值不对,是另一套环境的优先级被更高文件覆盖逐个文件搜同名键按优先级表核对
改了 .env 没反应dev server 缓存了旧值重启进程后再看重启 dev server
打包后是 undefined,dev 正常变量只在.env.development里检查.env.production补齐各模式的变量

vite --debug这个命令值得单独拎出来说。它会把解析后的配置、加载的 env 文件、最终的 env 对象全部打出来,信息量很大但结构清晰。我第一次用它的时候才发现,原来项目里还藏着一个.env.production.local,不知道哪个历史遗留,把线上地址全改了。这种文件不打印出来根本发现不了,ls -a也不一定想得起来看。

还有一个反直觉的点:变量名大小写是敏感的。VITE_api_base和VITE_API_BASE是两个不同的键。文档建议一律用大写,我建议在团队规范里写死,别给人留发挥空间。

5.2 动态取值为什么拿不到,以及怎么绕过

import.meta.env[key]这种写法在 Vite 里是不生效的,原因是构建时替换靠的是静态文本匹配,key在构建那一刻是什么它不知道,于是整段保持原样,运行时import.meta.env上可能压根没有这些键,结果就是 undefined。

如果你确实有「根据条件从多个配置里选一个」的需求,有两种写法。第一种是显式映射,把静态键名写全:

const API_MAP = { dev: import.meta.env.VITE_API_DEV, staging: import.meta.env.VITE_API_STAGING, prod: import.meta.env.VITE_API_PROD, } export const getApiBase = (envKey) => API_MAP[envKey]

这样每个键都是静态书写的,构建时都能被正确替换,只是体积上会带上三份地址(还好只是字符串,不影响什么)。

第二种就是彻底的运行时配置文件,前面讲过。如果配置确实很多、需要动态决定读哪一份,别硬刚 Vite 的替换机制,直接走 JSON 方案,省心。

const { VITE_A, VITE_B } = import.meta.env这种解构写法,不同 Vite 版本表现有差异,我现在的习惯是一律用完整写法import.meta.env.VITE_A。看起来多敲几个字,但没有任何兼容性疑虑,团队里新人也不会因为写法不同而踩坑。

5.3 安全红线:什么绝对不能进 VITE_ 变量

这一条我放在最后但分量最重。带VITE_前缀的变量会被原样打进 js 文件,任何人打开浏览器、点开 Sources,全局搜一下就能看到。哪怕你做了代码混淆,字符串常量还是躺在那里的。

有一个很实用的自查方法:打包完在产物目录里 grep 一下敏感词。

# 检查产物里有没有不该出现的字符串 grep -r "secret" dist/production/assets/ grep -r "token" dist/production/assets/ grep -r "password" dist/production/assets/

如果搜到了,说明有人把不该公开的东西加了前缀。这种情况在多人协作项目里非常常见,都是图方便。

那真正需要密钥的场景怎么办?答案是密钥不下发前端。所有需要密钥的调用都走自己的服务端做转发,前端只调自己的接口。这个原则跟框架没关系,是前后端分工的基本盘。有些第三方服务只提供前端 SDK 并且必须暴露 key,那就必须在服务端配置域名白名单、设置调用频次限制,把风险控制住,而不是把它藏起来——藏不住。

还有一个小坑:.env.local和.env.*.local一定要在.gitignore里。我见过有人把.env.production.local提交上去,里面带着一个测试用的临时 token,三天后才发现。多写一行,少一次事故。

6. 工程化收尾:团队协作里的几个约定

单打独斗怎么写都行,几个人一起做的时候,.env 这套东西特别容易变成一团乱麻。我总结了几条成本很低但收益很高的约定。

6.1 .env.example 与启动时校验

.env.example是变量清单,提交到仓库,内容只保留键名和示例值,不放真实值:

# .env.example VITE_APP_NAME= VITE_API_BASE= VITE_ENABLE_MOCK= VITE_ASSET_BASE=

新人 clone 下来之后,复制成.env.local自己填,步骤写进 README 里,两行话就行。这一条能把「为什么我这里跑不起」的问题消灭掉一大半。

再进一步,可以加一个启动时的变量校验脚本,缺关键变量直接报错退出,别等到页面白屏才发现:

// scripts/check-env.js import { loadEnv } from 'vite' const mode = process.argv[2] || 'development' const env = loadEnv(mode, process.cwd(), 'VITE_') const required = ['VITE_API_BASE', 'VITE_APP_NAME'] const missing = required.filter((key) => !env[key]) if (missing.length) { console.error(`[env] 缺少必需变量: ${missing.join(', ')}`) process.exit(1) } console.log(`[env] ${mode} 环境变量检查通过,共 ${Object.keys(env).length} 个`)

挂到prebuild上,npm run build之前自动跑。这一招在上线前夜救过我一次——当时切了个新环境,.env.production忘了加新变量,脚本直接拦住,不然就是带病上线。

6.2 CI 里注入变量的取舍

流水线上有两种做法:把.env.production提交进仓库,或者完全靠 CI 注入。

我的经验是分两种情况:

  • 如果变量都是公开的、不含敏感信息(大部分VITE_变量都是这样),提交进仓库更省事,本地也能复现线上配置,排查问题方便。
  • 如果某个变量敏感(虽然不该有,但现实里总有些擦边情况),或者需要按部署环境动态变化(比如不同客户的域名),那就走 CI 注入,仓库里只留键名。

CI 注入的写法很简单,在构建步骤里设置环境变量即可:

# 流水线脚本示例 export VITE_API_BASE="$DEPLOY_API_BASE" export VITE_APP_VERSION="$BUILD_NUMBER" npm run build -- --mode production

注意前面提过的那条规则:已经存在于进程环境里的键,优先级高于任何 .env 文件。所以 CI 注入的值是最高优先级的,不会被仓库里的文件覆盖,这个特性用起来很安心。

需要提醒的是,CI 注入的变量不会出现在.env里,本地开发时如果你在代码里引用了它却忘了加默认值,本地就会是 undefined。所以关键变量在代码里一定要有兜底,或者在check-env的必填清单里列上,让 CI 和本地都跑一遍校验。

6.3 我个人的一套最小实践

最后把我自己在用的这套配置整理一下,不复杂,但覆盖了日常需求。

.env只放全环境一致的元信息(应用名、版本),其余按模式拆开;base和outDir跟着 mode 走,产物分目录;build.sourcemap非生产环境打开;带VITE_的变量一律视为公开,敏感值走服务端;接口域名这类可能临时调整的走运行时config.json;.env.example提交、.env*.local忽略;构建前跑一次必填变量校验。

这套跑下来,过去两年我在 .env 这块基本没再出过线上事故。真要说还剩下什么问题,就是.env文件本身没有 schema 校验,全靠人肉维护,变量多到二三十个的时候还是容易写漏。如果团队规模再大一点,值得花半天时间把.env.example生成、类型声明生成、必填校验这三个脚本串成一条流水线,一次性投资,后面每次加变量都省心。我现在加变量的流程已经固定成三步:改.env.example,跑生成脚本更新类型,在必填清单里加一行,剩下的交给脚本兜着。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进