拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vite环境变量机制详解:原理、配置与多环境实践

Vite环境变量机制详解:原理、配置与多环境实践

做前端几年的人,大概都有过这种经历:同一个项目,本地跑得好好的,一上测试环境就报接口地址不对;或者今天给后端联调用的还是这台服务器,明天换了一台,就得满代码里找那个写死的IP。早期我用webpack的时候,最烦的就是改环境配置,改完还得重新构建。后来切到Vite,发现它的环境变量机制清爽很多,但要真正用明白,把import.meta.env、.env文件、模式、loadEnv这些概念理清楚,还是有不少细节可以聊。这篇博客我就把Vite环境变量的完整玩法捋一遍,从基础配置到工程化实践,再到我踩过的坑,一次性说清楚。

环境变量本身不算陌生概念,Java、Python、Node里都有,核心就是“把代码里的可变值抽离成运行时的配置”。Vite对这套逻辑做了自己的实现,底层依赖dotenv和dotenv-expand,但入口和使用方式遵循了ESM的规范——不是Node的process.env,而是更贴合浏览器环境的import.meta.env。这套设计解决的最大痛点,就是不同环境(开发、测试、预发、生产)之间自动切换配置,不用改一行代码,也不用担心密钥和敏感信息被前端打包泄露到浏览器里。

这篇文章适合刚接触Vite的人,也适合已经用Vite但环境变量这块只看过官方文档、没系统整理过的同学。我会从设计思路讲起,再给实操步骤,最后是问题排查,能帮你把这块真正焊在脑子里。

1. Vite环境变量机制与设计思路

1.1 为什么前端项目需要环境变量

先说一个最日常的场景:你写了一个请求函数,基础地址是http://192.168.1.10:8080,前端联调阶段没问题。等你要部署到服务器上,就得改成https://api.example.com。如果代码里到处硬编码,改动量不仅大,还容易漏。环境变量的第一个价值就是消除硬编码,把可变值集中到配置文件中。

再往后,你发现不同分支对应不同的后端接口地址、不同部署平台需要不同的CDN前缀,甚至灰度发布时要动态修改一些页面开关,这些都属于环境变量负责的范围。Vite的做法是:把process.env里与前端相关的变量,以一种安全可控的方式暴露给客户端,同时剔除掉服务端专用的敏感变量。

import.meta.env就是Vite暴露给业务代码的全局对象。它默认带了几个属性:

  • BASE_URL:对应base配置项,也就是部署时的基础路径。
  • MODE:当前的模式名,默认是development或者production,也可以自定义。
  • DEV:是不是开发环境。
  • PROD:是不是生产环境。
  • SSR:是否在服务端渲染环境中运行。

你自定义的变量也会挂到import.meta.env上,但有一个前缀限制,默认是VITE_开头。这个设计我记得从Vite 2.0开始就有,目的有两个:一是防意外泄露,二是明确边界——只有显式声明的变量才会被打进客户端代码。

1.2 对比传统Node环境变量和Vite环境变量

如果你之前用过webpack的DefinePlugin,或者CRA的REACT_APP_前缀,再到Vite,会发现思路相似,但细节更流畅。Vite底层会执行loadEnv读取.env文件,然后通过dotenv解析,再经过dotenv-expand做变量展开。这些过程发生在配置解析阶段,也就是说,Vite的配置文件里可以读到环境变量,业务代码里也能通过import.meta.env读到。

传统Node里最常用的写法是process.env.NODE_ENV,这个在Vite里也能用,但仅限于配置文件和服务端代码。Vite客户端代码里process.env是不存在的,因为浏览器环境没有Node的能力。如果直接在组件里写process.env.VITE_API_URL,大概率会报错或者被替换成undefined。正确姿势永远是用import.meta.env。

