很多用 Vite 的同学,项目跑起来之后基本不太会去管.env那一堆文件到底是怎么被加载的,反正写上VITE_开头的变量,代码里用import.meta.env一读就能用。但真到了要同时管理本地开发、测试环境、生产环境多套配置的时候,光会“写变量”是远远不够的。Vite 里的环境变量,本质上是构建期的配置注入机制,它解决的是同一份代码在不同环境跑出不同行为的问题:本地连本地后端,测试连测试后端,上线连正式后端,全程不用改业务代码。
这篇文章我不打算对着官方文档复读一遍。我从实际项目的使用角度出发,把环境变量的文件优先级、mode 模式、loadEnv的用法、TypeScript 类型提示、多环境切换的完整案例,以及那些文档里找不到的踩坑经验,一次性讲清楚。适合已经会跑 Vite 项目、但没仔细研究过.env机制的前端同学,也适合正在搭建多环境发布流程的工程化负责人。
1. 先搞清楚 Vite 环境变量到底解决什么问题
1.1 从“改代码切接口”的痛点讲起
假设你手头是一个后台管理系统,前端要通过 axios 请求后端接口。本地开发时后端地址是http://localhost:8080,测试环境是https://test-api.example.com,生产环境是https://api.example.com。如果没有环境变量,最原始的做法是定义在一个常量文件里:
export const API_BASE_URL = 'http://localhost:8080'然后每次要发布测试包,改一次代码,提交一次,回滚一次;发布生产包,再改一次,再提交一次。这种方案在只有一个人的玩具项目里还能忍,但凡有测试、有运维、有多个开发分支,早晚会出事故:忘了改、改错了、漏提交,说不定就把测试环境的接口带上了生产。
环境变量的意义就是把这个“改代码”的过程变成“改配置”。你在.env文件里写不同的值,构建的时候由 Vite 按当前模式把对应值注入进去。代码里永远只写import.meta.env.VITE_API_BASE_URL,不需要关心当前到底是哪个环境。
1.2 它和系统 PATH、Node 的 process.env 到底有什么区别
很多同学被“环境变量”这个词带偏,以为跟 Windows 上配置 Java、Python 那种系统环境变量是一回事。其实关系不大。
- 系统环境变量:由操作系统管理,进程启动时读取,比如
PATH、HOME。Java 的JAVA_HOME就是典型例子,它影响的是 JVM 能不能被找到。 - Node 的
process.env:运行在 Node 环境中的程序可以读取操作系统级的环境变量,也可以临时注入,比如在终端里执行FOO=bar vite。 - Vite 的
import.meta.env:这是 Vite 在编译时注入到客户端代码里的静态对象。浏览器里本来没有环境变量这个概念,Vite 相当于在构建阶段把变量替换成了具体的值,因此它只能在 Vite 处理过的模块里用。
理解这一点非常关键:import.meta.env不是浏览器原生能力,也不是运行时读取,而是“编译期的文本替换”。所以你在代码里console.log(import.meta.env.VITE_APP_TITLE),打包后在产物里看到的很可能直接就是那句console.log("本地开发环境")。这个机制决定了它天然不适合放会在运行时变化的配置,只适合放构建时确定的静态值。
2. .env 文件体系:加载优先级与 mode 模式的正确理解
2.1 五类 .env 文件,谁覆盖谁
Vite 支持从项目根目录的几个固定文件中加载环境变量。文件名就是一套“规则”,常见的有这些:
| 文件 | 加载时机 | 用途 |
|---|---|---|
.env | 所有模式都会加载 | 放公共配置、默认值 |
.env.local | 所有模式都会加载,但 test 模式除外 | 放本机私有配置,通常不进 git |
.env.development | dev 模式(vite启动) | 放开发环境配置 |
.env.development.local | dev 模式,但优先级最高 | 本机开发私有覆盖 |
.env.production | build 模式(默认) | 放生产环境配置 |
.env.test | 自定义 test 模式 | 放测试环境配置 |
.env.staging | 自定义 staging 模式 | 放预发布环境配置 |
文件之间的优先级,简单概括就是:越“具体”的文件优先级越高。如果同一个变量在多个文件里都出现了,加载顺序大概是这样:
.env.[mode].local > .env.[mode] > .env.local > .env举个例子:项目根目录有.env和.env.development,两个文件里都写了VITE_API_BASE_URL,最后开发环境下生效的一定是.env.development里的值。如果还写了一个.env.development.local,那它又能把.env.development的值覆盖掉。这个“local 后缀优先级最高”的设计初衷,是让每个开发者在本地能盖掉团队的公共配置,而不用污染提交记录。也正因为这个性质,官方建议.env.local要加到.gitignore里,避免不小心把本机私有配置提交出去。
一个经常被忽略的细节是:.env.local在test 模式下不会被加载。这一点是官方文档明确写过的,原因是防止测试环境和本地开发环境互相污染,影响测试结果的可靠性。真要是测试时需要某台机器的特殊配置,用.env.test.local会更符合预期。
2.2 mode、NODE_ENV、--mode 参数之间到底怎么联动
这三个概念放在一起特别容易乱。
mode是 Vite 当前运行的模式。不传参数的时候,vite命令默认是development模式,vite build默认是production模式。你也可以自己指定,比如执行vite build --mode test,那当前模式就变成了test,Vite 会去加载.env.test这个文件。
NODE_ENV是 Node 里约定俗成的一个环境变量,很多库都依赖它来区分开发和生产。在vite build时,Vite 会把NODE_ENV设为production(如果之前没设置的话)。这里要特别留意:--mode test改的是mode,不会把NODE_ENV从production改成test。换句话说,构建时你用了自定义模式,构建产物依然是以生产模式的标准去做压缩和 tree-shaking,这恰恰是大多数时候我们想要的行为。
你还可以在.env.staging里手动写一行NODE_ENV=production,这样即便 mode 是staging,各种依赖库也会把它当作生产环境来对待。这个手法很多团队在用,逻辑是:mode 管“加载哪个 env 文件”,NODE_ENV管“第三方库按什么心智模式运行”,两者可以分开控制。
开发模式同理:vite启动时mode=development,NODE_ENV=development。想跑一个 production 模式的本地预览,不要用vite,应该先vite build,再用vite preview预览构建产物。
2.3 vite.config.ts 里怎么拿到环境变量:别被 process.env 坑了
这是很多人踩过的大坑:明明.env文件里写了变量,但打开vite.config.ts,敲一段console.log(process.env.VITE_XXX),输出却是undefined。
原因在于:Vite 加载.env文件并把变量暴露到import.meta.env,是在配置加载完成之后才做的。你在vite.config.ts里直接操作process.env,执行阶段根本还没解析这些文件,自然读不到。
正确的做法是使用 Vite 提供的loadEnv方法:
import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { plugins: [vue()], server: { proxy: { '/api': { target: env.VITE_API_BASE_URL || 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } } })loadEnv的三个参数分别是:当前模式、项目根目录、要加载变量的前缀。第三个参数传空字符串'',表示不过滤前缀,把.env系列文件里的所有变量都加载进来。如果不传,默认只加载VITE_前缀的变量。
这个能力在配置 dev server 代理时特别有用。你想让本地开发统一走/api前缀,然后由 Vite 代理转发到真实后端地址,那么这个后端地址最好从.env.development里读,而不是硬编码在vite.config.ts里。这样团队里有人换了联调后端,只需改环境变量文件即可,不需要经过你改配置文件。
3. 在代码里读取环境变量:从 import.meta.env 到类型提示
3.1 import.meta.env 的暴露原则:VITE_ 前缀是安全边界
Vite 里有一个默认规则:只有以VITE_开头的环境变量,才会被暴露到客户端代码中。你在.env里写了API_SECRET=xxx,在vite.config.ts里用loadEnv能读到,但浏览器端.env的import.meta.env.API_SECRET永远是undefined。
这个设计初看有点绕,其实是一个安全边界。前端代码最终会整个打到 bundle 里,任何用户打开浏览器 DevTools 都能翻到import.meta.env里出现的所有变量。如果所有变量都不经筛选地暴露,很容易把后端密钥、内部服务地址这种敏感信息全都送到浏览器端。
所以你在vite.config.ts或服务端可能需要但浏览器不需要的配置,放在不带VITE_前缀的变量里;在 React/Vue 页面组件、业务模块中要读取的配置,统一用VITE_前缀。
如果项目里的确需要多个前缀,比如既有VITE_也有APP_,可以通过envPrefix配置扩展:
export default defineConfig({ envPrefix: ['VITE_', 'APP_'] })但不建议做得太花哨,团队项目里统一唯一前缀更利于查找。
3.2 给环境变量加上 TypeScript 类型
默认情况下,import.meta.env.VITE_APP_TITLE在你项目里是string | undefined或者直接any,编辑器的补全和拼写检查都约等于零。把变量名写错一个字母,等跑起来才发现是undefined,很浪费时间。
我习惯在项目里维护一个src/vite-env.d.ts,或者直接在已有的env.d.ts中扩展类型:
/// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_APP_TITLE: string readonly VITE_API_BASE_URL: string readonly VITE_API_TIMEOUT?: string } interface ImportMeta { readonly env: ImportMetaEnv }这样项目里只要用了import.meta.env.VITE_APP_TITLE,IDE 就能给出string类型提示,写错键名也会有红色波浪线。需要特别注意的是:不要重复声明interface ImportMeta,也不要把它塞在declare global外层的模块作用域里,否则类型可能覆盖不生效。写完这个文件后,如果编辑器没反应,把 TS Server 重启一下。
3.3 几个能直接抄走的读取封装技巧
我踩过很多次“在十来个组件里直接写import.meta.env.VITE_XXX”的坑。后来复盘,这种做法有三个问题:变量名分散在代码各处,统一重命名非常痛苦;每个使用点都得处理undefined;拿到的原始字符串没有领域语义,比如'true'和true的区别全靠人脑记。
所以更推荐的做法是:所有环境变量的读取,都收敛到一个独立的配置文件里。比如src/config/env.ts:
interface AppEnv { title: string apiBaseUrl: string isProd: boolean enableMock: boolean timeout: number } const parseBoolean = (value: string | undefined, fallback: boolean): boolean => { if (value === undefined) return fallback return value === 'true' } export const appEnv: AppEnv = { title: import.meta.env.VITE_APP_TITLE || '未命名应用', apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api', isProd: import.meta.env.PROD, enableMock: parseBoolean(import.meta.env.VITE_ENABLE_MOCK, false), timeout: Number(import.meta.env.VITE_API_TIMEOUT || 15000) }业务代码里要配置时,永远只从appEnv取。以后如果要调整变量命名、增加默认值、做 type 收窄,只需要改这一个文件。团队协作时这也相当于一个配置出口,新人接手看一眼这个文件就知道当前应用有哪些运行期开关。
有个细节:.env里的值全部是字符串,不要企图直接写成VITE_ENABLE_MOCK=false然后当布尔值用。false字符串在 JS 里是 truthy。所以上面parseBoolean的写法是必要的,或者统一用'true'/'false'并解析字符串。
4. 全套实战:实现开发、测试、生产三套 API 地址切换
前面讲了原理和工具,这一节我用一个完整的例子,把多环境配置从零到落地的路径走一遍。场景还是那个后台管理系统:本地联调、测试验收、生产发布,三套接口地址各不同。
4.1 准备三个 .env 文件和构建脚本
在项目根目录创建三个文件:
.env.development:
# 开发环境 VITE_APP_TITLE=后台管理系统(开发) VITE_API_BASE_URL=/api VITE_ENABLE_MOCK=true.env.test:
# 测试环境 VITE_APP_TITLE=后台管理系统(测试) VITE_API_BASE_URL=https://test-api.example.com VITE_ENABLE_MOCK=false.env.production:
# 生产环境 VITE_APP_TITLE=后台管理系统 VITE_API_BASE_URL=https://api.example.com VITE_ENABLE_MOCK=false这里留一个小细节:开发环境的VITE_API_BASE_URL没有写完整后端地址,而是写成/api。原因是配合 Vite dev server 的 proxy,让浏览器请求同源地址,避免开发时被 CORS 问题折磨。代理转发那一层我放到 4.2 节。
然后修改package.json的 scripts:
{ "scripts": { "dev": "vite", "build": "vite build", "build:test": "vite build --mode test", "preview": "vite preview" } }npm run build默认走生产模式,加载.env.production;npm run build:test通过--mode test加载.env.test。因为vite build会主动把NODE_ENV设成production,所以 test 包虽然接口地址是测试环境,但构建优化的程度和生产包一致,不会出现测试包不压缩的情况。
4.2 用 loadEnv 在 vite.config.ts 里做代理
本地开发地址写/api之后,需要让 Vite 把/api开头的请求转发到真正的后端。这里我不能在vite.config.ts里写死http://localhost:8080,因为团队里可能有人要连远程测试后端调联。我把这个转发目标也做成环境变量。
在.env.development里再加一行:
VITE_DEV_PROXY_TARGET=http://localhost:8080注意这个变量只在开发时需要,生产构建用不到。然后在vite.config.ts里用loadEnv读取:
import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { plugins: [vue()], server: { proxy: { '/api': { target: env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } } })rewrite那段的作用是把请求路径里的/api前缀去掉。比如浏览器请求/api/login,代理转发给后端的是/login。这个按后端实际规范来定,不一定都要 rewrite。我用这个方案半年多,最大的体会是:本地开发时你不用关心后端地址写死在哪,后端同事换了端口,改一下.env.development就能继续干活,不会出现“换个人拉代码就跑不起来”的情况。
4.3 请求层封装与前端代码改造
接 axios 的时候,不要在每个接口文件里到处拼import.meta.env.VITE_API_BASE_URL。我用的是前面创建的src/config/env.ts作为统一出口,然后封装一个request.ts:
import axios from 'axios' import { appEnv } from '@/config/env' const request = axios.create({ baseURL: appEnv.apiBaseUrl, timeout: appEnv.timeout }) request.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) export default request组件里调用接口时:
export function fetchUserInfo() { return request.get('/user/info') }这样整个业务层完全不感知环境差异。本地开发时baseURL是/api,走了 Vite 代理;测试和生产构建时,baseURL会被替换成真实地址。代码在三种环境下跑的是同一份。
4.4 构建、预览与产物验证
执行npm run build:test,构建结束后,在dist目录里搜一下https://test-api.example.com,应该能找到替换后的产物。这一步很重要,我见过不少“构建完还是旧地址”的案例,最后发现要么是忘了先删dist,要么是浏览器缓存,要么是.env.test压根没提交。
如果要本地验证测试包效果,用vite preview:
npm run preview默认会起一个本地静态服务器,预览dist目录的内容。有人会拿npm run dev来验证,那不对,dev是开发模式,不会读.env.test。另外vite preview默认端口是 4173,要指定端口就vite preview --port 5000。
还有一个容易忽略的地方:.env文件本身不会出现在dist里。Vite 只是在构建时读取值并替换到代码中,文件不会被打包。所以检查产物时,应该查dist/assets/*.js里的字符串值,而不是去找.env文件。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
把各个项目里遇到过的问题整理一下:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
import.meta.env.VITE_XXX是undefined | 变量没有VITE_前缀;写错文件名;没重启 | 检查 .env 文件名和前缀,重启 dev server |
| 修改 .env 后 dev server 没反应 | Vite 不一定对所有 .env 变更做热更新 | 手动重启 dev server |
| 构建后变量是旧值 | 构建前没清缓存;浏览器缓存;没有指定 mode | 删 dist、清缓存、确认脚本带 --mode |
| vite.config.ts 里读不到变量 | 直接用了 process.env | 改用loadEnv(mode, process.cwd(), '') |
| 测试包用了生产地址 | 构建脚本少了--mode test;或 .env.test 没配置 | 检查 scripts 和文件是否存在 |
字符串'false'被当成 true | env 值全是字符串 | 用=== 'true'解析 |
| 构建产物里出现敏感密钥 | 把密钥写成了 VITE_ 变量 | 改名去掉前缀,或移到后端 |
5.2 “修改 .env 不生效”怎么从根上排查
这个问题出现频率极高,我单独拎出来说。
首先是重启。Vite 对.env文件的更新并不可靠,开发服务器跑着的时候改了.env,很多时候不会自动刷新环境变量。这是设计上的行为,别跟它较劲,改完直接重启 dev server。
其次是文件名和模式对不上。上一个项目我写过.env.testing,然后在build --mode test里期待它生效,结果当然没生效。Vite 文件名里的后缀必须和--mode完全一致,test就对应.env.test,staging就对应.env.staging。一个下划线都不能差。
再就是文件位置。.env系列文件必须放在项目根目录(也就是执行vite命令时的cwd),放到src下、放错层级都会读不到。
最后检查高优先级覆盖:如果.env和.env.development同时定义了同一个变量,改.env自然看不到效果,因为.env.development把值盖掉了。排查想加一句console.log(import.meta.env),看当前环境实际拿到什么,比瞎猜快得多。
5.3 环境变量的安全边界:哪些东西千万别放进去
很多人第一次建.env.production时,顺手把数据库连接串、Redis 密码、对象存储 SecretKey 都写了进去,还用的是VITE_前缀。这是非常危险的事。
一旦变量带VITE_前缀,就一定会被编译进前端代码里。意味着所有访问网站的人,打开 DevTools,看Sources面板,搜索关键词,就能找到你的明文密钥。我见过不止一次线上事故,就是因为拿前端 bundle 里的 key 直接调了云厂商的管理接口。
正确的姿势分两种情况:
- 只在前端业务代码中使用的公开配置,如 API 地址、应用标题、埋点 ID,用
VITE_,因为它们本来就要暴露。 - 构建期才用、不能被用户看到的密钥,例如构建脚本访问内部服务的 Token,写在
.env里但不带VITE_前缀,并用loadEnv在构建配置里读取。 - 后端需要的核心密钥,比如数据库密码、JWT 签名私钥,不应该进前端项目。放到 CI/CD 平台的 secrets 或部署服务器的环境变量里,由后端服务直接读取。
另外.env.local、.env.production.local这类 local 文件,务必加入.gitignore。它们是留给本机或特定机器用的,如果被提交到仓库,等于把团队每个人的私有配置和历史遗留密钥都暴露了一遍。我在新项目初始化时一定会顺手把这一行加进去。
5.4 在 SPA 架构里动态改接口地址的一种替代思路
用.env文件配置环境变量,本质是“构建时锁定配置”。这带来一个痛点:如果你的产品是给多个客户独立部署的,客户 A 和客户 B 需要不同的 API 地址、不同的应用名称,那每个客户单独 build 一次,维护成本很高。
遇到这种场景,可以用运行时配置来补充。思路是在public目录下放一个runtime-config.js:
window.__APP_CONFIG__ = { apiBaseUrl: 'https://api.example.com', appTitle: '后台管理系统' }然后代码里配置优先级这样处理:
const runtime = window.__APP_CONFIG__ || {} export const appEnv = { title: runtime.appTitle || import.meta.env.VITE_APP_TITLE || '未命名应用', apiBaseUrl: runtime.apiBaseUrl || import.meta.env.VITE_API_BASE_URL || '/api' }由于public目录里的文件会原样复制到dist根部,部署方只要修改这个 JS 对象,就能在不重新构建前端的情况下调整关键配置。用 Docker 部署的话,这个文件还可以通过环境变量模板动态生成,运维同学会很喜欢。
这个方法不是要替代.env,而是说import.meta.env只解决“构建时确定配置”一类问题。遇到“部署后再决定配置”的需求,要用 window 全局注入这类运行时方案去补位。
上面这些内容,基本覆盖了 Vite 环境变量从原理到实战的完整链路。最后再分享一个习惯:我每次在项目里新建.env文件时,都会在文件顶部写上两三行注释,说明这个文件对应哪个 stage、哪些变量是敏感项、谁负责维护。环境变量文件容易被当成“写了就行”的一次性配置,但真正接手项目的人,往往就是靠着这几行注释在关键时刻救回一命。