跳到主内容

避坑实录:多环境配置治理与密钥防泄漏 ​

问题背景:多环境不是多复制几个配置文件 ​

前端项目一旦进入联调、测试、预发、生产并行阶段,最常见的问题并不是“不会配置环境变量”,而是配置逐渐失控:开发同学把测试域名写死在组件里,临时为了本地调试把密钥放进源码,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 基础分层默认/开发/测试/生产本地私有覆盖.env.local 不提交Vite 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=false
bash
# .env.development
VITE_API_BASE_URL=https://dev-api.example.com
VITE_ENABLE_MOCK=true
bash
# .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 检查中,才能真正杜绝密钥误提交,而不是依赖每次提交前的人工自觉。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。