Vite 项目里那套 .env 环境变量机制,是很多团队用得最多、也最容易用歪的一块功能。刚接触的时候觉得无非就是建个文件、加个前缀、import.meta.env 读一下,可真到要同时维护三套甚至四套部署环境,要在一个仓库里打出给不同客户、不同部署通道使用的包时,很多人才发现:变量读不到、mode 传错了、产物全堆在一个目录里、打完包发现配置改不了还得重新构建。这些问题我前后在四五个项目里全踩过一遍。
这篇内容围绕 Vite 里基于 .env 文件做自定义打包这条主线,把加载优先级、mode 的真实含义、非前缀变量在配置里的读取方式、产物分目录、打包后运行时改写配置、类型提示与变量校验,直到常见故障排查,逐段拆开讲。适合已经在用 Vite、但环境配置还停留在“一个 .env 走天下”的同学,也适合准备把老项目迁到 Vite、需要先摸清这套规则边界的同学。文中代码可以照着改,参数取值和踩坑原因我都会说清楚,不搞只贴配置不讲道理的写法。
1. 为什么打包配置不能写死在代码里
1.1 从一个被注释掉三次的文件说起
我接手过一个中台前端项目,request.ts文件顶部躺着三行被注释掉的 baseURL,对应开发、预发、生产三个不同的接口地址。每次发版,负责发布的同学要手动把第一行的注释挪到第三行,改完还要在群里回一句“改好了”。有一次发完版才发现有人把注释挪错了,线上包打到了预发地址上,排查了小半天。
这类问题不是靠“谁细心一点”能解决的。只要配置项散落在源码里,它就永远是靠人眼复核的脆弱环节。Vite 提供的 .env 文件机制,本质是把“随环境变化的值”从“代码逻辑”里剥离出去,让同一份源码在不同 mode 下产出不同产物。理解这句话很重要:变的是被注入进去的字面量,不变的是被打包的那套业务代码。
所以判断一个配置项该不该放进 .env,我一般用一条线划分:凡是部署位置、服务地址、渠道标识、开关这类“同一个代码换环境就要换值”的东西,进 .env;凡是描述业务行为的常量、枚举、状态码,老老实实写在代码或者 config 模块里,不要往环境变量里塞。后者塞进去只会让代码变得难读,还容易被人误改。
1.2 变量是在构建那一刻被“焊死”的
这是整套机制里最需要先弄明白的一点。Vite 处理import.meta.env.VITE_XXX的方式,是在构建阶段做文本替换,把代码里的表达式直接换成字符串字面量。构建产物里根本不存在“读环境变量”这个动作,值在打包完成的那一秒就固定了。
我见过有同学在服务器上改 .env,期望重启容器后生效,把 nginx 配置翻了一遍最后发现根本没用。因为 docker 镜像里躺着的是一份已经编译好的静态文件,js 里写死的就是构建机器上的那个值。要改只能重新构建,或者走后面第 3.5 节讲的运行时注入方案。
反过来说,这个特性也带来了好处:既然是编译期替换,压缩器就能做常量折叠。if (import.meta.env.VITE_FEATURE_FLAG === 'on')这种写法在构建后可能整段被裁掉,不会留下无谓的分支判断。所以真正用来“特性开关”的变量,用字面量比较是比动态取值更划算的。
1.3 方案设计要先定下三个硬约束
动手写配置之前,先想清楚三件约束,能省掉后面大量返工。
第一,环境的枚举值必须收敛。别允许任何人随手--mode xxx传一个没定义过的 mode 名。没有对应的 .env.xxx 文件时,Vite 只会加载最基础的两份,变量缺失会变成 undefined 一路传到业务代码里,最后在某个页面上炸掉。做法是把可用的 mode 列成枚举,写进 scripts 里固定住。
第二,谁是变量来源的唯一出题人。我倾向让 .env.[mode] 文件成为唯一来源,禁止在构建命令里再临时加一堆内联变量。原因很直接:内联变量不会出现在仓库里,别人构建同一个 mode 拿到的产物跟你不一样,出了问题无从对比。
第三,配置文件和环境文件必须能对照。变量名要成体系,比如全部用VITE_加上业务域前缀,不要一个叫API、一个叫SERVICE_ROOT、一个叫backendPath,半年后谁也不知道哪几个还在用。
2. .env 文件的加载顺序与命名规则
2.1 四类文件的优先级矩阵
Vite 在启动或者构建时,会按固定顺序去读环境文件,后读到的同名变量覆盖先读到的。这个顺序是很多人记混的地方,我直接列成表。
| 文件名 | 何时加载 | 建议是否入库 | 优先级 |
|---|---|---|---|
.env | 所有 mode | 入库 | 最低 |
.env.local | 所有 mode | 不入库 | 低于第 3 项 |
.env.[mode] | 指定 mode | 入库 | 高 |
.env.[mode].local | 指定 mode | 不入库 | 最高 |
表里“优先级”指的是同名变量以谁为准。举个具体场景:.env里写VITE_APP_TITLE=通用管理台,.env.staging里写VITE_APP_TITLE=预发环境,跑--mode staging时最终拿到的是“预发环境”。而.env.staging.local如果也写了这个 key,那就是它说了算。
我自己团队的约定是:.env只放那些所有环境都不变的公共值,比如应用标题前缀、分页大小默认值;.env.[mode]放该环境的接口地址、构建产目录、渠道标识;.env.local系列永远不进仓库,留给开发同学本地覆盖调试用。
注意:
.env.local在所有 mode 下都会被加载,很多人以为它只对 development 生效,结果在预发包里被本地的调试配置污染了。如果你有这种担心,可以在.gitignore里把.env.*.local和.env.local一起忽略,同时在 CI 脚本里加一步检查这两个文件是否存在,存在就报错退出。
2.2 mode 和 NODE_ENV 不是同一个东西
这是新手最容易混淆的一对概念,我面试时问过好几次,答清楚的人不多。
mode是 Vite 自己的概念,用来决定加载哪一份.env.[mode]文件。它的默认值取决于命令:vite开发时是development,vite build时是production。你完全可以vite build --mode staging,此时 mode 是 staging,但它依然是一个生产构建——代码会被压缩、Tree-shaking 照常执行。
NODE_ENV是 Node 生态里的约定,Vite 会把它默认设成与 mode 对应的值(开发对应 development,构建对应 production),但它不会因为你传了--mode staging就自动变成 staging。也就是说,跑--mode staging构建时,process.env.NODE_ENV仍然是production。
那怎么判断当前是哪个环境?用import.meta.env.MODE。这个内置变量就是当前 mode 的字面量,在.env.staging下它等于'staging'。我在业务里通常这么写:
// src/config/env.ts const mode = import.meta.env.MODE const isProdBuild = import.meta.env.PROD // 只要 build 出来就是 true export const ENV_NAME = (import.meta.env.VITE_ENV_NAME || mode) as 'dev' | 'staging' | 'prod' export function isStaging() { return ENV_NAME === 'staging' }这里我加了一个自己的VITE_ENV_NAME而不是直接用 MODE,原因是有些历史项目打包脚本把 mode 传得五花八门,用自定义变量做一层映射更可控。但如果你是新项目,直接用 MODE 就好,少一层间接。
顺带提醒:MODE、BASE_URL、DEV、PROD、SSR这几个是内置变量,你在 .env 文件里写同名变量是覆盖不掉的,Vite 会忽略。我见过有人在 .env 里写VITE_PROD=false想假装生产环境,这当然不生效,别在这上面浪费时间。
2.3 VITE_ 前缀是怎么筛出来的,能不能改
Vite 默认只把VITE_开头的变量暴露给客户端代码,其它变量留在 Node 侧。这个设计的用意是防止敏感信息被打进 bundle——比如数据库连接串、内部密钥,如果也一并注入到浏览器端,那就等于公开发布了。
这个前缀是可以改的,在vite.config.ts里配envPrefix。有些团队用公司缩写统一成APP_,理由是避免和第三方库的变量撞名。
// vite.config.ts export default defineConfig({ envPrefix: ['APP_', 'VITE_'], })这里有两个实操细节值得说。
一是envPrefix支持传数组,但数组里的每个前缀都会生效,不是“找到第一个就用”。如果你同时配了APP_和VITE_,两套变量都会被打进产物,筛选力度反而变弱了。我倾向只留一个前缀,不要贪方便。
二是前缀筛选只作用于“暴露给客户端”这一步,不影响loadEnv读取。也就是说,即使某变量只有DB_前缀,在vite.config.ts里也能读到它,只是业务代码里访问不到。这个差异是第 3.3 节方案的立足点。
再补一个容易被忽略的规则:.env文件里的值全部会被解析成字符串。VITE_ENABLE_MOCK=false读出来是字符串'false',而'false'在布尔判断里是真值。这是巨大坑,很多人写if (import.meta.env.VITE_ENABLE_MOCK),结果明明关掉了却一直在走 mock 分支。正确写法是显式比较:
// 错误示范:永远进 if if (import.meta.env.VITE_ENABLE_MOCK) { /* ... */ } // 正确示范 export const MOCK_ENABLED = import.meta.env.VITE_ENABLE_MOCK === 'true'数字同理,VITE_TIMEOUT=5000读出来是'5000',参与运算时靠隐式转换侥幸能跑,但遇上+拼接就会变成'5000' + 1000 = '50001000'。我现在的做法是统一在src/config/env.ts里做一次解析和类型收敛,业务代码只从这里面拿,不直接碰import.meta.env。
3. 一套能直接抄的多环境打包方案
3.1 目录与文件划分
先说结构。假设项目要发布到开发、测试、生产三套环境,对应 mode 命名为development、staging、production(开发环境直接用默认值即可,不需要额外文件)。我在项目根目录下这样摆:
# .env —— 所有环境共享 VITE_APP_TITLE=运营管理台 VITE_PAGE_SIZE=20 # .env.staging —— 预发环境 VITE_ENV_NAME=staging VITE_API_BASE=https://api-staging.example.com VITE_CDN_BASE=/static/ VITE_ENABLE_MOCK=false VITE_BUILD_OUT_DIR=dist/staging # .env.production —— 生产环境 VITE_ENV_NAME=prod VITE_API_BASE=https://api.example.com VITE_CDN_BASE=/static/ VITE_ENABLE_MOCK=false VITE_BUILD_OUT_DIR=dist/prod开发环境那份.env.development里放本地 mock 地址和VITE_ENABLE_MOCK=true,构建预发和生产时不加载它,天然隔离。
有几点命名上的讲究。VITE_BUILD_OUT_DIR这个变量是给我自己在vite.config.ts里读的,不是一个“给浏览器看的”变量。它其实可以不加VITE_前缀——加了会顺带被打进产物,虽然无害但没必要。我在早期项目里加惯了,后来觉得还是让它带前缀更省心,因为loadEnv默认只返回带前缀的变量,不带前缀要额外传参数。这是一个权衡,后面 3.3 节会细说。
值里如果有空格、#、=,一定要用引号包起来。dotenv 的解析规则里,#会被当作行内注释起点,VITE_APP_TITLE=运营 管理台这种不加引号也可能出意外。我现在的习惯是所有含空格的值一律双引号包裹。
还有一个版本要提:.env支持变量引用吗?Vite 底层用的解析库能力有限,别指望VITE_API=https://${VITE_HOST}/api这种插值。要拼路径就在 config 里自己拼,别在 env 文件里玩花活,我试过,踩坑。
3.2 scripts 里的 --mode 怎么写才不出错
写死 mode 是纪律问题。我的package.json一般长这样:
{ "scripts": { "dev": "vite", "build:staging": "vite build --mode staging", "build:prod": "vite build --mode production", "preview:staging": "vite preview --mode staging --outDir dist/staging" } }几个要点。
第一,不要给build留一个裸命令。有人习惯保留"build": "vite build"作为默认,结果 CI 里有人调npm run build打了个默认生产包扔到测试环境上去了。删掉裸命令,强制每个人选一个明确的入口,这是一种很低成本的防错设计。
第二,注意不同包管理器的传参差异。用npm run build:staging -- --mode production是能把后面的参数透传进去的,会覆盖脚本里的 mode,等于留了个后门。这个没法完全拦住,只能靠流程和 code review。pnpm 的写法略有不同,pnpm build:staging --mode production也能透传,团队里统一用同一种包管理器会省掉很多沟通成本。
第三,vite preview读取的也是 mode,但要注意它读的是构建时的产物目录。如果你的产物已经按环境分了目录,preview 时得用--outDir指过去,否则它去dist下找不到东西。这个点我卡过一次,看着空白页以为是路由问题,其实读的是空目录。
3.3 vite.config.ts 里读非前缀变量的正确姿势
业务代码里读import.meta.env就够了,但配置文件里做自定义构建行为的时候,需要主动去读 .env 文件。这里用loadEnv。
// vite.config.ts import { defineConfig, loadEnv } from 'vite' export default defineConfig(({ mode }) => { // 第三个参数传 '',表示不做前缀过滤,所有变量都返回 const env = loadEnv(mode, process.cwd(), '') return { base: env.VITE_CDN_BASE || '/', build: { outDir: env.VITE_BUILD_OUT_DIR || 'dist', sourcemap: env.VITE_ENV_NAME !== 'prod', rollupOptions: { output: { manualChunks: { vendor: ['vue', 'vue-router'], }, }, }, }, define: { __BUILD_TIME__: JSON.stringify(new Date().toISOString()), __API_BASE__: JSON.stringify(env.VITE_API_BASE), }, } })逐条说清楚为什么这么写。
用函数式配置。defineConfig接受函数时,参数里有mode,这是拿到当前 mode 的唯一入口。很多人写死对象配置,然后在里面硬编码base,结果换环境就失效。
loadEnv(mode, process.cwd(), '')的第三个参数是关键。默认值只返回带前缀的变量,传空字符串表示全部返回。这意味着你可以定义一个不带前缀的SENTRY_TOKEN在构建期使用,同时保证它不会出现在浏览器产物里。这个技巧我在需要对接构建期上传 sourcemap 的场景下用过,很实用。
process.cwd()是环境文件所在目录。如果你的项目是 monorepo,配置文件在子包里,这里的路径要相应调整,否则会读不到根目录的.env。这种情况我一般显式传path.resolve(__dirname, '../..')。
base用 CDN 前缀配置。注意 base 会影响 index.html 里的资源引用路径,也会影响import.meta.env.BASE_URL的值。如果 CDN 前缀是以/结尾的,Vite 拼接才正确;如果写成/static而不带斜杠,可能拼出/staticassets/index.js这种错误路径。这个细节我在两个项目里都提醒过,仍然有人栽。
define用来注入编译期常量。__BUILD_TIME__做过运维的同学会喜欢,页面上挂个版本号,出问题一眼看出当前跑的是哪个构建。注意define的值必须是 JSON 序列化后的字符串,直接写__BUILD_TIME__: new Date()会报错。
3.4 让产物落在不同目录
产物分目录的价值在流水线上体现得最明显。构建机连续打三个环境的包,如果都输出到dist,后一个会覆盖前一个,上传脚本拿到的是最后一个。分目录之后,上传环节只需按目录取文件。
目录名从环境变量来,也就是上面VITE_BUILD_OUT_DIR那个用法。要提醒的是 outDir 与 base 的配合:如果 base 配的是绝对路径/static/,产物放哪个目录都不影响引用;如果 base 配的是相对路径./,那部署时的目录层级会直接影响访问。我建议非必要不用相对 base,坑太多。
清理策略也要留意。build.emptyOutDir默认是 true,会在构建前清空 outDir。如果你把 outDir 配成dist/${mode},那是安全的;但如果哪天有人手滑把 outDir 配成了dist,而你又恰好把三个环境的包都放在里面,一次构建就全清了。我在 CI 里加了一道防护:构建前先判断 outDir 是否等于dist根目录,是的话中断并提示。
3.5 打包后还要改配置:运行时注入方案
有类需求会突破“构建期写死”这个前提:交付给客户的镜像需要现场改接口地址,或者需要一份产物在多个机房复用;还有些私有化部署场景,客户不允许重新打包。
这种时候就需要把一部分配置从构建期挪到运行期。我的做法是在public目录放一个config.js,构建时它会被原样拷到产物根目录,运行时由 index.html 加载,挂到 window 上。
// public/config.js —— 部署时可被运维直接改写 window.__APP_CONFIG__ = { apiBase: '/api', uploadMaxSize: 20, featureFlags: { newDashboard: false, }, }<!-- index.html --> <script src="%BASE_URL%config.js"></script> <script type="module" src="/src/main.ts"></script>// src/config/runtime.ts type RuntimeConfig = { apiBase: string uploadMaxSize: number featureFlags: Record<string, boolean> } const fallback: RuntimeConfig = { apiBase: import.meta.env.VITE_API_BASE, uploadMaxSize: Number(import.meta.env.VITE_PAGE_SIZE) || 20, featureFlags: {}, } export const runtimeConfig: RuntimeConfig = { ...fallback, ...(window as any).__APP_CONFIG__, }这套方案的关键在于把变量分成两类。构建期变量(比如构建时间、版本号、打包时的环境标识)留在 .env 里;运行期变量(接口地址、开关、外部链接)走 config.js。两边的默认值要对齐,config.js 加载失败时退回构建期的值,页面不至于白屏。
这里有个坑必须提醒:config.js是静态资源,浏览器会缓存它。运维改完文件刷新页面没生效的情况我遇到过好几次。解决办法要么在引用时加时间戳查询参数(构建期生成),要么在服务端对这个文件单独设置不缓存响应头。前者简单,代价是每次构建都产生一个不同的引用,反而更难缓存;后者需要在部署配置里动手。我现在倾向后者,让 config.js 走no-cache,其它静态资源长缓存,职责分得清。
另外,文件名带 hash 的问题也值得说一句。public目录下的文件不参与 hash 计算,原样拷贝,这正好符合我们的要求。如果放到src里 import,就会被加上 hash,运维就没法稳定找这个文件了。
4. 类型提示、校验与团队协作
4.1 env.d.ts 里把变量声明补全
不补类型的时候,import.meta.env.VITE_XXX在 TS 里是any,编辑器没有补全,写错变量名也不报错,运行起来才拿到 undefined。补一下成本很低,收益很大。
// src/vite-env.d.ts /// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_APP_TITLE: string readonly VITE_ENV_NAME: 'dev' | 'staging' | 'prod' readonly VITE_API_BASE: string readonly VITE_CDN_BASE: string readonly VITE_ENABLE_MOCK: 'true' | 'false' readonly VITE_BUILD_OUT_DIR: string } interface ImportMeta { readonly env: ImportMetaEnv }注意我把VITE_ENV_NAME和VITE_ENABLE_MOCK都写成了字面量联合类型而不是string。这样在业务代码里做比较时编辑器会提示可用值,写错'stageing'立刻标红。这个细节带来的收益比想象中大,尤其是团队新人多的时候。
还有一个配套动作:把这份声明当成契约。每次新增变量,先改声明再加 .env 文件。顺序反过来容易漏。我在 code review 时的检查点之一就是“diff 里有新增的 VITE_ 变量,但没有对应的类型声明”。
4.2 构建前把必需变量卡住
类型声明只在编译期起作用,运行时变量缺失仍然会静默失败。所以在 vite.config.ts 里加一次性校验很有价值。
const REQUIRED = ['VITE_API_BASE', 'VITE_ENV_NAME'] as const export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') const missing = REQUIRED.filter((k) => !env[k]) if (missing.length) { throw new Error(`[env] 缺少必需变量: ${missing.join(', ')},当前 mode: ${mode}`) } // ... })throw会让构建直接中断,CI 上立刻暴露。相比让产物打到线上再由用户发现接口 404,这个成本低得多。
更进一步,我见过一种做法是校验变量值的“合法域”。比如VITE_ENV_NAME只允许三个值,出现别的立刻报错。这防止了从别的分支复制 .env 文件时没改干净的情况——这事儿挺常见,预发包带着生产地址上线,属于严重事故。校验逻辑十行代码,能挡掉一整类问题。
如果你所在团队有 CI,可以把这个校验脚本单独提出来,在构建之前跑一遍,甚至做成 pre-commit 钩子检查 .env 文件的变更。这类工具化的投入,长期看很值。
4.3 哪些变量绝对不能加 VITE_ 前缀
这条规则值得单独占一节,因为它涉及安全边界。
凡是带VITE_前缀的变量,最终都会以明文形式躺在前端 bundle 里。所以下面这些一个都不能加前缀:第三方服务的密钥、带签名的私有令牌、内部服务的账号密码、任何你不希望用户在浏览器 DevTools 里搜到的内容。
我遇到过有人把地图服务的 key 直接放VITE_MAP_KEY里打进产物,觉得“前端用的 key 无所谓”。这个判断要看具体服务商:有的 key 可以配合域名白名单限制,风险可控;有的 key 一旦泄露就能被随意调用产生费用。判断不了的时候,一律走后端代理转发,前端不持有凭证。
那构建期需要用到这些密钥怎么办?比如构建完成后上传 sourcemap 到错误收集平台。这时候用不带前缀的变量,配合loadEnv的第三参数空字符串,在vite.config.ts里读出来用,用完即弃,不进产物。这个模式我在两个项目里落地过,效果不错。
还要提醒一句.env.local的权限问题。有些团队把它提交了,理由是“方便新人快速上手”。这相当于把本地调试配置变成了团队共享配置,一个人的改动会影响所有人,而且一旦里面有过期凭证,扩散范围就大了。我坚持的做法是提交一份.env.example作为模板,.env.local严格忽略。
5. 常见问题排查实录
5.1 高频问题速查表
真跑起来之后,问题基本集中在几个固定位置。我把它们整理成一张速查表,遇到事先对号入座,比到处翻文档快。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 变量读出来是 undefined | 没加VITE_前缀;或 .env 文件名拼错 | 检查前缀与文件名的 mode 拼写 |
| 改了 .env 不生效 | 打包后值是固定的;或浏览器缓存 | 重新构建;确认访问的是新产物 |
| 布尔判断总是成立 | 值是字符串'false' | 用=== 'true'显式比较 |
| 本地配置污染了测试包 | .env.local全 mode 生效 | 检查该文件是否被提交或残留 |
| 产物目录被清空 | emptyOutDir生效且 outDir 重叠 | 每个环境用独立子目录 |
| CI 上构建报变量缺失 | CI 环境没有 .env 文件 | 把 .env 纳入仓库或由流水线注入 |
| 部署到子路径后资源 404 | base与实际部署路径不一致 | 用环境变量控制 base 并保持斜杠 |
| 运维改 config.js 不生效 | 静态资源被长缓存 | 对该文件单独设置不缓存头 |
表里最后一条值得多说两句,因为它涉及“构建期”和“运行期”两种配置思路的切换成本。一旦项目决定用 config.js 做运行时注入,就必须同步通知部署环节,让他们知道这个文件的特殊性。我在一个项目里做过,交接文档里专门开了一节讲这个文件能不能改、改了怎么验证、改错怎么回滚。
5.2 几个靠猜猜不出来的坑
第一个坑是.env文件的编码和换行符。Windows 上编辑生成的文件如果是 CRLF 换行,偶尔会导致最后一行解析异常。这个不是每次都复现,很难查。现在我统一在.gitattributes里指定*.env text eol=lf,并且编辑器设置成 UTF-8 无 BOM。BOM 尤其要注意,带 BOM 的文件第一行 key 会解析失败,症状就是“第一个变量永远读不到”,特别迷惑。
第二个坑是变量名里的小写。我用过VITE_api_base这种写法,在某些场景下能跑,但一旦和其他工具链配合就出问题。约定上保持一致:环境变量名统一大写加下划线,这既是惯例,也避免了跨平台的不一致。
第三个坑是多入口项目里的变量作用范围。如果你的项目有多个 html 入口,index.html 里能用的%VITE_XXX%替换只在各自的 html 里生效,不会跨文件。我试过在一个子入口里引用只定义在主入口环境的变量,结果是原样输出%VITE_XXX%字符串,页面直接露馅。
第四个坑是 mode 拼写错误导致的“静默降级”。传--mode prod,但文件叫.env.production,Vite 不会报错,它只是找不到对应文件,然后所有变量回退到.env和.env.local的值。如果你.env里恰好有兜底值,页面能打开,接口却指向了错误地址,排查起来非常绕。这就是为什么我在 3.2 节强调 mode 要写死在 scripts 里——人手动敲字必然会出错。
第五个坑是关于构建缓存。有些 CI 平台会缓存node_modules和构建中间产物,如果缓存策略没覆盖.env文件,可能出现“改了环境变量但产物没变”的情况。判断方法很简单:产物里搜一下旧值还在不在,在就说明缓存命中了。处理方式是在流水线里把.env*加入缓存键的计算范围。
5.3 一个万能的自查流程
遇到环境变量相关的故障,我一般按这个顺序走一遍,绝大多数情况三步内定位。
第一步,在vite.config.ts里临时打一行console.log(mode, env),看配置文件读到的值和预期是否一致。这一步能区分“文件没读到”和“业务代码没拿到”两类问题。
第二步,在浏览器控制台执行import.meta.env,但这只在开发环境可行,生产构建里拿不到。所以更通用的做法是在入口文件里临时加一行console.log(import.meta.env.VITE_XXX),构建后看产物内容或者页面控制台。
第三步,去构建产物的 js 里 grep 那个值。搜到了说明注入成功,问题在使用方;搜不到说明注入环节就错了,回去检查前缀和文件名。这一步最直接,我几乎每次都用。
6. 我在几个项目里沉淀下来的做法
6.1 变量命名与环境矩阵的约定
做了几个项目之后,我把变量命名收敛成三段式:VITE_+ 域 + 具体含义。域目前只有四个:APP(应用层面)、API(接口相关)、BUILD(构建行为)、FEATURE(功能开关)。这样一眼看名字就知道该找哪个模块,也方便按域做批量检查。
环境矩阵也固定下来。行是环境(dev、staging、prod),列是变量,哪些列必须在每一行都有值,用一张表管起来。哪个环境缺哪个值,一眼能看出来。这张表我放在项目文档里,跟代码一起维护。有人觉得这是额外负担,但实际省下的是排查时间——尤其是接手别人项目的时候,五分钟看完矩阵就能上手。
还有一条不成文的约定:一个.env.[mode]文件里的变量数量控制在十五个以内。超过这个量,说明有东西放错了位置,多半是业务常量混进了环境配置。这条规矩执行下来,我们的环境文件一直很好读。
6.2 环境模板文件的维护方式
.env.example这个文件我在每个项目里都放,它承担两个职责:新人入职时的参照物,以及 CI 检查的基准。
内容上我会把每个变量的用途写成注释,包括取值范围和默认值。比如:
# 接口根地址,末尾不要带斜杠 # 取值示例:https://api-staging.example.com VITE_API_BASE= # 构建产目录,相对于项目根目录 # staging 环境用 dist/staging,prod 用 dist/prod VITE_BUILD_OUT_DIR= # 是否开启本地 mock,字符串 'true' / 'false' VITE_ENABLE_MOCK=false注释比变量本身重要。三个月后回来看,变量名基本能猜出用途,但“末尾要不要带斜杠”这种事只有当时写的人知道。我踩过一次,接口地址末尾多了一个斜杠,拼出来的请求路径变成双斜杠,后端把路由匹配拒了,查了两小时。
同步机制上,我做了一个简易的比对脚本:读取.env.example的所有 key,逐个检查各个.env.[mode]里是否存在,缺了就打印出来。这个脚本挂在构建前置步骤上,跑得很快,能防住“新增变量只改了一份文件”这类漏改。
6.3 关于这套方案适用边界的个人判断
最后说说我个人的判断:这套基于 .env 的自定义打包方案,适合环境数量在五个以内、配置项以字符串为主的场景。超过这个规模,或者配置结构变得复杂(比如需要按地域分组、按租户区分),我会改用一份构建期的 JSON 配置文件,配合代码生成脚本,而不是继续往 .env 里堆。dotenv 的格式限制太多,不支持嵌套结构,硬塞进去只会让解析代码越来越丑。
还有一个判断是,如果你的产物需要被反复部署到不同环境而不允许重新构建,那从一开始就该把运行时注入的架子搭好,不要等到交付前才想起来改。我见过一个项目在上线前一周才发现客户要求现场改配置,临时加了 config.js,结果接口请求模块、上传模块、日志模块全都要改,回归测试做了一整轮。
真要做好这块,我觉得比配置本身更重要的是两条纪律:环境文件入库且可追溯,构建命令不允许临时拼参数。这两条执行到位,剩下的都是细节问题,遇到查一查就好。