这里有个容易忽略的点:import.meta.env不是一个静态对象,所有VITE_前缀的变量,Vite在源码扫描阶段就会按“出现即替换”的方式内联到代码里。这种替换从构建层面保证了变量的可用性,但同时也意味着你必须在构建前把所有需要用到的变量定义好,不能像Node运行时那样临时塞一个值进去。

1.3 Vite处理环境变量的整体流程

我用一句话概括整个流程:启动或构建时,Vite读取当前模式下对应的.env文件,解析成键值对,筛选出带VITE_前缀的字段,挂载到import.meta.env上,并在代码编译期做静态替换。

具体到内部实现,loadEnv方法可以在vite.config.ts里手动调用,返回的是一份完整的“解析后”环境变量集合,包含process.env里已有的和.env文件里定义的。很多插件或者自定义配置就需要用loadEnv来主动获取,因为vite.config.ts自身加载时可能还没读入.env文件的内容。

这个流程设计得很聪明:它把“配置解析”和“业务读取”分开了。配置文件要拿的是“全部变量”,用于决定服务怎么跑;业务代码要拿的是“白名单变量”,用于控制页面逻辑。两条路互不干扰,各自安全。

2. 核心配置:环境文件、模式与优先级

2.1.env文件家族都有谁

Vite约定了一组环境文件的命名,直接放在项目根目录,不需要额外创建目录。最常用的有这么几个:

  • .env:所有环境都会加载的基础文件,优先级最低。
  • .env.local:同样所有环境都会加载,但只在本机生效,通常用来存本地调试的临时配置,不会提交到Git。
  • .env.development:开发模式下的配置,对应的启动命令是vite。
  • .env.production:生产模式下的配置,对应的构建命令是vite build。
  • .env.test:测试模式,需要你用vite --mode test手动指定。

命名规则就是.env加可选的环境名。环境名对应的是--mode参数,而不是文件名本身。比如你运行vite build --mode staging,Vite就会去找.env.staging文件。

文件优先级从高到低是:.env.[mode].local>.env.[mode]>.env.local>.env。也就是说,同名变量,高优先级的文件会覆盖低优先级文件的值。这里有个坑:.env.local在所有环境里都会覆盖同名的.env,但如果你同时存在.env.development和.env.development.local,最终以.env.development.local为准。

我把常用场景和推荐文件名整理成一张表,方便你直接对照:

场景启动命令使用的文件说明
本地开发vite.env,.env.development,.env.development.locallocal文件存个人专属变量,如代理地址
本地联调后端vite --mode dev.env.dev,.env.dev.local自定义模式,需要配套传入--mode
测试环境部署vite build --mode test.env.test测试服务器专用地址与开关
预发环境部署vite build --mode staging.env.staging一般包含预发接口地址
生产构建vite build.env.production,.env.production.local生产环境变量,注意敏感信息处理

2.2 自定义前缀与类型定义

默认只能暴露VITE_开头的变量。很多新手第一次遇到的问题是:“我明明在.env里写了API_URL=xxx,为什么import.meta.env.API_URL是undefined?”原因就是缺少VITE_前缀。

要是你就是不想用VITE_前缀,Vite也开了口子。在vite.config.ts的envPrefix配置项里,可以设置一个数组或者字符串:

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

这样APP_开头的变量也能暴露给客户端。不过我个人建议,除非有特殊的工程化需求,否则就老老实实用VITE_前缀。原因很简单:团队协作时,看到VITE_就知道这个变量会进客户端代码,看到别的就得查配置,心智负担重。

注意一点:envPrefix只能配置“暴露给客户端的白名单”,它不会改变服务器端读取完整环境变量的能力。在配置文件里用loadEnv拿到的变量,跟这个前缀没关系,是所有变量。

另外,如果你的项目用了TypeScript,直接访问import.meta.env上自定义的变量会报类型错误。需要补一个类型声明文件,最标准的位置是src/vite-env.d.ts:

/// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_APP_TITLE: string } interface ImportMeta { readonly env: ImportMetaEnv }

