
大概在去年年中我接手一个 Vue3 Vite 的中台项目上线后客户反馈右上角租户信息没渲染出来控制台一堆红色报错。拉下日志一看Cannot read properties of undefined (reading BASE_URL)。第一反应就是项目里肯定有人把import.meta.env和process.env混着用了。翻代码验证果不其然前端页面初始化配置时写的是process.env.BASE_URLbuild 产物压到 CDN 之后浏览器里根本没有 Node 的process对象不报错才怪。后来我把这类错误源逐一排查发现团队里至少有 4 个人分不清这两个 API 的边界。这段经历让我决定写点东西。如果你也在 Vite / Vue / React 项目里同时写过process.env.NODE_ENV和import.meta.env.MODE或者经常在 Node 服务端代码里下意识敲出import.meta.env那这篇文章就是给你准备的。我会把两个 API 的来源、底层机制、运行时机掰开揉碎再用真实事故和排查过程讲清楚边界最后给你一份可以直接落地的 Vite 环境变量配置清单。1. 先看一个让人抓狂的线上事故现场1.1 事故还原为什么配置全部消失把这个事故完整重现一遍。项目使用 Vite 构建入口文件src/main.ts中有这样一段配置加载逻辑const baseURL process.env.VITE_API_BASE ?? /api这段代码在本地开发时完全正常——npm run dev启动后控制台能正常打印出/api接口也能通。但执行npm run build并把产物部署到 Nginx 之后页面所有请求都指向了相对路径登录接口 404首页直接白屏。为什么本地正常、线上不行关键差异在于Vite 的 dev server 运行在 Node.js 环境里它的process.env是可以被读取到的而构建产物最终是浏览器执行的 JavaScript浏览器里没有 Node.js 的process全局对象。于是process.env.VITE_API_BASE实际上执行到process这一步时就已经抛错了根本走不到后面取属性那一步。有人会说不对我本地 build 后也跑过 preview好像没报错。vite preview模拟的是静态文件服务代码依然运行在浏览器中只是页面恰好没有触发到那条读取逻辑而已等真实用户在特定路由下触发立刻原形毕露。这种隐性故障比直接报错更可怕——它不炸在你本地只炸在用户面前。1.2 事故反转有人用对了有人一知半解排查时更有意思的是同一个文件里还有一处import.meta.env.MODE的判断这段代码反而是正常的。也就是说整个项目里两个 API 交替出现一个能用、一个在线上暴雷这种割裂状态最容易让新人产生是不是环境配置问题的错误联想。后面 git 历史显示早期成员用的是import.meta.env.MODE后来另一位同事为了统一配置管理把部分代码改成了process.env.NODE_ENV理由是公司后端 Node 项目都是这么写的。正是这种看着像、用起来也像的错觉埋下了线上事故的根子。所以我认为搞懂这两者的本质差异远比背几个配置项重要。你不一定要记住 Vite 的每一种 env 加载顺序但你必须知道process.env是运行时环境变量、import.meta.env是构建期常量替换这个底层区别才能判断一段代码在什么环境下会不会出事。2. process.env 的本质Node 进程启动那一刻的快照2.1 它从哪里来操作系统传给进程的环境变量表在进入前端框架讨论之前先回到 Node.js 本身。process是 Node.js 提供的全局对象代表当前运行的进程。process.env就是这张进程环境变量表的读入口。你在命令行里执行NODE_ENVproduction node server.js操作系统会创建一个新进程并把NODE_ENVproduction写入该进程的环境变量表。Node.js 启动后把这张表挂到process.env上你的代码里就能读到它。所以process.env的值本质上是进程启动那一刻系统层面的快照不是某个框架凭空生成的。这带来两个直接推论。第一process.env的默认来源是完整的系统环境变量不只有你自己定义的。比如你有HOME、PATH、USER在 Node 代码里同样可以读取到process.env.HOME。第二读取发生在进程启动时如果你在代码里写死了const env process.env.NODE_ENV之后即使你通过外部命令修改了系统配置这个值在内存里也不会自动更新。当然Node 运行期间通过process.env.XXX xxx赋值是进程内存里的动态修改和外部环境变量变化是两码事这一点后面细说。2.2 一个常见的认知误区NODE_ENV 是 Node 原生提供的吗很多刚接触 Node.js 的同事以为process.env.NODE_ENV是 Node 自己封装好的专用变量其实不是。Node.js 本身并不认识NODE_ENV它只是约定俗成的惯例变量主要由各类框架和工具读取。默认情况下直接执行node server.js进程环境变量表里根本没有NODE_ENV你打印它是undefined。为什么很多项目里又能直接读到那是因为执行命令时用cross-env NODE_ENVproduction node server.js显式注入了或者在项目入口处调用过dotenv库把.env文件内容加载到process.env。dotenv 的工作原理也很简单逐行解析.env文件把KEYVALUE通过process.env[KEY] VALUE写入内存。它改变不了进程启动才能有 env的基本事实只是把加载时机提前到了应用入口处。顺带提醒一句dotenv 加载是有顺序的后面的赋值会覆盖前面同名变量。如果有.env和.env.local两个文件记得让工具按正确顺序合并否则本地区分很容易出错。2.3 动态修改与跨平台坑由于process.env就是一个带 setter 的特殊对象代码里可以随时赋值process.env.CURRENT_TASK_ID task-123这种动态性在写脚本、跑定时任务、做单元测试时非常方便比如你在 Jest 里每个用例前临时设置环境变量。但它同时也带来一个问题你在进程内存里修改的值只对这个进程有效不影响其他终端也更不会写回操作系统的环境变量文件。所谓环境变量持久化得靠 shell 配置或 CI 平台管理而不是靠进程内赋值。跨平台的坑则体现在设置命令上在 Linux/macOS 的 shell 里可以写NODE_ENVproduction node app.js但 Windows 的 cmd 和 PowerShell 不认这种语法所以业界通常用cross-env统一写法。这是process.env生态里最常见的小坑也是很多前端同学在本地把服务跑挂的经典原因。3. import.meta.env 的本质构建期就写进代码的常量替换3.1 import.meta 是什么ES Module 的模块元数据入口import.meta是 ES Module 规范中的标准特性每个模块都有一个import.meta对象用来暴露跟这个模块相关的元数据。浏览器原生支持它Node.js 启用 ESM 后也支持。在 Vite 项目中常见的是import.meta.url和import.meta.env前者表示当前模块的 URL后者是所有环境变量的挂载点。但注意import.meta.env并不是 ECMAScript 规范定义的标准属性它是 Vite 在编译过程中注入到模块代码里的。换句话说你在源代码里写import.meta.env.MODEVite 构建时会在产物中把它替换成一个字符串字面量比如production。这个过程发生在构建期compile time不是运行期runtime。这是个最容易误解、也最需要记住的核心差异。3.2 构建产物里它变成了什么静态替换的实质用一个最小例子说明。源代码console.log(import.meta.env.MODE) console.log(import.meta.env.VITE_API_BASE)经过vite build之后你会在dist/assets/*.js中看到类似这样的产物console.log(production) console.log(/api)看到没有源代码中的import.meta.env读取不见了直接变成了具体字符串。构建工具是在打包阶段根据项目根目录下的.env文件内容做了一次精准的文本替换。这个机制决定了三个重要特性第一import.meta.env的变量在构建完成后就定型了。想在浏览器运行阶段动态修改某个环境变量值做不到因为产物里没有这段读取逻辑只有一个写死的字符串。第二直接替换掉的写法必须是静态字面量属性访问比如import.meta.env.MODE。如果你写const key MODE; console.log(import.meta.env[key])Vite 无法在编译期推导出 key 的值产物里不会替换浏览器运行时import.meta.env到底有没有值就完全取决于构建工具是否注入了运行时对象。第三替换行为意味着暴露给浏览器的所有变量都是公众可见的打包产物任何人一查就能看到所以绝不能把服务端密钥写进import.meta.env并在客户端使用。3.3 Vite 内置的五个保底变量Vite 默认提供五个变量即使你不写任何.env文件也可以直接使用变量说明dev 模式典型值build 模式典型值MODE当前运行模式developmentproductionDEV是否开发模式truefalsePROD是否生产模式falsetrueBASE_URL部署基础路径//可配置SSR是否服务端渲染falsefalse这几个内置变量最常见的用途是区分不同环境下的逻辑分支比如if (import.meta.env.DEV)用来只在开发环境打开调试面板或者if (import.meta.env.PROD)在生产环境开启埋点上报。比process.env.NODE_ENV development这类手写判断要更直观也更不容易拼错。3.4 为什么有 VITE_ 前缀这个硬性规则Vite 读取.env文件时并不是所有变量都会暴露给客户端代码。默认只有以VITE_开头的变量会被注入import.meta.env其余变量仅供服务端相关代码比如vite.config.ts通过loadEnv()读取。这个设计是有意为之的安全护栏如果所有配置都无脑暴露到浏览器端密钥泄漏简直是必然的。所以社区的约定是VITE_前缀的变量代表这些是安全地暴露给前端的公共配置而非VITE_前缀的是仅存在于服务器构建上下文。但这里有个常见误区有人以为.env文件里写了API_SECRETxxx只要代码里不去引用它就不会泄漏。这不对——这个变量根本不会被注入到import.meta.env所以客户端代码想读也读不到它只存在于 Vite 的构建进程内存里。如果你确实要用它得在vite.config.ts里面通过loadEnv手动读入再配合define或者服务端接口使用。4. 六个维度的硬核对比一次看清全部差异4.1 一张对比表把边界画清楚前面两章讲了各自的底层机制这一章直接用表格把两个 API 的所有关键维度摆在一起方便你收藏回看。维度process.envimport.meta.env来源Node.js 运行时全局对象processVite 构建工具注入的模块元数据读取时机进程启动时读取操作系统环境变量构建时静态替换为字符串常量运行环境Node.js 服务端 / CLI 脚本 / dev server通过构建产物运行的浏览器环境浏览器原生支持不支持无 process 对象需要构建工具配合处理 import.meta能否动态修改可以进程内存中可赋值不能构建后是写死的字符串默认可见范围系统全部环境变量内置 5 个 VITE_前缀自定义变量配置来源shell 环境变量 / dotenv 加载.envVite 读取项目根目录.env系列文件典型使用场景后端配置、脚本、CI/CD前端构建配置、SPA 运行参数看完这张表你会发现两者最根本的区别就八个字运行时读取 vs 构建期替换。搞懂这一点其余差异全都能推导出来。4.2 场景一纯前端 SPA 项目如果你的项目就是一个纯前端单页应用部署到 Nginx/CDN没有 Node 层那么浏览器中的唯一正确选择是import.meta.env。Vite 会负责把所有VITE_前缀变量注入构建产物。这时候你在代码里写process.env.XXX构建产物不会做任何替换除非你手动配置了define浏览器运行到那行代码就会因为process is not defined报错。这个场景最典型也是事故高发区。很多从 Webpack 时代转过来的项目老代码里全是process.env.NODE_ENV迁移到 Vite 后不改掉早晚要出事。我见过最夸张的情况是一个团队把所有process.env引用都留着线上环境一跑全挂排查了一整天才发现是历史包袱。4.3 场景二Node.js 服务端项目如果你在写一个纯 Node.js API 服务比如 Express/Koa/NestJS没有经过 Vite 构建那么正确选择是process.env。此时import.meta虽然存在但import.meta.env并不是 Node.js 官方定义的标准属性直接访问会得到undefined。除非你专门引入 Vite 做 SSR 构建把import.meta.env注入到 Node 运行环境的代码中否则在普通 Node 服务里压根没有这个概念。在服务端项目里process.env的正确用法通常配合dotenv加载.env文件再通过process.env.PORT、process.env.DB_HOST这类方式读取。这里的配置天然是运行时动态的改完配置重启服务即可生效不需要重新打包。4.4 场景三同构应用 / SSR 项目最复杂的是 SSR 场景。Nuxt、Next 这类框架在开发期同时运行 Node 服务和浏览器脚本两套环境并存。在这种代码库里你需要严格区分当前代码段的执行环境运行在浏览器的客户端代码用import.meta.env运行在 Node 的服务端代码用process.env。不少全栈项目里这两个 API 会出现在同一个文件的不同函数中这正是最容易迷糊、也最需要对本质有清晰认知的地方。判断方法其实很简单看这份代码跑在哪一层。客户端组件初始化、路由守卫里做环境判断用import.meta.env服务器 API 路由、数据库连接、中间件里做配置判断用process.env。写代码前先问自己一句这段代码最终会跑在哪个运行时里答案自然就有了。5. 混用踩坑实录两种典型报错的反向排查5.1 坑一浏览器端 Cannot read properties of undefinedreading BASE_URL错误信息TypeError: Cannot read properties of undefined (reading BASE_URL) at setupApp (src/main.ts:18:13)排查顺序很重要。第一步先看报错的代码在哪确认是浏览器运行的客户端代码还是经由 Vite 编译的 SSR 代码。第二步看它访问的是哪个 API——如果写的是process.env.BASE_URL在浏览器端process本身就是undefined所以报错是读取 undefined 的属性。第三步决定修复方向改成import.meta.env.BASE_URL或者通过define手动映射。很多团队会直接跑到服务器上检查环境变量有没有配全这其实是方向性错误。你可以在服务器上设置一百个环境变量浏览器里的process.env依然不存在。这个坑的根因是代码运行环境和变量来源完全不匹配跟服务器配置无关。5.2 坑二Node 端 import.meta.env.MODE 始终为 undefined反过来的报错也可能出现在 Node 端比如你在写 Vite 插件或者 SSR 服务时使用了import.meta.env.MODE。Node.js 原生支持import.meta对象ESM 模块规范但import.meta.env不是 Node 标准化属性所以这里不会报 TypeError而是安静地返回undefined——这种不报错但值不对的行为往往更危险。正确做法是如果你在 Node 环境如vite.config.ts、自定义插件里需要读取模式用loadEnv(mode, process.cwd(), )或defineConfig参数里的mode不要直接访问import.meta.env。如果你说我在 Node 里就是想拿到 Vite 注入的那份 env那前提是你的代码必须经过 Vite 的 SSR 构建管线由 Vite 在编译期完成替换普通node file.js直接跑是拿不到的。5.3 坑三动态键访问 import.meta.env[key] 得不到值还有一个隐蔽的坑。有些人为了避免硬编码会写下类似代码function getEnv(key: string) { return import.meta.env[key] } getEnv(MODE)这段代码在 dev 模式下可能碰巧能用因为 Vite dev server 确实会把import.meta.env对象暴露给模块运行时动态访问能拿到值。但构建后就不一定了——静态替换是针对字面量属性访问做的动态键访问没法替换Vite 在产物里保留的是原始读取逻辑如果运行时没有注入import.meta.env对象结果就是undefined。类似问题也存在于某些低级模板引擎的字符串拼接写法里。我的建议始终是使用静态字面量访问import.meta.env.XXX不要用变量做键。如果需要动态环境变量能力就把固定变量的读取统一封装成一个getEnvByKey工具函数内部用 map 映射而不是动态访问import.meta.env。5.4 复盘两个报错的共同规律上面三个坑看起来五花八门本质规律只有一条先分清代码执行环境再匹配正确的环境变量 API。前端浏览器代码优先import.meta.envNode 服务端代码优先process.env。一旦环境搞错了报错只是时间问题早晚会在线上以各种形态出现。排查这类问题还有一个偷懒技巧直接在构建产物里搜关键变量名。如果你在dist/assets/*.js里搜import.meta.env应该几乎搜不到——因为正常替换后都变成字符串了。如果搜出来一大堆import.meta.env残留说明替换没生效八成是动态键访问或某个插件绕过了 Vite 的静态分析这就是定位方向的强信号。6. 生产环境正确食用Vite 项目环境变量配置全清单6.1 .env 文件家族与优先级Vite 项目里环境变量配置主要落在根目录的.env系列文件上.env所有环境通用的基础配置.env.local本地环境覆盖通常不进版本库.env.development仅开发模式生效.env.production仅生产构建生效.env.[mode].local特定模式的本地区域覆盖加载优先级从低到高依次是.env.env.local.env.[mode].env.[mode].local后者会覆盖前者的同名变量。注意.env和.env.[mode]建议提交到 git.env.local和.env.[mode].local应该写进.gitignore。6.2 自定义变量的三条规则在 Vite 里自定义环境变量有非常明确的规则必须放在.env系列文件中且以VITE_开头才会暴露给客户端代码。非VITE_前缀变量会被 Vite 过滤掉不会出现在import.meta.env中。读取必须使用静态访问import.meta.env.VITE_XXX并且 TS 类型需要额外声明才不会有类型报错。一个完整的.env.production示例VITE_APP_TITLE中台管理系统 VITE_API_BASE/api VITE_APP_VERSIONv1.0.0然后在src里通过import.meta.env.VITE_APP_TITLE读取。如果你配套使用 TypeScript建议在项目根目录src/vite-env.d.ts中声明/// reference typesvite/client / interface ImportMetaEnv { readonly VITE_APP_TITLE: string readonly VITE_API_BASE: string readonly VITE_APP_VERSION: string } interface ImportMeta { readonly env: ImportMetaEnv }这样 IDE 自动补全和类型检查就都有了避免把环境变量名拼错导致线上取不到值。6.3 vite.config.ts 中读取非 VITE_ 前缀变量如果你确实需要在构建配置里使用不公开的变量比如某些 CDN 上传密钥可以在vite.config.ts中使用loadEnvimport { defineConfig, loadEnv } from vite import vue from vitejs/plugin-vue export default defineConfig(({ mode }) { const env loadEnv(mode, process.cwd(), ) return { plugins: [vue()], define: { __CDN_PUSH_TOKEN__: JSON.stringify(env.CDN_PUSH_TOKEN) } } })注意loadEnv的第三个参数传表示不带前缀过滤这样就能把.env文件里的所有变量都读出来。这个函数本身在定义上就是为了服务端构建上下文准备的跟浏览器端import.meta.env又隔了一层正好印证了前面的分层逻辑。6.4 什么时候才需要用 define 映射 process.env有些老项目从 Webpack 迁移过来代码里大量写了process.env.NODE_ENV。Vite 为了兼容这类代码也允许在define里手动映射define: { process.env.NODE_ENV: JSON.stringify(process.env.NODE_ENV ?? production) }这样构建产物里的process.env.NODE_ENV同样会被替换成字符串。但要提醒一句这只是过渡方案不代表process.env在浏览器里真正存在了。新代码请一律直接使用import.meta.env.MODE / PROD / DEV能不用 define 就别用。define 映射越多构建配置越难维护也越容易踩以为能用其实只是被替换的认知坑。6.5 几个我踩过之后的实操建议最后给几条贴近生产的建议。第一条环境变量永远区分公开和机密两类。凡是准备暴露给前端浏览器的统一用VITE_前缀凡是密钥、token 这些机密信息绝不能放.env并被客户端引用必须留在服务端或 CI 平台的私有变量里。构建产物是公开的任何人拉下 JS 文件都能看到所有字符串常量。第二条团队内约定统一 API前端代码全部import.meta.env后端脚本全部process.env代码评审时重点盯跨环境使用。光靠个人自觉没用得把这条写进团队的 code review 检查清单。第三条CI 流水线里维护一份环境变量清单把 dev / staging / prod 三套变量的差异写清楚避免某个人在某个环境忘了配变量导致线上事故。说实话这些规则本身都不复杂但真实项目里看着能用就行的人太多。我自己早年在项目里也吃过类似的亏当年第一反应是翻环境配置、重启服务折腾半天发现报错根源居然只是 API 用错了。现在带团队做 code review我把区分import.meta.env和process.env列为前端入组必考的一关。希望你能比我早一步想清楚这条边界。