
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_BASEhttp://127.0.0.1:8080明晃晃写着问题出在哪出在加载顺序上。Vite 不是随便挑一个 .env 文件读它按 mode 挑而且.env.production的优先级高于.env。你在.env里改的那一行被.env.production里的同名变量直接盖掉了代码里读到的永远是.env.production那一份。这个机制本身没毛病它就是为多环境设计的问题在于很多人不知道有优先级这回事。还有一类更隐蔽的变量名没加前缀。代码里写import.meta.env.API_BASE.env里写API_BASExxx本地看着好像也没报错只是值是undefined接口请求直接打到当前域名下去了开发环境因为配了代理反而能通一上线就全挂。这就是VITE_前缀那条安全边界在起作用——不加前缀的变量根本不会被注入到客户端代码里。最后还有一类改完 .env 不重启。Vite 的 dev server 启动时就把 env 读进内存了你改文件它不会热更新这一部分必须 CtrlC 重来。这个坑新人踩得最多改完刷新页面没变化就开始怀疑人生。提示判断「是变量没读到」还是「是代码没写对」最快的办法是在组件里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 是developmentvite build的 mode 是production。你可以用--mode显式指定比如vite build --mode staging。这时候会出现一个反直觉的组合import.meta.env.MODE的值是stagingimport.meta.env.PROD的值是trueimport.meta.env.DEV的值是falseprocess.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是 falseNODE_ENV是 development。这个组合是合理的——你在本地用测试环境的配置跑开发服务器但代码还是开发模式热更新照常。我在一个项目里见过这样的判断if (import.meta.env.MODE production) { 关掉调试面板 }。结果 staging 包打出来调试面板大开着被测试同学当成 bug 提了三个单子。改法很简单换成if (import.meta.env.PROD) { 关掉 }一行搞定。注意如果你的 CI 里显式设置了NODE_ENVstaging这类非标准值某些依赖库的行为可能不受控它们只认 production/development。别为了方便去改NODE_ENV用 mode 就够了。2.3 VITE_ 前缀不是摆设是一条安全边界默认情况下只有以VITE_开头的变量会被注入到客户端代码。这条规则的存在意义是防止你把不该暴露的东西打进 bundle。想象一下.env里同时写着DB_PASSWORDxxx和VITE_API_BASExxx如果没有前缀区分两个都会被打进 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_NAMEdemo-console VITE_APP_VERSION2.4.1 # .env.development VITE_API_BASEhttp://127.0.0.1:8080/api VITE_ENABLE_MOCKtrue VITE_ASSET_BASE/ # .env.staging VITE_API_BASEhttps://staging-api.example.com/api VITE_ENABLE_MOCKfalse VITE_ASSET_BASEhttps://staging-cdn.example.com/static/ # .env.production VITE_API_BASEhttps://api.example.com/api VITE_ENABLE_MOCKfalse VITE_ASSET_BASEhttps://cdn.example.com/static/几个书写上的坑先记一下。第一所有值读出来都是字符串。VITE_ENABLE_MOCKfalse拿到的是字符串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 的资源走测试 CDNproduction 走正式 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 typesvite/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__: stringdefine有三个必须知道的点。第一值必须是 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打包后是 undefineddev 正常变量只在.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跑生成脚本更新类型在必填清单里加一行剩下的交给脚本兜着。