写完重新启动Vite,类型提示就有了。这一点在团队协作里尤其重要,不然队友用的时候只能靠猜,经常把变量名拼错。

2.3 变量展开与默认值

.env文件里支持引用其他变量,用的是${KEY}语法。这得益于Vite内置的dotenv-expand。举例:

VITE_API_URL=/api VITE_FULL_URL=${VITE_API_URL}/v1

解析之后,VITE_FULL_URL的值就是/api/v1。这个能力在做统一前缀时很好用,但要注意顺序:必须保证被引用的变量定义在前面,或者至少能被解析到。如果循环引用,会得到空值而不是报错,排查起来比较头疼。

另一个常见需求是“默认值”。有时候你希望某个变量没定义时,代码里给个兜底值。Vite本身没有内置这种语法,但业务代码里可以这么写:

const apiUrl = import.meta.env.VITE_API_URL ?? '/api'

或者用defineConfig里的env回调吗?Vite没有直接提供。我一般习惯在vite.config.ts里做个变量归一化,把默认值合并进去,避免业务代码到处写??。

3. 实操过程与核心环节实现

3.1 在业务代码里正确读取环境变量

这是最基础也是最重要的一环。记住一句话:客户端代码里永远使用import.meta.env,不要用process.env。Vite会为每一处import.meta.env属性替换为对应字符串值,因为这是编译期的静态替换,所以变量名必须是静态字符串。

比如下面这段代码没问题:

const apiBase = import.meta.env.VITE_API_BASE_URL

但动态访问就会出现问题:

const key = 'VITE_API_BASE_URL' const value = import.meta.env[key] // 这样拿不到,或者得到undefined

原因是Vite做静态分析时,只能识别直接点属性访问的语法。动态属性访问会被当成普通对象操作,但import.meta.env在浏览器运行时其实并不存在,所以就必须杜绝。

开发环境里偶尔能跑到,是因为Vite的dev server做了注入模拟,但build之后就不可能了。这种问题上线前才发现的话,最尴尬。我的习惯是写一个统一的src/config/index.ts,把环境变量接收一遍并导出:

export const config = { apiUrl: import.meta.env.VITE_API_URL, appTitle: import.meta.env.VITE_APP_TITLE, isDev: import.meta.env.DEV, isProd: import.meta.env.PROD, baseUrl: import.meta.env.BASE_URL }

业务代码只依赖这个模块,不直接碰import.meta.env。好处是以后加默认值、做校验、甚至给变量改名,都只动一个文件。

3.2 在vite.config.ts里读取环境变量

如果你在vite.config.ts里直接写process.env.NODE_ENV,能读到,但.env文件里的自定义变量是读不到的。因为配置文件本身是在Vite启动早期被加载的,此时环境文件还没被解析。正确做法是用loadEnv手动读取。

// vite.config.ts import { defineConfig, loadEnv } from 'vite' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { // 这里可以用env里的变量做配置 server: { host: true, port: Number(env.PORT) || 3000 }, define: { __APP_VERSION__: JSON.stringify(env.VITE_APP_VERSION) } } })

注意loadEnv的第三个参数。默认情况下,它只会返回VITE_前缀的变量。如果想拿到全部变量,就传一个空字符串''。我这里让它全部读取,主要是为了拿端口号这类不带VITE_的配置。loadEnv返回的对象里,变量名不带前缀。

有人会问:既然配置文件里能直接define硬编码,为什么不直接写JSON.stringify('1.0.0')?因为版本号、接口地址这类值往往和部署环境相关,写在.env里才能让同一套源码构建出不同环境的结果。

基于模式的配置拆分是我强烈推荐的做法。比如代理转发:

export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') const proxyTarget = env.VITE_PROXY_TARGET || 'http://localhost:8080' return { server: { proxy: { '/api': { target: proxyTarget, changeOrigin: true } } } } })

联调时你在.env.development.local里写VITE_PROXY_TARGET=http://192.168.1.5:9000,本地启动就会代理到那个地址,不需要改任何代码,也不用提交这个文件,非常清爽。

