外观
避坑实录:多环境配置治理与密钥防泄漏
问题背景:多环境不是多复制几个配置文件
前端项目一旦进入联调、测试、预发、生产并行阶段,最常见的问题并不是“不会配置环境变量”,而是配置逐渐失控:开发同学把测试域名写死在组件里,临时为了本地调试把密钥放进源码,CI 中又依赖一份没人能完整说清的变量表。最终,一次看似普通的提交,就可能把内部接口地址、第三方 AppKey,甚至具备服务权限的凭证带进 Git 历史。
很多团队使用 Vite 后,会直接通过 import.meta.env.VITE_API_BASE_URL 读取变量,也会在 vite.config.ts 中使用 define 替换常量。但如果缺少分层规范、提交前校验和构建时检查,多环境仍然会退化为“靠人记忆”。
本文给出一套可运行的治理方案:用 .env 分层管理公开环境变量,用 Vite define 固化构建期常量,用本地私有文件覆盖敏感调试配置,再通过脚本阻止密钥进入仓库。
方案对比:.env、运行时配置与 Vite define 怎么选
常见方案有三类。
第一类是 Vite 原生 .env 文件。它适合保存需要被前端代码读取的非敏感公开配置,例如 API 前缀、站点标题、功能开关。Vite 只会把 VITE_ 前缀的变量暴露给客户端代码,这是第一层约束。
第二类是运行时配置,例如把 config.js 放到 public 目录,部署时由 Nginx 或容器注入。它适合同一个构建产物部署到多个环境的场景,但要处理缓存、加载时序和配置完整性校验。
第三类是 define。它本质上是构建期文本替换,适合定义版本号、构建时间、全局特性开关等常量。注意,它不是秘密保险箱:被替换进浏览器包的内容仍然可以被用户看到。
结论很明确:公开的、随环境变化的配置放 .env;需要部署后可变的配置使用运行时加载;构建期确定的版本和开关使用 define;真正的服务端密钥永远不要进入前端项目。
实操步骤一:建立 .env 分层与忽略规则
推荐在项目根目录建立以下文件:
text
.env # 所有环境共享的默认值
.env.development # npm run dev 时加载
.env.test # 测试构建
.env.production # 生产构建
.env.local # 个人本地覆盖,永远不提交
.env.development.local
.env.production.local先写基础配置。
bash
# .env
VITE_APP_TITLE=开发者日记
VITE_API_BASE_URL=/api
VITE_ENABLE_MOCK=falsebash
# .env.development
VITE_API_BASE_URL=https://dev-api.example.com
VITE_ENABLE_MOCK=truebash
# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_MOCK=false个人调试时不要修改公共文件,而是创建 .env.local:
bash
# .env.local,仅保存在本机
VITE_API_BASE_URL=http://127.0.0.1:8787然后在 .gitignore 中明确忽略:
gitignore
# 本地环境与密钥文件
.env
.env.*
!.env.example
!.env.development
!.env.test
!.env.production这段规则的重点是“先全部忽略,再放行模板和允许提交的环境文件”。团队中经常有人只写 .env.local,但不同操作系统、脚手架或调试脚本可能生成 .env.staging.local、.env.private 等文件,统一忽略更安全。
同时提交一份 .env.example,只放键名和占位值,不放真实密钥:
bash
# .env.example
VITE_APP_TITLE=Your App
VITE_API_BASE_URL=https://example.com/api
VITE_ENABLE_MOCK=false实操步骤二:在 Vite 中使用 loadEnv 与 define
下面的配置可以直接用于 Vue3 + Vite 项目。它做了三件事:按模式加载环境变量;把非敏感构建信息通过 define 注入;在生产构建时拦截疑似密钥。
typescript
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
// 第三个参数为空字符串,表示读取全部环境变量;
// 业务代码仍只应使用 VITE_ 前缀变量。
const env = loadEnv(mode, process.cwd(), '')
const requiredKeys = ['VITE_APP_TITLE', 'VITE_API_BASE_URL'] as const
const missingKeys = requiredKeys.filter((key) => !env[key])
if (missingKeys.length > 0) {
throw new Error(`缺少必需环境变量:${missingKeys.join('、')}`)
}
const forbiddenPatterns = [
/BEGIN (RSA |EC )?PRIVATE KEY/,\n /sk-[A-Za-z0-9]{20,}/,
/AKIA[0-9A-Z]{16}/,
]
for (const [key, value] of Object.entries(env)) {
if (forbiddenPatterns.some((pattern) => pattern.test(value))) {
throw new Error(`环境变量 ${key} 疑似包含真实密钥,请立即移出前端配置`)
}
}
return {
plugins: [vue()],
define: {
// JSON.stringify 很关键:define 是代码替换,不是赋值
__APP_VERSION__: JSON.stringify(process.env.npm_package_version ?? '0.0.0'),
__BUILD_MODE__: JSON.stringify(mode),
__BUILD_TIMESTAMP__: JSON.stringify(new Date().toISOString()),
__ENABLE_MOCK__: JSON.stringify(env.VITE_ENABLE_MOCK === 'true'),
},
server: {
proxy: {
'/api': {
target: env.VITE_API_BASE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
}
})为了让 TypeScript 认识自定义全局常量,可以新增 src/types/env.d.ts:
typescript
declare const __APP_VERSION__: string
declare const __BUILD_MODE__: string
declare const __BUILD_TIMESTAMP__: string
declare const __ENABLE_MOCK__: boolean
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
readonly VITE_ENABLE_MOCK?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}在 Vue3 组件中可以这样使用:
vue
<script setup lang="ts">
// 公开环境变量:来自 .env.* 文件
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
const appTitle = import.meta.env.VITE_APP_TITLE
// 构建期常量:来自 Vite define
const version = __APP_VERSION__
const buildMode = __BUILD_MODE__
const enableMock = __ENABLE_MOCK__
</script>
<template>
<section>
<h1>{{ appTitle }}</h1>
<p>接口地址:{{ apiBaseUrl }}</p>
<p>版本:{{ version }},环境:{{ buildMode }},Mock:{{ enableMock ? '开启' : '关闭' }}</p>
</section>
</template>在 package.json 中补充多环境命令:
json
{
"scripts": {
"dev": "vite --mode development",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"build:prod": "vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview"
}
}实操步骤三:增加提交前密钥扫描
仅靠 .gitignore 还不够,因为密钥可能被写进 src 目录下的测试文件、文档示例或历史提交。可以增加一个轻量扫描脚本,在 CI 和 Husky 中执行。
javascript
// scripts/check-secrets.mjs
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
const ROOT = process.cwd()
const INCLUDE_DIRS = ['src', 'scripts', '.']
const ALLOWED_ENV_FILES = ['.env.example', '.env.development', '.env.test', '.env.production']
const patterns = [
{ name: '私钥片段', rule: /BEGIN (RSA |EC )?PRIVATE KEY/ },
{ name: 'OpenAI 风格密钥', rule: /sk-[A-Za-z0-9]{20,}/ },
{ name: '云厂商 AccessKey', rule: /AKIA[0-9A-Z]{16}/ },
{ name: 'Bearer 硬编码', rule: /Bearer\s+[A-Za-z0-9\-_\.]{30,}/ },
]
function walk(dir) {
const results = []
for (const entry of readdirSync(dir)) {
if (['node_modules', 'dist', '.git'].includes(entry)) continue
const fullPath = join(dir, entry)
if (statSync(fullPath).isDirectory()) {
results.push(...walk(fullPath))
} else {
results.push(fullPath)
}
}
return results
}
const files = INCLUDE_DIRS.flatMap((dir) => walk(join(ROOT, dir)))
const errors = []
for (const file of files) {
if (file.includes('.env') && !ALLOWED_ENV_FILES.some((name) => file.endsWith(name))) {
errors.push(`禁止提交私有环境文件:${file}`)
continue
}
const content = readFileSync(file, 'utf8')
for (const { name, rule } of patterns) {
if (rule.test(content)) {
errors.push(`${file} 中发现疑似 ${name}`)
}
}
}
if (errors.length > 0) {
console.error('密钥扫描失败:\n' + errors.map((item) => `- ${item}`).join('\n'))
process.exit(1)
}
console.log('密钥扫描通过')再把它接入命令:
json
{
"scripts": {
n "check:secrets": "node scripts/check-secrets.mjs",
"prebuild:prod": "npm run check:secrets"
}
}如果仓库已经提交过敏感内容,只删除当前文件并不能消除泄漏,必须立刻轮换密钥,然后使用 Git 历史清理或重建仓库。前端构建产物可以被任何人下载,因此“混淆后别人找不到”不能作为安全假设。
避坑总结:这些错误在真实项目里反复出现
1. 把密钥放进 VITE_ 变量,以为只有代码能读取
错误现象:在 .env.production 中写入 VITE_THIRD_PARTY_SECRET=xxx,然后通过 import.meta.env 使用。构建后搜索产物,会发现密钥被直接打进 JavaScript chunk。
修正方法:前端只保存公开标识,例如公钥、App ID、匿名埋点 Key;真正用于签名、换取令牌、访问云资源的密钥必须放在服务端,由 BFF 或网关代理请求。
2. define 注入字符串时忘记 JSON.stringify
错误现象:写成 __APP_VERSION__: '1.2.3',构建后代码被替换成裸标识符 1.2.3 或语法异常;布尔值也可能被替换为 false 后引发压缩器解析问题。
修正方法:所有字符串使用 JSON.stringify,布尔值和数字也建议统一序列化。把 define 理解为“编译前查找并替换文本”,不要把它当成运行时对象赋值。
3. 本地调试直接改公共 .env,导致团队配置互相污染
错误现象:A 同学把代理地址改成自己的 IP,B 拉取后联调全部失败;或者为了关闭登录校验,把功能开关提交到测试环境。
修正方法:公共 .env.* 只保存团队默认值,个人差异放 .env.local。代码评审时看到公共环境文件被修改,应同时确认影响范围和回滚方式。
4. 只在运行时报错,CI 却照常产出错误包
错误现象:页面白屏后才发现 VITE_API_BASE_URL 未定义,或者构建成功但接口全部请求到 /undefined。
修正方法:在 vite.config.ts 启动阶段维护必需变量清单,缺失即抛错;CI 的生产构建必须执行类型检查、密钥扫描和环境变量校验,让问题在流水线中提前失败。
治理多环境配置的核心不是记住某个 API,而是建立边界:哪些值可以进入浏览器,哪些值只能存在服务端,哪些值由构建期决定,哪些值允许开发者本机覆盖。把这些边界固化到文件规范、Vite 配置、类型系统和 CI 检查中,才能真正杜绝密钥误提交,而不是依赖每次提交前的人工自觉。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。