打包后环境变量全失效?NODE_ENV、BASE_URL和自定义变量的底层逻辑与排查指南
写在前头这篇东西不是讲某个具体框架的用法它讲的是前端工程化里一个特别容易踩的坑——打包后环境变量失效、BASE_URL 变了、自定义变量读到 undefined。我见过太多人开发环境跑得好好的一打包上线就崩最后查半天发现是环境变量在构建期被“写死”了。这篇文章我会把 NODE_ENV、BASE_URL、自定义变量这三样东西的底层逻辑拆开讲清楚再给一套可以直接抄的配置方案最后附上我自己排查这类问题的实战清单。不管你是用 Vite、Webpack 还是 uniapp 打包这套思路基本都能用。1. 先搞清楚 NODE_ENV 在打包后到底经历了什么1.1 NODE_ENV 的前世今生为什么打包后会变成 production先说一个最基本的认知NODE_ENV 本质上是 Node.js 运行时的一个环境变量但前端项目里它早就不只是“运行时变量”这么简单了。无论是 Webpack 还是 Vite在构建时都会做一步叫做“环境变量内联”的操作——把你代码里写的process.env.NODE_ENV直接替换成字符串字面量。举个例子你在业务代码里写了这样一行if (process.env.NODE_ENV production) { console.log(生产环境逻辑); }Webpack 在构建时通过 DefinePlugin 会把process.env.NODE_ENV这个表达式整体替换成production这个字符串。所以上面的代码在打包产物里实际上变成了if (production production) { console.log(生产环境逻辑); }这不是模拟这是字面意义上的替换替换发生在构建阶段而不是代码运行阶段。这就能解释很多人的困惑为什么我在服务器上临时用export NODE_ENVdevelopment也改变不了已经打包好的代码逻辑因为那段逻辑早在打包那一刻就被焊死了运行时怎么改都没用。那打包工具是怎么决定 NODE_ENV 的值的以 Vite 为例默认情况下执行vite build时 mode 是 production所以 NODE_ENV 就被定为 production执行vite dev时 mode 是 development。Webpack 5 也类似但你也可以在 npm scripts 里显式指定cross-env NODE_ENVproduction webpack --config xxx来告诉它。1.2 你以为的全局变量其实是构建期的常量替换理解了“替换”这个动作很多问题就豁然开朗了。比如打包后你发现process.env.NODE_ENV读到了 production但你自己写的process.env.BASE_URL读出来是 undefined。为什么会这样因为process.env.BASE_URL并不是 Node.js 里真实存在的标准环境变量它通常是某个框架帮你注入的。Vue CLI 里BASE_URL其实来自publicPath配置构建时同样被替换成了字符串。如果你不是在 Vue CLI 体系下或者没有显式配置环境变量注入那么你代码里的process.env.BASE_URL在替换时找不到对应值就会被替换成undefined。这个机制也解释了一个常见现象为什么打包产物里搜不到process.env这些字眼。因为它们在构建期已经被替换掉了产物里只剩下一堆写死的字符串。这就是“静态替换”和“动态读取”的本质区别。理解这一点后遇到任何“开发时正常、打包后异常”的环境变量问题第一反应就应该是这个变量到底有没有被构建工具注入进去2. BASE_URL 的常见坑打包后资源路径为什么全乱了2.1 BASE_URL 在不同框架里的默认行径BASE_URL 这个变量在不同工程体系里含义不完全一样但用途基本都是定义“应用部署的根路径”。Vue CLI 里它是从publicPath计算出来的Vite 里它是从base配置算出来的。默认情况下这两个配置的值都是/表示应用部署在域名根路径下。问题就出在这。如果你的应用最后是部署在https://example.com/根路径下那么BASE_URL/没毛病。但如果部署在https://example.com/admin/这个子路径下而你的构建配置里base还是/那么打包后所有静态资源地址都会变成/assets/index.js浏览器会去https://example.com/assets/index.js找文件——就这样 404 了。在我接触过的项目里至少有三分之一的白屏问题是因为这个。尤其是用 nginx 部署到子路径、用 OSS 静态托管、或者用微信 H5 放在公众号菜单下的时候BASE_URL配置错误几乎必然导致资源加载失败。常见的错误表现是HTML 能打开但页面空白控制台里一堆.js、.css文件 404。2.2 publicPath 与 BASE_URL 的联动一个配错全盘皆输在实际工程里BASE_URL 不只是影响静态资源路径它还会影响前端路由的 base 配置。举一个 Vite Vue Router 的例子// router/index.js const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes });这里的import.meta.env.BASE_URL在构建期会被替换成你配置的 base 值。如果 base 是/admin/那么路由就会以/admin/开头。如果你只改路由的 base不改构建配置的 base那就会出现“路由地址对但静态资源全 404”的割裂状态。反过来如果构建配置里的 base 改了但路由没有使用 BASE_URL那就会出现“资源加载正常但路由刷新后 404”的情况。这两种错位我在不同项目里都踩过排查起来相当费时间。另外要特别提醒Webpack 体系下的publicPath和 Vite 的base还有一个区别就是支持相对路径。Vite 里如果你设置base: ./那么打包后的资源路径就是相对路径这种配置在某些不能控制部署路径的场景下非常救命。但相对路径也会带来一个问题就是如果你的应用不是一个纯静态站点而是有 history 路由那么刷新深度路由页面时仍然会 404因为服务器不知道要回退到 index.html。所以相对路径只适合纯静态场景或者你能在服务器层面做好 fallback。3. 自定义变量为什么打包后读不到或者读到的是旧值3.1 变量注入的三种主流姿势自定义环境变量是大家最常用的功能也是最容易出问题的功能。先说清楚目前主流的三种注入方式理解了它们之间的区别你就能明白为什么有的变量打进去有的没打进去。第一种是构建工具自带的 env 文件体系。Vite 会读取项目根目录下的.env、.env.development、.env.production等文件然后把以VITE_开头的变量暴露给业务代码。Webpack 体系下可以用dotenv插件配合DefinePlugin来实现类似效果。第二种是命令行直接注入。比如在 npm scripts 里用cross-env MY_VARhello vite build这种方式注入的变量并不会自动暴露给业务代码。除非你显式在配置里把它读出来再传给 DefinePlugin 或envPrefix。第三种是构建配置里硬编码。就是直接在vite.config.ts或webpack.config.js里写死一个常量然后通过define注入。这种方式最直接但也最容易被忽略——因为它是配置文件里写的不在环境文件体系里很多人根本不知道有这层。那么为什么你打包后自定义变量是 undefined大概率是因为你踩了下面这些坑变量名没有以VITE_开头被 Vite 过滤掉了你用的是process.env.XXX的写法但项目是 Vite应该用import.meta.env.XXX变量定义在.env里但被.env.production里的同名变量覆盖了构建缓存没清读到的还是旧值我见过最隐蔽的一种坑是变量名以VITE_开头但它在vite.config.ts里被引用这没问题可是你在业务代码里用process.env.VITE_XXX去读在 Vite 5 环境下这几乎必然返回 undefined。正确的姿势是import.meta.env.VITE_XXX。3.2 环境文件体系.env.development / .env.production 的正确打开方式这里我展开讲一下 Vite 的环境文件加载规则因为它是目前最容易讲清楚也最容易踩坑的。Vite 加载环境文件的优先级是从上往下的.env— 所有环境下都会加载.env.local— 所有环境下都会加载但会被 git 忽略.env.[mode]— 特定 mode 下加载比如.env.development、.env.production.env.[mode].local— 特定 mode 下加载会被 git 忽略如果有同名变量后面的会覆盖前面的。也就是说.env.production里的VITE_API_URL会覆盖.env里的同名变量。这个机制本身很合理但容易让人困惑的是默认情况下vite build用的 mode 就是 production所以.env.production会被加载如果你执行vite build --mode staging那么加载的是.env.staging而不是.env.productionNODE_ENV 仍然是 production但 mode 变成了 staging。很多团队会利用这个机制做多环境打包。比如# 默认按生产环境打包 npm run build # 打包到测试环境 npm run build:staging对应的 package.json scripts{ scripts: { build: vite build, build:staging: vite build --mode staging } }然后在.env.staging里配置VITE_API_URLhttps://staging-api.example.com VITE_BASE_PATH/staging/这样一次配置两套环境都能打包出来而且互不干扰。4. 实操复盘一套配置让 NODE_ENV、BASE_URL 和自定义变量在打包后全部生效4.1 场景设定与目录结构讲了这么多原理现在用一个真实场景把整个流程串一遍。假设我有一个 Vue3 Vite 的项目部署在https://example.com/coolapp/这个子路径下接口服务在https://api.example.com/v1同时需要区分线上环境和测试环境。项目结构如下project-root/ ├── .env ├── .env.development ├── .env.production ├── .env.staging ├── vite.config.ts ├── package.json ├── index.html └── src/ ├── main.ts ├── router/ │ └── index.ts └── utils/ └── request.ts4.2 vite.config.ts 里的关键配置先看.env文件的内容我习惯把不需要区分环境的公共变量放在这里# .env VITE_APP_TITLE酷应用 VITE_APP_VERSION1.0.0然后是.env.development# .env.development VITE_API_URL/dev-api VITE_BASE_PATH/.env.production# .env.production VITE_API_URLhttps://api.example.com/v1 VITE_BASE_PATH/coolapp/.env.staging# .env.staging VITE_API_URLhttps://staging-api.example.com/v1 VITE_BASE_PATH/staging/接下来是关键vite.config.ts里要把base和VITE_BASE_PATH关联起来import { defineConfig, loadEnv } from vite; import vue from vitejs/plugin-vue; export default defineConfig(({ mode }) { const env loadEnv(mode, process.cwd(), ); return { base: env.VITE_BASE_PATH || /, plugins: [vue()], server: { proxy: { /dev-api: { target: https://api.example.com/v1, changeOrigin: true, rewrite: (path) path.replace(/^\/dev-api/, ) } } }, build: { outDir: dist, sourcemap: false } }; });这里有几个细节要重点解释。第一loadEnv的第三个参数传了空字符串意思是把环境文件里所有变量都加载进来而不是只加载VITE_开头的。如果不传这个参数默认只加载VITE_前缀的变量那我在vite.config.ts里读VITE_BASE_PATH是没问题的因为VITE_BASE_PATH本来就是VITE_开头。第二base直接取自env.VITE_BASE_PATH。这样打包到测试环境时执行npm run build:stagingbase 自动变成/staging/打包到正式环境时执行npm run buildbase 自动变成/coolapp/。不需要改任何代码。第三开发环境的代理配置。开发时VITE_API_URL是/dev-api请求/dev-api/user/list会被代理转发到https://api.example.com/v1/user/list。生产环境就不走代理直接请求https://api.example.com/v1。这套模式几乎可以无脑复用到任何前后端分离的项目里。再看路由的配置使用import.meta.env.BASE_URL作为路由 base// src/router/index.ts import { createRouter, createWebHistory } from vue-router; const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ // 路由表 ] }); export default router;这个import.meta.env.BASE_URL在构建时会自动替换成vite.config.ts里配置的base值。打包到正式环境它就是/coolapp/打包到测试环境它就是/staging/。不需要手动处理。4.3 自定义变量在业务代码中的正确使用方式业务代码里读取这些变量统一推荐用import.meta.env// src/utils/request.ts import axios from axios; const request axios.create({ baseURL: import.meta.env.VITE_API_URL, timeout: 10000 }); request.interceptors.request.use((config) { console.log([${import.meta.env.VITE_APP_TITLE}], v${import.meta.env.VITE_APP_VERSION}); return config; }); export default request;如果在 TypeScript 项目里你需要在src/vite-env.d.ts里补上类型声明不然 TS 会报错/// reference typesvite/client / interface ImportMetaEnv { readonly VITE_APP_TITLE: string; readonly VITE_APP_VERSION: string; readonly VITE_API_URL: string; readonly VITE_BASE_PATH: string; } interface ImportMeta { readonly env: ImportMetaEnv; }这套配置完成后执行npm run build产物在dist目录下index.html里的资源路径会自动变成https://example.com/coolapp/assets/xxx.js或者相对路径形式取决于部署方式。代码里的import.meta.env.VITE_API_URL会被替换成https://api.example.com/v1import.meta.env.BASE_URL会被替换成/coolapp/。这里要提醒一个点部署到子路径时base标签很重要。Vite 在构建时如果base配置了子路径会自动在index.html中生成对应的base href/coolapp/标签。这个标签会对页面内所有相对路径比如img src./logo.png产生影响。如果你手动改了构建后的index.html把base标签删了那页面上的相对路径就会乱套。5. 常见问题与排查技巧实录5.1 打包后自定义变量为 undefined 的排查流程这是最高频的问题我按排查步骤列一下。先别急着改代码按顺序做检查变量名是否以VITE_开头。如果没有Vite 默认不会暴露给业务代码。检查代码里用的是process.env.XXX还是import.meta.env.XXX。Vite 项目里应该用后者。检查环境文件里的变量是否被其他文件覆盖。比如.env里定义了一个变量.env.production里又定义了同名变量后者的值会生效。如果你以为读到的是.env里的值但实际被覆盖了排查起来很迷。检查是否在vite.config.ts里用了loadEnv并且是否把变量正确传递到了define或base。如果你在vite.config.ts里访问process.env.VITE_XXX这不一定读得到因为vite.config.ts本身是在 Node 环境里跑的它读的是系统环境变量而不是.env文件里的变量。要用loadEnv加载。清缓存重打包。Vite 的依赖预构建缓存有时候会抽风node_modules/.vite目录清掉重新npm install或直接npx vite build --debug看看输出信息。这里我额外分享一个排查技巧在代码里临时打印环境变量打包后搜产物。比如// 临时调试代码 console.log(JSON.stringify(import.meta.env));打包后在dist/assets目录下搜打印出来的字符串如果搜索不到https://api.example.com/v1那说明变量根本没有注入成功。5.2 打包后静态资源 404 的排查思路资源 404 基本就是 base 路径的问题。排查方法很简单打开浏览器控制台看报错的完整资源 URL。如果是https://example.com/assets/xxx.js而你的应用实际部署在/coolapp/下那就是 base 没配对。修改vite.config.ts里的base为/coolapp/重新打包。如果还是 404检查 nginx 配置看有没有做静态资源目录的映射。比如location /coolapp/ { alias /usr/share/nginx/html/coolapp/; try_files $uri $uri/ /coolapp/index.html; }如果公司没有专门的运维这个try_files配置对 history 路由特别重要。没有它用户刷新/coolapp/about这个地址时会直接 404。5.3 NODE_ENV 正确但运行时逻辑异常的排查方法有的问题不是环境变量本身读不到而是“读到了但逻辑判断不对”。比如你在代码里写if (import.meta.env.DEV) { // 开发环境逻辑 }如果打包后这段逻辑还在但import.meta.env.DEV被替换成了false那这段代码不会执行——这是正常的。但如果你把import.meta.env.DEV传到了子组件或者第三方库里而那个库是在运行时才读取那就可能出现不一致。举个我实际遇过的场景某 UI 组件库内部会根据process.env.NODE_ENV判断是否输出警告日志。在 Vite 项目里组件库源码里的process.env.NODE_ENV不一定会被替换因为它不是你的业务代码。如果你引入了这个组件库的源码版本就得在 Vite 配置里显式处理define: { process.env.NODE_ENV: JSON.stringify(mode production ? production : development) }如果你用的是vite-plugin-define或者某些生态插件也可以通过配置把process.env.NODE_ENV全局注入。但最稳妥的方式还是在构建配置里显式定义。5.4 打包类通用问题速查表我在处理问题的时候习惯做表这里把常见问题整理成一张速查表方便大家直接对照症状可能原因解决方法打包后process.env.NODE_ENV显示 production但代码行为还是开发模式构建模式没配对或代码里误用了动态属性访问检查 package.json scripts确认vite build或webpack --mode production代码里不要写process.env[NODE_ENV]这种动态访问自定义变量打包后是 undefined没加VITE_前缀或用了process.env而不是import.meta.env统一改用import.meta.env.VITE_XXX检查 env 文件静态资源 404 白屏base或publicPath没配置子路径修改vite.config.ts中base或在 Vue CLI 中修改publicPathhistory 路由刷新 404服务器没有 fallback 到 index.htmlnginx 配置try_files $uri $uri/ /index.html;打包体积过大或打包慢没做代码分割和依赖优化配置build.rollupOptions.output.manualChunks开启 gzip同一个变量在不同环境读到不同值env 文件优先级覆盖检查.env.[mode]是否有同名变量覆盖5.5 uniapp 打包场景的特殊注意事项因为热词里出现了很多 uniapp 打包相关的词我单独说一嘴。uniapp 的 H5 打包底层其实就是 Vite新版本或 Webpack老版本环境变量的处理方式跟上面讲的几乎一致。但你需要注意几个差异点第一uniapp 里约定了一些特殊的环境变量比如process.env.NODE_ENV、process.env.UNI_PLATFORM、process.env.VUE_APP_DEV_SERVER。其中UNI_PLATFORM在 H5 打包时会变成h5在 App 打包时会变成app在小程序端会变成对应的平台名。第二在你写条件编译时要注意process.env.NODE_ENV在生产打包后一定是production但process.env.NODE_ENV在 App 端还受开发工具影响。如果你用 HBuilderX 运行到手机真机NODE_ENV可能是development这时候你在代码里判断环境不能只依赖NODE_ENV。第三uniapp 如果是用 CLI 项目跑的话.env文件的支持情况和原生 Vite 项目并不完全相同。有些版本需要依赖uni-env或手动在vite.config.js里做变量注入。如果你在 uniapp 项目里发现自定义变量读不到先确认你的 uniapp 是 Vite 版本还是 Webpack 版本网上很多教程是混着讲的容易误导。我建议在 uniapp 项目里做环境区分时尽量用条件编译配合自定义变量而不是单靠NODE_ENV。比如// #ifdef H5 const baseUrl import.meta.env.VITE_API_URL || https://api.example.com/v1; // #endif // #ifdef APP-PLUS const baseUrl https://api.example.com/v1; // #endif这样不管是打 H5、打 App 还是打小程序环境地址都不会乱。6. 再补几个真实场景里的“隐形坑”工具链的问题往往不只在配置本身很多时候是多个环节叠加出来的。我再分享几个在团队协作里经常遇到的隐形坑这些不是文档里会写的都是实打实踩出来的经验。第一个是npm scripts 的跨平台问题。很多人喜欢在 package.json 里直接写NODE_ENVproduction vite build。这在 Mac 和 Linux 上没问题但在 Windows 的 cmd 和 PowerShell 里会直接报错。推荐用cross-env来统一{ scripts: { build: cross-env NODE_ENVproduction vite build, build:staging: cross-env NODE_ENVproduction vite build --mode staging } }这样团队里不管是谁、用什么系统跑出来的行为都是一致的。第二个是CI 环境里的环境变量注入。很多团队用 Jenkins、GitLab CI 或 GitHub Actions 打包如果觉得“测试环境和生产环境直接用 .env.production 区分就行”那就要小心了。因为 CI 的执行环境可能已经设置了同名的环境变量这会覆盖 .env 文件里的值。Vite 的loadEnv有一种行为如果系统环境变量里已经有某个变量它不会被 .env 文件覆盖。具体来说loadEnv的加载规则是.env文件里的值默认不会覆盖已有的process.env除非你显式传overwrite: true。所以如果 CI 里设置了VITE_API_URL这个环境变量那么即使.env.production里写的是正式地址最终打包出来的也可能是 CI 里配置的值。这本身是好事——CI 里可以动态切换目标环境但如果你不知道这个优先级排查起来会非常抓狂。第三个是打包产物里的敏感信息。这个问题我建议每个前端团队都认真对待。import.meta.env.VITE_XXX这类变量在打包后会以纯文本的形式出现在 JS 产物里。也就是说任何人都可以通过浏览器开发者工具看到你的 API 地址、App ID、百度统计 key 等信息。所以绝不要在VITE_开头的变量里放密钥、密码、token 这类敏感信息。正确的做法是把敏感信息放在后端通过接口下发或者用部署时的运行时配置注入比如在index.html里动态写入全局变量再用 JS 读取。第四个是关于缓存导致的旧版本问题。有时候你明明改了配置重新打包但线上还是旧的这往往不是构建问题而是浏览器缓存或 CDN 缓存。Vite 默认对带 hash 的资源文件做强缓存策略但index.html通常不设 hash有些 CDN 会把它也缓存起来。解决办法是给 CDN 配置index.html的Cache-Control: no-cache或者文件名里加版本号。第五个是关于本地打包和 Docker 打包结果不一致的问题。热词里有人搜“idea 打包docker镜像”“php使用docker打包镜像”这说明很多后端同学也开始接触前端构建了。这里要注意Docker 镜像里的 Node 版本和本机 Node 版本不一致可能导致构建结果有差异。比如本机是 Node 20Docker 基础镜像里是 Node 14某些依赖的编译结果会不一样。统一的思路是Dockerfile 里指定和本地一致的 Node 版本或者在 CI 里统一用固定的 Node 镜像。我个人在实际项目里的做法是所有前端项目的 Dockerfile 都基于同一个 Node 20 alpine 镜像然后通过 multi-stage 构建把产物拷贝到 nginx 镜像里。这样至少能保证构建环境的一致性减少“我本地跑得好好的一到服务器就废了”这类沟通成本极高的扯皮问题。7. 最后的实用建议写到最后我再给几个总结性的建议这些建议都是基于多次踩坑后的复盘希望能让你少走弯路。给环境变量定一个严格的命名规范。我用过的团队里凡是环境变量出问题的基本都是因为命名混乱有的变量叫API_URL有的叫VUE_APP_API_URL有的叫VITE_API_BASE。建议统一为VITE_前缀Vite 项目或VUE_APP_前缀Vue CLI 项目这样既符合框架约定又能通过前缀快速判断变量的作用域和暴露范围。把构建配置和部署路径做成强关联。很多项目只在外层运维文档里写了“部署在子路径下”但代码里 base 永远是/。这样只要有人换一个部署路径就白屏。我建议把部署路径作为环境变量的一部分像上文那样通过VITE_BASE_PATH控制base而不是写死在代码里。写一个环境变量读取的统一封装。在业务代码里每处都用import.meta.env.VITE_XXX当然没问题但一旦变量名变更或者要做默认值兜底改动会特别分散。我习惯在src/config/index.ts里统一导出// src/config/index.ts export const appConfig { title: import.meta.env.VITE_APP_TITLE || 默认标题, version: import.meta.env.VITE_APP_VERSION || 0.0.0, apiUrl: import.meta.env.VITE_API_URL || , basePath: import.meta.env.BASE_URL || / };这样所有业务代码都从appConfig读取配置以后加变量、改默认值只需要动这一个文件。最后一个小技巧善用localStorage搭配构建期变量做临时开关。比如你想在正式环境临时打开某个调试面板又不想重新打包可以在代码里写if (import.meta.env.PROD localStorage.getItem(DEBUG_FLAG) on) { openDebugPanel(); }然后你在浏览器控制台执行localStorage.setItem(DEBUG_FLAG, on)就能临时打开调试面板。这种机制在线上排查问题的时候特别有用比每次都打一个 debug 包要高效得多。环境变量这件事说难不难说简单也不简单。核心就一句话构建期替换是静态的运行时读取是动态的把这两者的边界搞清楚大部分问题都能迎刃而解。如果你现在手头正好有打包后环境变量读不到的问题对照上面的排查步骤走一遍大概率能定位到原因。如果还有没覆盖到的场景欢迎在评论区补充我看到了会继续更新这篇内容。