3.3 模式与生产构建场景

前面提到--mode可以自定义模式,这是Vite环境变量机制里非常灵活的部分。默认情况:vite命令对应development模式,加载.env.development;vite build命令对应production模式,加载.env.production。如果你执行vite build --mode staging,import.meta.env.MODE就是'staging',同时PROD这个变量依然是true吗?

这里有一个容易误会的点:--mode staging只改变MODE变量的值,DEV和PROD依然根据当前命令是serve还是build决定。vite build --mode staging时PROD为true,DEV为false。也就是说,模式是环境的名字,而DEV/PROD是构建类型的标识。

这背后的设计逻辑是:你可以为“生产构建”准备多个环境,比如test、staging、prod,但它们都是生产构建,所以PROD都是true。如果你想区分“测试构建”和“生产构建”,就得自己在.env里定义一个状态位,比如VITE_ENV_NAME。

在实际项目里,我习惯维护这样的环境命名规范:

模式名对应文件用途
development.env.development本地联调,离后端最近
test.env.test测试环境部署
staging.env.staging预发验证
production.env.production线上发布

这样整个流水线的构建命令基本就是固定的几个vite build --mode xxx,配置全由.env.xxx承载。

3.4 在HTML模板中访问环境变量

很少有人提但特别好用的一个点:.html文件里也能用环境变量。Vite会对HTML做模板渲染,语法是%VITE_XXX%。比如我要给页面动态设置标题和统计脚本地址:

<!DOCTYPE html> <html> <head> <title>%VITE_APP_TITLE%</title> <script> var _hmt = _hmt || []; (function() { var hm = document.createElement("script"); hm.src = "%VITE_ANALYTICS_URL%"; var s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(hm, s); })(); </script> </head> </html>

构建的时候,Vite会把这两个占位符替换成.env.production里的实际值。如果你不知道这个能力,就会走“index.html里写死统计脚本,每个环境复制一份”的弯路,维护起来相当痛苦。

同样注意,HTML里只能使用VITE_前缀的环境变量,不然不会替换。

4. 常见问题与排查技巧实录

4.1 变量不生效的几种典型原因

每次有同事来问“我明明在.env里写了变量,怎么页面上是空的”,我脑子里会自动过一遍排查清单,按出现概率排序:

  • 前缀写错了,没有VITE_开头。
  • 文件命名不对,比如创建了.env.development,但运行命令是vite build --mode prod,那加载的是.env.prod。
  • 环境变量值里有特殊字符,比如#被当成注释了。
  • 修改完.env没有重启Vite dev server。
  • 变量在import.meta.env后面加了点号访问,但TypeScript没有声明类型,编辑器拦下来了。
  • 代码用了动态访问,比如import.meta.env[变量名]。

排查第一步永远是把loadEnv的结果打印出来,看Vite到底读没读到你的文件。在vite.config.ts里临时跑一下:

export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') console.log('当前环境变量:', env) return {} })

看到控制台输出,基本就定位了。如果env里是空的,说明文件名或模式不匹配;如果非空但业务代码拿不到,那就是前缀或类型声明的问题。

4.2process.env与import.meta.env的边界

原生Node里process.env是好用的,Vite配置文件里也能用,但业务代码里混用就会有各种怪事。这里我说一下边界:

  • vite.config.ts:可以同时使用process.env和loadEnv。前者读系统的,后者读项目的。
  • 服务端渲染代码(SSR):process.env和import.meta.env都可用,具体看框架暴露。
  • 浏览器客户端代码:只能通过import.meta.env访问Vite暴露的变量。

最典型的问题场景是用了一些CJS库或者老依赖,它们的代码里访问process.env.NODE_ENV,Vite构建时无法真正提供process对象,已经很新版本的Vite会尝试替换process.env.NODE_ENV为'production'或'development',但其他process.env.MY_VAR类引用就无能为力。这时候可以在define里手动指定:

