测试接口为什么进了生产包:前端环境变量到底在什么时候生效

本文是「前端工程现场」系列的第 4 篇。

下面使用一个假设场景说明 Vite 项目中的环境变量问题,代码、目录和命令均为简化示例。

生产页面发布后,用户列表能够正常打开,但所有查询请求都发向了测试环境:

text 复制代码
https://staging-api.example.com/users?page=1

静态资源已经部署成功,页面没有白屏,CI 中的 pnpm build 也正常结束。排查最初落在 Nginx 代理和生产服务器环境变量上,因为生产机器已经配置了 API_BASE_URL=https://api.example.com,从部署环境看不出测试地址来自哪里。

在构建产物中搜索测试域名后,问题范围缩小了:

bash 复制代码
grep -R "staging-api.example.com" dist

dist/assets 中的 JavaScript 文件已经包含完整测试地址。浏览器发起请求时没有再读取服务器上的 API_BASE_URL,它只是执行构建阶段生成的代码。

继续检查构建脚本,假设项目中存在下面这段配置:

json 复制代码
{
  "scripts": {
    "build": "vite build",
    "build:staging": "vite build --mode staging",
    "build:prod": "vite build --mode staging"
  }
}

生产流水线调用了 pnpm build:prod,脚本名称写着 prod,实际执行参数仍然是 --mode staging。Vite 加载了 .env.staging,再把测试接口地址写进生产构建产物。部署服务器上的环境变量没有参与这次替换,因为 dist 在进入服务器之前已经生成完毕。

测试地址在构建阶段进入了 JavaScript

假设项目把接口地址写在 .env.staging 中:

dotenv 复制代码
VITE_API_BASE_URL=https://staging-api.example.com

业务代码通过 import.meta.env 读取:

ts 复制代码
export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL
})

开发阶段,Vite 会提供 import.meta.env 中的变量。执行生产构建时,这类访问会被静态替换,生成后的代码不再保留"发布时重新读取环境变量"的过程。

构建后的代码经过压缩,结构可能难以阅读,但地址已经成为 JavaScript 字符串的一部分。为了观察替换结果,可以临时关闭压缩,或者直接在 dist 中搜索已知域名:

bash 复制代码
pnpm vite build --mode staging --minify false
grep -R "staging-api.example.com" dist

这两条命令用于本地验证。正式构建是否关闭压缩,应当由项目的产物体积、调试方式和发布要求决定。

从环境文件到浏览器请求,中间经历了多个有先后依赖的步骤。下面的图例用于说明测试地址在哪个位置失去了继续变化的机会。

flowchart LR A["vite build --mode staging"] --> B["加载 .env 和 .env.staging"] B --> C["读取 VITE_API_BASE_URL"] C --> D["替换 import.meta.env"] D --> E["写入 dist/assets"] E --> F["部署到生产服务器"] F --> G["浏览器请求测试接口"] H["生产服务器 API_BASE_URL"] -. "前端产物未读取" .-> F

这条链路也解释了一个常见误判:重启 Nginx、修改容器变量或重新加载页面,都无法修改已经写入静态资源的测试地址。修复需要重新执行正确模式的构建,或者让应用在运行时从独立配置文件读取地址。

检查此类问题时,可以先做两次搜索。第一处搜索源码中的变量来源,例如 grep -R "VITE_API_BASE_URL" src vite.config.*;第二处搜索构建产物中的最终值,例如 grep -R "staging-api.example.com" dist。源码确认代码读取了哪个变量,产物确认变量最终变成了什么。

mode 决定加载哪个文件,脚本名称不会参与判断

Vite 的开发服务器默认使用 development 模式,vite build 默认使用 production 模式。命令显式传入 --mode staging 后,本次构建会加载 staging 模式对应的环境文件。

假设项目目录中有:

text 复制代码
.env
.env.local
.env.production
.env.production.local
.env.staging
.env.staging.local

执行 vite build 时,模式是 production.env.production 参与加载。执行 vite build --mode staging 时,模式变成 staging,对应文件换成 .env.staging

package.json 中的脚本名称不会影响模式。下面两个脚本执行结果相同,因为右侧命令完全一致:

json 复制代码
{
  "scripts": {
    "build:staging": "vite build --mode staging",
    "build:prod": "vite build --mode staging"
  }
}

修复后的脚本可以写成:

json 复制代码
{
  "scripts": {
    "build:staging": "vite build --mode staging",
    "build:prod": "vite build --mode production"
  }
}

生产模式本来就是 vite build 的默认值,build:prod 也可以直接执行 vite build。显式保留 --mode production,方便在脚本和流水线日志中核对本次构建使用的模式,但项目需要持续维护模式名称与环境文件的一致性。

环境文件还存在覆盖关系。模式专用文件中的同名变量会覆盖通用 .env 中的值,Vite 启动前已经存在的进程环境变量优先级更高。

假设文件中分别写着:

dotenv 复制代码
# .env
VITE_API_BASE_URL=https://default-api.example.com
dotenv 复制代码
# .env.production
VITE_API_BASE_URL=https://api.example.com

直接执行 vite build 时,生产文件中的地址覆盖通用值。如果 CI 在命令执行前已经注入 VITE_API_BASE_URL,进程中的值会继续覆盖环境文件:

bash 复制代码
VITE_API_BASE_URL=https://preview-api.example.com pnpm vite build

这种覆盖方式适合由流水线统一注入配置,但排查入口也会增加。仓库里的 .env.production 看起来正确,最终产物仍可能来自 CI Secret、任务变量或容器环境。

构建日志可以输出当前 mode 和接口域名,但不要直接打印令牌、私钥或完整敏感配置。下面是一个简化的检查方式:

ts 复制代码
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), 'VITE_')
  const apiOrigin = new URL(env.VITE_API_BASE_URL).origin

  console.log(`[build] mode=${mode}`)
  console.log(`[build] api=${apiOrigin}`)

  return {}
})

这段日志能确认流水线传入了哪个模式,以及接口域名来自哪组配置。它没有检查地址是否符合生产要求,还可以在生产模式中增加允许列表:

ts 复制代码
const productionOrigins = new Set([
  'https://api.example.com'
])

if (
  mode === 'production' &&
  !productionOrigins.has(apiOrigin)
) {
  throw new Error(
    `Unexpected production API origin: ${apiOrigin}`
  )
}

允许列表检查比判断域名里是否包含 staging 更明确。测试环境如果使用另一套命名规则,简单的字符串匹配可能漏掉错误地址。

modeNODE_ENV 也要分开检查。vite build --mode staging 仍然执行生产构建,只是 import.meta.env.MODEstaging,并加载 staging 模式文件。产物经过压缩,不能说明它一定读取了 .env.production

总结一下整段代码,作用可以概括为:

复制代码
读取当前构建模式
→ 加载对应环境变量
→ 打印实际 API 域名
→ 生产构建时校验域名
→ 配置错误则终止 CI

VITE_ 前缀决定变量是否进入客户端

Vite 默认只把带有 VITE_ 前缀的自定义变量暴露给客户端代码。这个前缀划定的是暴露范围,没有提供加密处理。

下面的变量会进入浏览器可以访问的构建内容:

dotenv 复制代码
VITE_API_BASE_URL=https://api.example.com
VITE_MAP_TOKEN=public-client-token

数据库密码、服务端私钥和具备高权限的第三方密钥不应使用 VITE_ 前缀:

dotenv 复制代码
DB_PASSWORD=server-only-password
PAYMENT_PRIVATE_KEY=server-only-key

这些变量应由后端、Serverless Function 或边缘函数读取。前端需要调用受保护能力时,请求先到服务端,由服务端完成鉴权和密钥使用。

名称不带 VITE_ 只能阻止 Vite 默认暴露该变量。项目如果通过 define、插件或手动字符串替换把服务端变量写进前端,密钥仍然会进入产物。

验证方式很直接:使用生产命令生成 dist,再搜索变量值或具有识别度的片段。浏览器 DevTools 也可以检查加载的 JavaScript 和网络请求。发现敏感值已经发布后,需要先撤销或轮换密钥,再修复构建流程;删除前端代码不会使已经泄露的凭据失效。

同一份 dist 部署多套环境时,需要运行时配置

构建时变量适合"每个环境单独构建"的发布方式。测试环境执行 staging 构建,生产环境执行 production 构建,两次构建得到不同的 dist

有些发布流程要求同一份产物依次部署到测试、预发布和生产。这个条件下,import.meta.env.VITE_API_BASE_URL 无法在部署阶段变化,因为地址已经写入 JavaScript。

一种处理方式是把可公开配置放进独立的运行时文件。下面是简化示例,部署脚本需要在每个环境中写入不同内容:

js 复制代码
// runtime-config.js
window.__APP_CONFIG__ = {
  apiBaseURL: 'https://api.example.com'
}

index.html 在应用入口之前加载它:

html 复制代码
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.ts"></script>

应用启动时读取并校验:

ts 复制代码
type AppConfig = {
  apiBaseURL: string
}

declare global {
  interface Window {
    __APP_CONFIG__?: AppConfig
  }
}

const config = window.__APP_CONFIG__

if (!config?.apiBaseURL) {
  throw new Error('Missing runtime apiBaseURL')
}

export const runtimeConfig = config

这套方案允许同一份业务 JavaScript 搭配不同的 runtime-config.js。部署到测试环境时写入测试地址,部署到生产环境时写入生产地址,业务 bundle 不需要重新生成。

新增的配置文件也带来新的失败位置。runtime-config.js 加载失败、缓存没有更新、字段缺失或脚本顺序错误,都会让应用启动中断。部署时需要保证配置文件先于入口脚本加载,并为该文件设置合适的缓存策略。长期缓存的业务资源可以使用内容哈希,运行时配置通常需要较短缓存时间或明确版本号。

运行时配置依旧属于客户端公开信息。接口地址、公开功能开关和公开版本号可以放进去,私钥和数据库凭据仍然只能留在服务端。

构建时配置和运行时配置会改变产物数量与部署步骤。下面这张图用于对比两种方式各自在什么时候确定接口地址。

本篇的排查范围停在纯静态前端产物。SSR、Node.js 服务端渲染和后端模板注入拥有服务端运行阶段,变量读取时机与静态部署不同,需要结合具体框架的服务端入口继续检查。

下一篇会处理构建产物已经正确生成、页面上线后却出现资源 404 的问题,重点检查 Vite 的 base、路由部署路径和服务器回退配置。

相关推荐
午安~婉1 小时前
Git中SSH连接
前端·git·gitee
盏灯1 小时前
mac 外接磁盘,热更新失效
前端·后端
那些年丶ny2 小时前
颜色扩展库
前端·javascript·数据可视化
ihuyigui2 小时前
海外签收通知短信接口
android·java·开发语言·前端·数据库·后端
YWL2 小时前
OpenLayers + Vue 2 使用指南(01)
前端·javascript·vue.js
暖和_白开水2 小时前
数据分析agent (七):contextvars 模块上下文request_id
java·前端·数据分析
程序员黑豆2 小时前
鸿蒙应用开发:Flex 组件从入门到实战
前端·华为·harmonyos
大爱编程♡2 小时前
Vue3+TypeScript+element-plus的文章管理项目(配后端接口)-退出及文章管理页面
前端·javascript·typescript
先吃饱再说2 小时前
React 组件设计:从 Props 传递到组件封装
前端·react.js