define: { 'process.env.MY_VAR': JSON.stringify(env.MY_VAR) }

但我不建议把业务变量都塞进process.env,因为Vite没法替你打包成一个独立对象,最终还是靠字符串替换。最初的变量存在哪里并没那么重要,重要的是统一出口。

4.3 敏感信息泄露风险与防范

环境变量配置里最容易翻车的点,是把密钥、Token、数据库密码写进VITE_前缀变量。一旦写了,这些值就会被Vite原封不动打进浏览器可见的JS代码里。构建完的产物里搜索一下变量名,值就在那。

所以我的硬性规则是:

  • 只有“客户端必须知道”的变量才用VITE_前缀。比如接口地址、页面标题、正常的开关。
  • “服务端才知道”的变量,比如签名密钥、数据库地址,绝不能出现在.env.*中被loadEnv加载后传给前端的那部分。如果你的项目有后端,这类变量应该只存在后端环境里,通过接口下发。
  • .env.local必须加进.gitignore,防止本机配置泄露。
  • 定期检查构建产物,可以直接在dist目录搜API密钥格式,确认没混进去。

如果确实有某些配置项需要构建时注入但不想直接暴露给浏览器,可以用define配合一个服务端渲染接口来做,但那是另一个层级的话题,普通SPA项目尽量别把复杂密钥下放到前端。

4.4 我的排查速查表

我把日常高频问题整理成了一个表格,团队里新同学遇到问题直接查表:

现象可能原因检查方式
import.meta.env.VITE_X为undefined前缀错误或环境文件加载错误检查是否VITE_开头;确认--mode参数
.env.local不生效文件名写成了.env.local.txt看根目录文件后缀
修改变量后不生效dev server在缓存历史重启Vite,确认终端日志
构建后process.env is not defined第三方依赖引用Node全局使用define替换或者换兼容库
TypeScript报错找不到属性缺少vite-env.d.ts类型声明补接口声明
生产环境代理不生效代理只在dev server有效用nginx或网关注入,或VITE_API_URL指向完整地址
值里有#被截断#在dotenv里被视为注释对#转义或用引号包裹

这个表不一定覆盖全部情况,但足够应对90%的初阶问题。

5. 工程化进阶:多环境配置的最佳实践

5.1 环境文件内容组织与命名规范

当一个项目从开发演进到具备完整测试、预发、线上流程后,环境文件会逐渐多起来。我在团队里推行过一套比较稳的组织方式,这里分享给你。

根目录只保留公共和样例文件,比如:

.env # 公共变量,所有环境共享,比如应用名 .env.example # 提交到仓库的模板,列出所有需要配置的键 .env.development # 开发默认值 .env.production # 生产默认值(仅包含公开变量) .gitignore # 排除所有.local文件

每个开发者自己的个性化配置,比如指向本地后端、临时开关调试工具,都写在.env.local或者.env.development.local里。这些文件不提交,避免互相污染。

对于测试和预发,一般由部署平台(CI/CD)在构建时动态生成临时环境变量,或者维护单独的.env.test、.env.staging。如果项目规模不大,建议二进制包里只放.env.production,其他环境通过在构建机里挂的变量注入,更安全。

5.2 编译期的变量校验

环境变量一多,就容易拼错名字或者漏配置。Vite本身不会校验缺失变量,构建成功了,上线后发现接口地址是空的。解决办法是写一个小型校验脚本,在启动命令前先跑一遍。

我用过比较轻量的方式是维护一个envSchema.ts,用简单的assert校验:

function requireEnv(name: string): string { const value = import.meta.env[name] if (value === undefined || value === '') { throw new Error(`缺少必要的环境变量: ${name}`) } return value } export const config = { apiUrl: requireEnv('VITE_API_URL'), version: import.meta.env.VITE_VERSION || 'unknown' }

在入口文件顶部加载这个模块,启动时一旦缺失就会快速失败,比线上挂掉好一万倍。如果项目中用了zod这类的校验库,也可以定义更完整的schema,比如手机号格式、URL格式等。前端项目不比后端,不需要特别复杂,但“缺失即报错”这个底线要有。

5.3 与CI/CD结合的环境注入

除了一般的.env文件,实际工程里还会遇到“构建机不希望你提交真实IP或密钥,但构建又需要”的情况。主流做法是:代码仓库里放.env.example,CI平台(比如GitHub Actions、GitLab CI、Jenkins)里配置环境变量,然后在构建步骤里生成临时的.env.production。

比如在GitLab CI的配置中:

build: stage: build script: - echo "VITE_API_URL=$VITE_API_URL" >> .env.production - echo "VITE_APP_ID=$VITE_APP_ID" >> .env.production - npm ci - npm run build artifacts: paths: - dist/

这里$VITE_API_URL是CI平台里维护的变量,Vite构建时读取生成的.env.production。这样做的好处是不会把真实环境变量提交到Git记录里,需要变更时直接在CI平台修改,重新构建即可,真正做到配置和代码分离。

5.4 利用define注入编译期常量

有时候你不想让某些值被别人通过浏览器调试面板一眼看到,但它又是整个应用级别的固定值。Vite的define配置可以做编译期的常量替换,替换动作发生在代码编译阶段,值和import.meta.env的静态替换类似,但好处是你可以在这里处理任何表达式,比如版本信息、全局注入的函数变体。

我举一个实际例子:

export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { define: { __BUILD_TIME__: JSON.stringify(new Date().toISOString()), __DEPLOY_ENV__: JSON.stringify(env.VITE_DEPLOY_ENV) } } })

然后在代码里:

declare const __BUILD_TIME__: string declare const __DEPLOY_ENV__: string console.log(`构建时间: ${__BUILD_TIME__}`) console.log(`部署环境: ${__DEPLOY_ENV__}`)

构建后,这段代码会被替换成真实的字符串。这个能力非常适合展示在单页应用右下角的“版本信息”里,排查线上问题时特别有用,能立刻知道当前线上跑的是哪个环境的哪个构建。

5.5 动态环境变量与运行时配置的取舍

最后必须说清楚一个边界:Vite的环境变量是“构建时静态替换”的,不是“运行时动态读取”的。也就是说,同一个构建产物,在不同机器上运行,import.meta.env的值不会变化。如果你需要“同一份构建产物适配不同后端地址”,Vite环境变量这套机制满足不了,得走“运行时配置”路线。

运行时配置常见做法是:在public/下放一个config.js,里面定义window.__APP_CONFIG__,前端代码启动时去读。部署时根据机器环境改写这个文件,不用动业务代码。这种方式在微前端和需要多区域部署的项目里非常普遍。

我这里不展开实现,但提醒你:环境变量的选择标准是“构建时是否确定”。确定,用import.meta.env;不确定,请用运行时配置文件。


我自己实际把这些方案落地过不少次,感受最深的一点是:环境变量这东西看起来小,可一旦前期不规划,后期能恶心死整个发布流程。前缀乱写、敏感信息外泄、.env文件互相覆盖,这些都是我亲眼见过的线上事故。所以建议所有用了Vite或者准备用Vite的团队,一开始就把环境文件命名规则和变量前缀定死,然后严格区分“客户端可见”和“服务端专有”两类配置。

再分享一个小技巧:每次新开一个Vite项目,先新建.env.example,把目前用到的所有VITE_变量都列上去,写上注释,提交到仓库。以后不管是队友接手还是自己隔了几个月回来看,只要对着.env.example右键复制一份改成.env,马上就能跑起来。这套动作虽然简单,却能省下大量“咦,这个报错是因为我没配环境变量吗”的排查时间。希望这篇从配置到原理再到境界的文章能帮你真正把Vite里的环境变量玩利索,少踩几个我已经替你趟过的坑。

返回列表