Vue 基础体系 · 第 61/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 环境与运行时配置:构建变量、注入、校验和 Secret 边界

在 Vue 应用中,“配置”至少有两个不同的时间点:

  1. 构建时:Vite 读取环境文件,把变量替换或注入到构建产物中。
  2. 运行时:浏览器已经加载了静态 JavaScript,应用再从 HTML、全局变量或 HTTP 接口读取配置。

这两个时间点决定了配置能否在不重新构建的情况下改变,也决定了某个值是否会进入浏览器、是否可能被用户看到,以及它是否还能被称为 Secret。

本文以 Vue 3、Composition API、TypeScript 和现代 Vite 工具链为例,逐步说明:

  • Vite 环境变量和 mode 的关系;
  • import.meta.env 的实际机制;
  • 构建时替换与运行时注入的差异;
  • 如何在应用启动前校验配置;
  • 如何处理缺失、非法、缓存和并发部署;
  • 为什么任何进入浏览器的值都不能再视为真正的 Secret;
  • 哪些配置应放在前端,哪些必须留在服务端。

一、先区分四个概念:环境、配置、构建变量和运行时配置

1. 环境不是配置文件

“开发环境”“测试环境”“生产环境”通常描述的是一组部署条件,例如:

  • 后端 API 地址;
  • OAuth 客户端标识;
  • 是否启用调试功能;
  • 静态资源基础路径;
  • 当前构建所使用的 mode。

.env.production.env.staging 等文件只是这些条件的一种表达方式。它们不是运行时数据库,也不会自动让浏览器拥有动态读取配置的能力。

2. 构建变量是构建过程的输入

构建变量在执行 vite build 时被读取。例如:

VITE_API_BASE_URL=https://api.example.com npm run build

该值可以影响:

  • JavaScript 中的条件分支;
  • 资源路径;
  • API 地址;
  • Vite 插件行为;
  • 是否启用某些代码。

关键点是:构建完成后,变量通常已经被写入静态产物。之后修改服务器上的环境变量,并不会自动改变已经生成的 JavaScript。

3. 运行时配置是浏览器启动后读取的数据

运行时配置在构建完成后仍然可以变化。例如:

构建一次 dist/
部署到测试环境 -> 读取测试 API 地址
部署到生产环境 -> 读取生产 API 地址

这要求应用在启动时通过某种协议读取配置,例如:

  • 读取 /config.json
  • 执行一个注入了 window.__APP_CONFIG__ 的脚本;
  • 从 HTML 的 meta 标签读取;
  • 通过服务端渲染注入。

运行时配置不是 Vite 自动提供的功能,而是应用和部署系统共同约定的一条数据通道。

4. Secret 是安全边界内不可被客户端取得的值

如果一个值最终到达浏览器,那么用户可以通过以下方式取得它:

  • 查看 Network 面板;
  • 读取打包后的 JavaScript;
  • 在控制台访问全局变量;
  • 断点调试;
  • 查看浏览器缓存、Source Map 或请求头。

因此,下面这些值即使名字包含 SECRET,也不能算 Secret:

VITE_DATABASE_PASSWORD=...
VITE_PRIVATE_KEY=...
VITE_SECRET_TOKEN=...

VITE_ 前缀只影响 Vite 是否把变量暴露给客户端,不提供任何加密或权限保护。


二、Vite 的 mode、.env 文件和变量优先级

2.1 mode 决定加载哪一组环境文件

Vite 内置了两个常见 mode:

npm run dev
# 默认 mode 通常是 development

npm run build
# 默认 mode 通常是 production

也可以显式指定:

vite build --mode staging

此时 Vite 会按 staging 读取对应的环境文件。

常见文件名如下:

.env
.env.local
.env.development
.env.development.local
.env.production
.env.production.local
.env.staging
.env.staging.local

可以把它们理解为逐层覆盖:

通用配置
  -> mode 专属配置
  -> 本地覆盖配置
  -> 进程启动时已经存在的环境变量

例如:

# .env
VITE_API_BASE_URL=https://api.example.com
VITE_LOG_LEVEL=info
# .env.staging
VITE_API_BASE_URL=https://staging-api.example.com
# .env.local
VITE_LOG_LEVEL=debug

staging mode 下,最终得到的逻辑结果是:

VITE_API_BASE_URL=https://staging-api.example.com
VITE_LOG_LEVEL=debug

.local 文件通常用于本机覆盖,不应提交包含敏感内容的文件。.env.example 可以提交,但它只应包含变量名和示例值:

VITE_API_BASE_URL=https://example.invalid
VITE_RUNTIME_CONFIG_URL=/config.json

2.2 mode 与 NODE_ENV 不是同一个概念

这是一个常见误区。

  • mode 表示 Vite 当前使用哪一套环境文件和配置模式;
  • NODE_ENV 是 Node 生态中常见的环境变量,通常表示 developmentproduction 等运行状态。

例如:

vite build --mode staging

这表示使用 staging 环境文件,但不应简单推断所有与 NODE_ENV 相关的行为都会变成某个固定值。

在前端业务代码中,判断 Vite 构建环境通常使用:

if (import.meta.env.DEV) {
  // 开发服务器运行时
}

if (import.meta.env.PROD) {
  // 生产构建产物
}

如果业务需要区分测试、预发布和生产,应使用:

const mode = import.meta.env.MODE

if (mode === 'staging') {
  // 预发布
}

2.3 变量值始终是字符串

环境文件中的值由 dotenv 类机制读取,业务代码看到的通常是字符串:

VITE_FEATURE_ENABLED=false
VITE_REQUEST_TIMEOUT=5000

下面的判断是错误的:

if (import.meta.env.VITE_FEATURE_ENABLED) {
  // "false" 是非空字符串,因此这里仍然会执行
}

应显式转换:

const featureEnabled = import.meta.env.VITE_FEATURE_ENABLED === 'true'
const requestTimeout = Number(import.meta.env.VITE_REQUEST_TIMEOUT)

if (!Number.isFinite(requestTimeout)) {
  throw new Error('VITE_REQUEST_TIMEOUT 必须是数字')
}

数字、布尔值、数组和对象都不能依赖隐式类型转换。配置边界应该负责把字符串转换为应用内部的强类型。


三、import.meta.env 到底做了什么

3.1 它不是浏览器原生的动态环境变量

在 Vite 源码中可以写:

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL

但生产浏览器并没有一个天然存在的 import.meta.env 环境变量对象。Vite 会在构建阶段处理它,常见效果类似于:

const apiBaseUrl = 'https://api.example.com'

因此,下面的代码不是“运行时查找任意变量”:

const key = 'VITE_API_BASE_URL'
const value = import.meta.env[key]

环境变量访问应尽量使用静态属性:

const value = import.meta.env.VITE_API_BASE_URL

静态访问便于 Vite 替换和静态分析。动态访问可能无法得到预期的变量值,也会削弱死代码消除和类型检查。

3.2 内置变量和自定义变量

Vite 提供了一些内置变量:

import.meta.env.MODE
import.meta.env.BASE_URL
import.meta.env.DEV
import.meta.env.PROD
import.meta.env.SSR

例如:

console.log({
  mode: import.meta.env.MODE,
  baseUrl: import.meta.env.BASE_URL,
  isDev: import.meta.env.DEV,
  isProd: import.meta.env.PROD,
})

自定义变量默认必须使用 VITE_ 前缀:

VITE_API_BASE_URL=https://api.example.com
const url = import.meta.env.VITE_API_BASE_URL

下面的变量默认不会进入客户端代码:

DATABASE_PASSWORD=correct-horse-battery-staple
INTERNAL_SIGNING_KEY=...
console.log(import.meta.env.DATABASE_PASSWORD)
// 通常不是可用的客户端变量

这只是“默认不暴露”的构建约定,不是服务端 Secret 管理系统。

3.3 envPrefix 可以改变暴露规则,但必须谨慎

Vite 配置可以调整变量前缀:

import { defineConfig } from 'vite'

export default defineConfig({
  envPrefix: ['VITE_', 'PUBLIC_'],
})

之后 PUBLIC_ 变量也可能被暴露给客户端:

PUBLIC_APP_NAME=console

不要这样配置:

export default defineConfig({
  envPrefix: '',
})

这会让大量本来只供 Node/Vite 配置使用的环境变量具备进入客户端的可能性,极易误暴露凭据。即使某个变量暂时没有被业务代码引用,也不应依赖“当前没有用到”作为安全策略。

3.4 define 也是构建时替换,不是运行时注入

Vite 支持通过 define 注入编译常量:

import { defineConfig } from 'vite'

export default defineConfig({
  define: {
    __BUILD_COMMIT__: JSON.stringify(process.env.GIT_COMMIT ?? 'unknown'),
  },
})

业务代码:

console.log(__BUILD_COMMIT__)

这适合构建元数据、编译开关等场景,但它仍然会把值写入客户端产物。它不能保存 Secret,也不能在部署后动态改变。

TypeScript 需要声明这个全局变量:

// src/env.d.ts
declare const __BUILD_COMMIT__: string

define 的边界可以形式化为:

构建输入 -> Vite 替换源码 -> 静态产物

而运行时配置的路径是:

部署文件或 HTTP 响应 -> 浏览器启动 -> 应用读取

两者的时间点不同,不能用其中一个代替另一个。


四、一个可运行的构建时配置示例

目录结构:

.env
.env.production
src/
  env.d.ts
  build-config.ts
  main.ts

环境文件:

# .env
VITE_API_BASE_URL=http://localhost:8080
VITE_REQUEST_TIMEOUT=10000
# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_REQUEST_TIMEOUT=15000

类型声明:

// src/env.d.ts
interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_REQUEST_TIMEOUT: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

配置解析:

// src/build-config.ts
function requireString(name: string, value: string | undefined): string {
  if (!value || value.trim() === '') {
    throw new Error(`缺少构建变量 ${name}`)
  }

  return value.trim()
}

function requirePositiveInteger(name: string, value: string | undefined): number {
  const parsed = Number(value)

  if (!Number.isInteger(parsed) || parsed <= 0) {
    throw new Error(`${name} 必须是正整数,实际值为 ${String(value)}`)
  }

  return parsed
}

export const buildConfig = Object.freeze({
  apiBaseUrl: requireString(
    'VITE_API_BASE_URL',
    import.meta.env.VITE_API_BASE_URL,
  ).replace(/\/+$/, ''),

  requestTimeout: requirePositiveInteger(
    'VITE_REQUEST_TIMEOUT',
    import.meta.env.VITE_REQUEST_TIMEOUT,
  ),
})

使用:

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { buildConfig } from './build-config'

const app = createApp(App)

app.provide('buildConfig', buildConfig)
app.mount('#app')

执行:

npm run dev

开发服务器启动时会读取 .env 和相关开发配置。

执行:

npm run build

默认会使用 production mode,VITE_API_BASE_URL 将来自 .env.production,最终值会被编译进产物。

如果缺少变量,可能在开发模块加载时或构建过程中抛出错误:

Error: 缺少构建变量 VITE_API_BASE_URL

这种“尽早失败”优于让应用启动后才出现:

GET undefined/users

但它仍然有一个结构性限制:修改生产服务器上的 VITE_API_BASE_URL 不会修改已经生成的 dist/assets/*.js


五、为什么需要运行时配置

假设只有三套部署环境:

test      -> https://test-api.example.com
staging   -> https://staging-api.example.com
production-> https://api.example.com

如果每套环境都重新执行一次构建,流程是:

测试配置 -> 构建 -> 测试部署
预发布配置 -> 构建 -> 预发布部署
生产配置 -> 构建 -> 生产部署

这会带来两个问题:

  1. 同一个源代码提交产生了多份不同构建产物;
  2. 部署系统必须拥有完整的前端构建能力,而不仅是静态文件发布能力。

如果要求“一个构建产物,多环境部署”,则流程应变成:

源代码 -> 构建一次 -> dist/
                 |
                 +-> 测试环境读取测试配置
                 +-> 生产环境读取生产配置

这时需要把配置从构建产物中移出,变为运行时输入。


六、运行时注入的三种主要方式

6.1 注入 HTML 全局变量

部署系统生成一个脚本:

<script>
  window.__APP_CONFIG__ = {
    apiBaseUrl: "https://api.example.com"
  };
</script>

应用启动时读取:

const config = window.__APP_CONFIG__

优点:

  • 首次加载时即可取得;
  • 不需要额外请求配置接口;
  • 适合配置很少的应用。

缺点:

  • 必须定义全局变量类型;
  • 注入内容必须正确转义;
  • CSP、缓存和 HTML 压缩流程更复杂;
  • 配置修改通常会导致 HTML 缓存失效问题。

如果值来自服务端,不能直接拼接未经转义的字符串,否则可能形成 HTML 或 JavaScript 注入。对于复杂配置,生成独立的 JSON 文件通常更容易验证。

6.2 注入独立的 config.js

例如部署系统生成:

window.__APP_CONFIG__ = {
  apiBaseUrl: "https://api.example.com"
}

页面加载:

<script src="/config.js"></script>
<script type="module" src="/assets/index.js"></script>

它和 HTML 全局变量的核心机制相同,只是把配置放在独立文件中。独立文件便于部署系统覆盖,但需要特别处理:

  • config.js 是否被 CDN 长时间缓存;
  • 配置文件是否与 HTML 原子更新;
  • 主应用是否可能先于配置脚本执行;
  • CSP 是否允许该脚本执行。

6.3 通过 HTTP 请求读取 config.json

例如:

/config.json

内容:

{
  "apiBaseUrl": "https://api.example.com",
  "requestTimeout": 15000
}

应用启动时执行:

const response = await fetch('/config.json', {
  cache: 'no-store',
})

优点:

  • JSON 格式简单;
  • 可以使用统一的解析和校验逻辑;
  • 配置文件可由容器启动脚本或反向代理生成;
  • 不需要执行注入的 JavaScript。

缺点:

  • 应用启动多了一次请求;
  • 配置接口失败时应用无法正常启动;
  • 必须处理缓存和版本一致性。

对于需要动态替换配置的静态 SPA,config.json 是一种清晰的实现方式。它不是 Vite 官方自动约定的文件名,而是项目自己的运行时协议。


七、用 /config.json 实现完整的运行时配置加载

下面给出一个可以直接放入 Vue 3 项目的实现。

7.1 配置类型和校验函数

// src/runtime-config.ts
export interface RuntimeConfig {
  apiBaseUrl: string
  requestTimeout: number
  enableNewDashboard: boolean
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null
}

function requireString(
  object: Record<string, unknown>,
  name: string,
): string {
  const value = object[name]

  if (typeof value !== 'string' || value.trim() === '') {
    throw new Error(`运行时配置 ${name} 必须是非空字符串`)
  }

  return value.trim()
}

function requirePositiveInteger(
  object: Record<string, unknown>,
  name: string,
): number {
  const value = object[name]

  if (
    typeof value !== 'number' ||
    !Number.isInteger(value) ||
    value <= 0
  ) {
    throw new Error(`运行时配置 ${name} 必须是正整数`)
  }

  return value
}

function requireBoolean(
  object: Record<string, unknown>,
  name: string,
): boolean {
  const value = object[name]

  if (typeof value !== 'boolean') {
    throw new Error(`运行时配置 ${name} 必须是布尔值`)
  }

  return value
}

export function parseRuntimeConfig(value: unknown): RuntimeConfig {
  if (!isRecord(value)) {
    throw new Error('运行时配置必须是 JSON 对象')
  }

  const apiBaseUrl = requireString(value, 'apiBaseUrl')

  let parsedUrl: URL

  try {
    parsedUrl = new URL(apiBaseUrl)
  } catch {
    throw new Error('运行时配置 apiBaseUrl 不是合法 URL')
  }

  if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
    throw new Error('运行时配置 apiBaseUrl 只允许使用 HTTP 或 HTTPS')
  }

  return Object.freeze({
    apiBaseUrl: apiBaseUrl.replace(/\/+$/, ''),
    requestTimeout: requirePositiveInteger(value, 'requestTimeout'),
    enableNewDashboard: requireBoolean(value, 'enableNewDashboard'),
  })
}

export async function loadRuntimeConfig(
  url = '/config.json',
): Promise<RuntimeConfig> {
  let response: Response

  try {
    response = await fetch(url, {
      cache: 'no-store',
      headers: {
        Accept: 'application/json',
      },
    })
  } catch (error) {
    throw new Error(
      `无法请求运行时配置 ${url}: ${
        error instanceof Error ? error.message : String(error)
      }`,
    )
  }

  if (!response.ok) {
    throw new Error(
      `运行时配置请求失败:HTTP ${response.status} ${response.statusText}`,
    )
  }

  let body: unknown

  try {
    body = await response.json()
  } catch {
    throw new Error('运行时配置不是合法 JSON')
  }

  return parseRuntimeConfig(body)
}

这里有几个重要的边界:

  • response.ok 为假时直接失败,不把错误页面当配置解析;
  • JSON 解析失败时直接失败;
  • URL、整数、布尔值分别按实际类型校验;
  • 不使用 Boolean("false") 这种错误转换;
  • 校验完成后冻结对象,避免启动后被随意修改。

7.2 在 Vue 挂载前加载配置

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import {
  loadRuntimeConfig,
  type RuntimeConfig,
} from './runtime-config'

const RuntimeConfigKey = Symbol('RuntimeConfig')

async function bootstrap() {
  let runtimeConfig: RuntimeConfig

  try {
    runtimeConfig = await loadRuntimeConfig('/config.json')
  } catch (error) {
    console.error(error)

    document.body.innerHTML = `
      <main style="font-family: sans-serif; padding: 2rem">
        <h1>应用配置错误</h1>
        <p>应用无法读取有效的运行时配置,请联系管理员。</p>
      </main>
    `

    return
  }

  const app = createApp(App)

  app.provide(RuntimeConfigKey, runtimeConfig)
  app.mount('#app')
}

void bootstrap()

启动时序是:

sequenceDiagram
    participant B as 浏览器
    participant H as index.html
    participant C as config.json
    participant V as Vue 应用

    B->>H: 加载 HTML
    H->>B: 加载主 JavaScript
    B->>C: GET /config.json
    C-->>B: JSON 配置
    B->>B: 解析与校验
    alt 配置合法
        B->>V: createApp()
        V->>V: provide(RuntimeConfig)
        V->>V: mount()
    else 请求失败或配置非法
        B->>B: 显示启动错误页
        Note over V: 不挂载应用
    end

应用没有在配置完成前调用 mount()。这意味着所有依赖配置的组件都能假设配置已经通过校验,而不需要每个组件都处理“配置是否加载完成”的中间状态。

7.3 在 Composition API 中读取配置

// src/use-runtime-config.ts
import { inject, type InjectionKey } from 'vue'
import type { RuntimeConfig } from './runtime-config'

export const RuntimeConfigKey: InjectionKey<RuntimeConfig> =
  Symbol('RuntimeConfig')

export function useRuntimeConfig(): RuntimeConfig {
  const config = inject(RuntimeConfigKey)

  if (!config) {
    throw new Error(
      'RuntimeConfig 未注入:请确认组件运行在应用根组件内部',
    )
  }

  return config
}

相应地,main.ts 使用同一个 key:

import { createApp } from 'vue'
import App from './App.vue'
import { loadRuntimeConfig } from './runtime-config'
import { RuntimeConfigKey } from './use-runtime-config'

async function bootstrap() {
  const config = await loadRuntimeConfig('/config.json')
  const app = createApp(App)

  app.provide(RuntimeConfigKey, config)
  app.mount('#app')
}

void bootstrap()

组件中:

<script setup lang="ts">
import { useRuntimeConfig } from './use-runtime-config'

const config = useRuntimeConfig()

async function loadUsers() {
  const response = await fetch(`${config.apiBaseUrl}/users`, {
    signal: AbortSignal.timeout(config.requestTimeout),
  })

  if (!response.ok) {
    throw new Error(`用户请求失败:${response.status}`)
  }

  return response.json()
}
</script>

<template>
  <button @click="loadUsers">
    加载用户
  </button>
</template>

这里的配置是通过 Vue 的依赖注入传递的,而不是到处读取 window。这样做的好处是:

  • 组件依赖可以被明确表达;
  • 单元测试可以提供不同配置;
  • 业务代码不关心配置来自 JSON、全局变量还是 SSR;
  • 配置加载发生一次,组件不需要重复请求。

八、运行时配置文件的部署方式和一致性问题

假设构建产物为:

dist/
  index.html
  assets/
    index-abc123.js
  config.json

config.json 可以由部署脚本在发布时生成:

cat > dist/config.json <<'EOF'
{
  "apiBaseUrl": "https://api.example.com",
  "requestTimeout": 15000,
  "enableNewDashboard": true
}
EOF

这个命令要求:

  • shell 支持 here-document;
  • dist/ 已经由构建步骤生成;
  • JSON 中的字符串经过正确转义;
  • 生成文件的进程有写权限。

对于来自环境变量的值,不应直接无条件拼接。简单值可以这样处理:

node - <<'NODE'
const fs = require('node:fs')

const config = {
  apiBaseUrl: process.env.API_BASE_URL,
  requestTimeout: Number(process.env.REQUEST_TIMEOUT ?? 15000),
  enableNewDashboard: process.env.ENABLE_NEW_DASHBOARD === 'true',
}

if (
  typeof config.apiBaseUrl !== 'string' ||
  config.apiBaseUrl.length === 0 ||
  !Number.isInteger(config.requestTimeout) ||
  config.requestTimeout <= 0
) {
  throw new Error('部署环境变量无效')
}

fs.writeFileSync(
  'dist/config.json',
  JSON.stringify(config, null, 2) + '\n',
)
NODE

使用时:

API_BASE_URL=https://api.example.com \
REQUEST_TIMEOUT=15000 \
ENABLE_NEW_DASHBOARD=true \
node generate-config.mjs

预期输出文件:

{
  "apiBaseUrl": "https://api.example.com",
  "requestTimeout": 15000,
  "enableNewDashboard": true
}

相比 shell 字符串拼接,使用 JSON.stringify 能正确处理引号、换行和特殊字符,降低生成非法 JSON 或注入内容的风险。

8.1 缓存不是小问题

如果 /config.json 被 CDN 或浏览器缓存,那么“修改了配置”不一定意味着用户立即拿到新配置。

常见策略是让配置响应携带:

Cache-Control: no-store

或者至少:

Cache-Control: no-cache, must-revalidate

客户端的:

fetch('/config.json', { cache: 'no-store' })

只能影响浏览器的请求缓存策略,不能覆盖所有 CDN、反向代理和 Service Worker 的行为。生产验证必须直接检查响应头和实际响应内容:

curl -i https://app.example.com/config.json

应检查:

  • HTTP 状态码是否为 200
  • Content-Type 是否为 application/json
  • 返回内容是否属于当前部署;
  • Cache-Control 是否符合预期;
  • 是否被登录页或网关错误页替代。

8.2 配置和主应用必须协调发布

以下发布顺序可能产生短暂故障:

先替换 config.json
再替换 JavaScript

如果新配置包含旧 JavaScript 不认识的字段,通常问题不大;但如果新 JavaScript 要求旧配置没有的必填字段,则会启动失败。

更稳妥的兼容演进顺序是:

第一阶段:发布能兼容旧配置的新代码
第二阶段:发布新配置
第三阶段:删除旧代码仍依赖的兼容逻辑

若使用版本字段,可以进一步校验:

{
  "schemaVersion": 2,
  "apiBaseUrl": "https://api.example.com",
  "requestTimeout": 15000,
  "enableNewDashboard": true
}

代码可以拒绝不支持的版本:

if (value.schemaVersion !== 2) {
  throw new Error('不支持的运行时配置版本')
}

这样失败原因比“某字段不存在”更明确。


九、构建时配置和运行时配置如何共存

并非所有值都适合放到运行时。

9.1 适合构建时确定的值

以下值常常适合构建时处理:

  • 是否启用某段代码;
  • 是否导入某个插件;
  • 影响打包分支的常量;
  • 构建提交号;
  • 需要被 Tree Shaking 删除的开发代码。

例如:

if (import.meta.env.DEV) {
  console.debug('开发诊断信息')
}

构建器可以删除生产构建中的相关分支。

9.2 适合运行时确定的值

以下值通常适合运行时读取:

  • 不同部署环境的 API 地址;
  • 租户标识;
  • 前端功能开关;
  • 部署后可能修改的公开参数;
  • 不希望因环境不同而重复构建的配置。

9.3 明确优先级,不要隐式混合

一种常见但危险的写法是:

const apiBaseUrl =
  runtimeConfig.apiBaseUrl ||
  import.meta.env.VITE_API_BASE_URL ||
  'http://localhost:8080'

它隐藏了三个问题:

  1. 生产环境到底使用了哪个值;
  2. 运行时配置拼写错误时是否悄悄回退;
  3. 默认值是否被误部署到生产环境。

如果运行时配置是生产必需项,应采用 fail-closed 规则:

const runtimeConfig = await loadRuntimeConfig('/config.json')

失败就停止启动,而不是静默回退。

如果确实需要开发环境回退,应把规则写明并限制在开发环境:

const configUrl =
  import.meta.env.DEV
    ? import.meta.env.VITE_RUNTIME_CONFIG_URL || '/config.json'
    : '/config.json'

更重要的是,生产路径不应因为缺少配置而自动使用本地 API 地址。


十、在 Vite 配置文件中读取服务端环境变量

vite.config.ts 在 Node 进程中执行,与浏览器业务代码不是同一个运行环境。

例如开发代理的目标地址只供 Vite 开发服务器使用:

# .env.development
DEV_API_TARGET=http://localhost:9000
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')

  return {
    plugins: [vue()],
    server: {
      proxy: {
        '/api': {
          target: env.DEV_API_TARGET,
          changeOrigin: true,
        },
      },
    },
  }
})

这里使用 loadEnv(mode, process.cwd(), '') 的原因是:Vite 配置需要读取没有 VITE_ 前缀的服务端变量。

请求流程是:

浏览器 -> Vite 开发服务器 /api/users
Vite 开发服务器 -> DEV_API_TARGET/api/users

DEV_API_TARGET 不会因此自动进入浏览器 JavaScript。它只用于开发服务器转发。

但必须注意,代理只是开发体验功能,不是生产安全边界。生产环境需要由真实的反向代理、网关或后端服务处理 /api 路由。

如果配置文件中读取了数据库密码、云厂商密钥等服务端变量,不能通过下面方式把它们传给前端:

define: {
  __DATABASE_PASSWORD__: JSON.stringify(env.DATABASE_PASSWORD),
}

这会直接把密码写入客户端产物。


十一、Secret 边界:浏览器能使用的凭据就不是 Secret

可以用一个简单的可观测性条件判断:

如果值 x 被客户端用来完成请求、签名或解密,
那么拥有浏览器控制权的用户通常可以获取 x 或复现使用过程。

因此以下内容不能放入前端:

  • 数据库密码;
  • 云服务 Access Key Secret;
  • JWT 签名私钥;
  • 第三方服务管理端密钥;
  • 内部服务之间的认证凭据;
  • 具有高权限的 API Token。

即使代码经过压缩、混淆或 Base64 编码,也没有改变这个条件。混淆只提高阅读成本,不改变权限模型。

11.1 “公开标识”可以进入前端

有些值常被叫作“客户端密钥”,但实际是公开标识,例如:

  • OAuth 的 public client ID;
  • 需要绑定域名的地图服务公开 key;
  • 前端错误监控的项目 ID;
  • 公共资源的版本号。

它们可以进入前端的前提是:服务商明确将其定义为公开值,并且权限由域名、来源、配额等其他机制限制。不能仅因名称叫 client_secret 就把它放到前端。

11.2 正确做法是让服务端持有秘密

例如前端需要调用一个必须使用供应商密钥的服务:

错误:
浏览器 -> 供应商 API
         请求头携带供应商 Secret

正确:
浏览器 -> 自己的后端 /api/search
后端   -> 供应商 API
         请求头携带供应商 Secret

后端可以负责:

  • 保存 Secret;
  • 验证用户身份;
  • 限制权限和速率;
  • 记录审计日志;
  • 过滤请求参数和响应数据;
  • 在 Secret 泄露时轮换。

如果业务需要前端调用 API,服务端可以签发短时、低权限、可撤销的凭据,但这类凭据仍然是“客户端可见凭据”,不能当作永久 Secret。


十二、启动校验、类型检查和运行时校验不是一回事

TypeScript 只能检查编译器能够看到的类型,不能证明部署时的 JSON 真的符合类型。

例如:

interface RuntimeConfig {
  requestTimeout: number
}

并不能保证外部 JSON 是:

{
  "requestTimeout": "15000"
}

TypeScript 类型和运行时数据之间存在边界:

静态类型声明:编译时假设
JSON 响应:运行时事实
校验函数:把事实转换成可信内部值

因此需要三层检查:

  1. 编辑器和编译检查:防止代码把字符串当数字使用;
  2. 启动时运行时校验:防止部署文件格式错误;
  3. 服务端或部署流水线校验:尽量在用户访问前发现错误。

如果项目已经使用 schema 库,也可以用 Zod 等工具替代手写校验。例如概念上:

const RuntimeConfigSchema = z.object({
  apiBaseUrl: z.string().url(),
  requestTimeout: z.number().int().positive(),
  enableNewDashboard: z.boolean(),
})

const config = RuntimeConfigSchema.parse(await response.json())

具体 API 取决于所使用的库版本;无论使用何种库,核心机制都一样:外部数据先解析,校验通过后才进入应用内部。


十三、常见失败表现和诊断路径

13.1 页面请求了 undefined/api

可能原因:

  • 变量没有 VITE_ 前缀;
  • .env 文件位于错误目录;
  • 修改 .env 后没有重启开发服务器;
  • 使用了错误的 mode;
  • 变量值为空字符串;
  • 使用了动态属性访问;
  • 构建产物来自旧构建。

诊断步骤:

console.log(import.meta.env.MODE)
console.log(import.meta.env.VITE_API_BASE_URL)

然后确认:

vite build --mode staging

使用了预期的文件,并检查构建产物:

grep -R "staging-api.example.com" dist/assets

注意:不要在日志中打印可能包含凭据的环境变量。这里只能检查公开 API 地址或其他非敏感值。

13.2 生产环境修改了容器环境变量,但页面没有变化

这是构建时变量的正常行为。

错误预期:

容器启动时设置 VITE_API_BASE_URL
-> 已生成的 JS 自动读取新值

实际情况通常是:

npm run build 时读取 VITE_API_BASE_URL
-> 值被写入 dist/assets
-> 容器启动时再设置同名变量不会改变静态 JS

解决方式有两个:

  • 每次环境变化都重新构建;
  • 把需要部署后变化的值移动到运行时配置文件或接口。

13.3 /config.json 返回 HTML

单页应用部署中,反向代理可能把不存在的 /config.json 回退到 index.html。结果是:

HTTP 200
Content-Type: text/html
响应内容为 <!doctype html>

如果代码直接调用 response.json(),会出现 JSON 解析错误。

诊断:

curl -i https://app.example.com/config.json

修复方向:

  • 确保配置文件实际存在;
  • /config.json 配置静态文件优先级;
  • 不要让未知 JSON 路径被 SPA fallback 截获;
  • 在客户端同时检查状态码和 JSON 解析结果。

13.4 配置更新后部分用户仍使用旧值

可能涉及:

  • 浏览器 HTTP 缓存;
  • CDN 缓存;
  • Service Worker 缓存;
  • HTML 和 config.json 分属不同缓存策略;
  • 多节点发布不一致。

验证时要分别检查:

curl -I https://app.example.com/config.json
curl https://app.example.com/config.json

如果使用 Service Worker,还应检查 Cache Storage,而不是只看普通 Network 请求。

13.5 配置解析成功但业务请求失败

这说明“配置格式正确”不等于“配置可用”。

例如:

{
  "apiBaseUrl": "https://api.example.com"
}

URL 语法合法,但可能:

  • DNS 不可解析;
  • TLS 证书错误;
  • CORS 未允许当前来源;
  • API 路径不兼容;
  • 认证策略不匹配;
  • 网络只允许内网访问。

配置校验可以验证结构和局部约束,不能替代端到端健康检查。发布验证还应执行:

curl -i https://api.example.com/health

或者通过浏览器实际访问一个不携带敏感信息的健康接口。


十四、运行时配置加载失败时,是否应该回退

这取决于配置的性质。

必需配置:失败关闭

API 地址、认证模式、资源基础路径等配置缺失时,继续启动通常会造成更难诊断的级联错误:

配置请求失败
-> 应用使用默认值
-> 请求错误服务
-> 用户看到空白列表
-> 监控只记录大量业务请求失败

对于必需配置,应直接显示启动错误页,并让发布系统修复配置。

可选配置:显式默认值

例如一个纯展示用的 UI 开关,可以定义默认值:

const enableNewDashboard =
  typeof value.enableNewDashboard === 'boolean'
    ? value.enableNewDashboard
    : false

但默认值必须是安全的、文档化的,并且不能掩盖必填字段错误。不要对所有字段都使用:

value.someField || defaultValue

因为空字符串、零、false 和缺失字段的语义并不相同。

不建议在生产环境自动切换到备用后端

这种回退:

const apiBaseUrl =
  config.apiBaseUrl || 'https://backup.example.com'

可能把流量发送到错误租户、旧版本 API 或未经验证的服务。高风险配置应让失败可见,而不是让系统悄悄选择另一个目标。


十五、配置系统中的并发和状态变化

配置加载通常发生一次,但多个组件可能同时依赖它。最简单的方案是“加载完成后才挂载 Vue”,这样不会产生组件级并发请求。

如果应用架构要求在挂载后再加载,可以缓存 Promise:

// src/runtime-config.ts
let configPromise: Promise<RuntimeConfig> | undefined

export function loadRuntimeConfigOnce(
  url = '/config.json',
): Promise<RuntimeConfig> {
  configPromise ??= loadRuntimeConfig(url)
  return configPromise
}

状态变化可以表示为:

未加载
  -> 加载中
      -> 成功:得到不可变配置
      -> 失败:进入错误状态

不要在失败后无条件反复重试。配置文件 404、JSON 格式错误和字段类型错误通常不是临时网络故障,重试只会增加启动延迟。

如果需要重试,应区分故障类型:

  • DNS、连接超时:可能适合有限次数重试;
  • HTTP 404:部署缺文件,不应长时间重试;
  • HTTP 200 但 JSON 非法:配置发布错误,不应重试;
  • HTTP 401/403:访问控制或部署路径错误,应立即报警。

十六、配置路径与 base 的关系

如果应用不是部署在域名根路径,例如:

https://example.com/console/

而是使用:

export default defineConfig({
  base: '/console/',
})

那么运行时配置 URL 需要和部署路径一致。直接写:

fetch('/config.json')

请求的是域名根路径:

https://example.com/config.json

而不是:

https://example.com/console/config.json

可以使用 Vite 提供的 BASE_URL

const configUrl = new URL(
  'config.json',
  window.location.origin + import.meta.env.BASE_URL,
).toString()

const config = await loadRuntimeConfig(configUrl)

或者在同源部署且路径规则稳定时:

const configUrl = `${import.meta.env.BASE_URL}config.json`

BASE_URL 是构建时确定的部署基础路径;它不能代替 API 地址这类环境相关配置。


十七、服务端渲染时需要重新划分边界

在纯 SPA 中,浏览器代码是最终消费者。若使用 SSR,配置可能同时存在于:

  • Node 服务端;
  • 服务端渲染结果;
  • 浏览器端激活代码;
  • 浏览器发起的 API 请求。

服务端可以读取真正的 Secret,但不能把 Secret 放进 SSR 返回给浏览器。例如下面的做法是错误的:

return {
  props: {
    databasePassword,
  },
}

即使它只存在于服务端代码中,只要被序列化到 HTML 或 hydration state,就已经泄露。

SSR 配置需要明确分成:

server-only config
client-safe config

只有第二类才能进入 HTML、序列化状态或客户端 JavaScript。import.meta.env.SSR 可以帮助区分构建代码运行在服务端还是客户端,但它同样不是 Secret 保护机制;最终仍要审查哪些值被返回给浏览器。


十八、一个配置边界的判断表

配置 构建时 运行时 可进入浏览器 是否应保密
VITE_API_BASE_URL 可以 也可以 可以
VITE_FEATURE_ENABLED 可以 可以 可以
OAuth public client ID 可以 可以 可以 否,但需按供应商规则限制
数据库密码 可被 Node 读取 不应给浏览器 不可以
JWT 签名私钥 可被服务端读取 不应给浏览器 不可以
后端短时访问令牌 不推荐写死 可按需下发 可以 不是永久 Secret,应限制权限和生命周期
Vite 开发代理目标 Vite 配置可读取 不属于浏览器运行时 通常不可以 视其内容决定

判断标准不是变量名称,而是数据流:

变量从哪里产生?
经过哪些构建或部署步骤?
是否被写入 JS、HTML、JSON 或请求?
谁能够读取最终载体?

十九、发布前的验证闭环

一个可执行的验证流程应同时覆盖构建、文件、浏览器和权限边界。

1. 验证构建 mode

vite build --mode staging

确认日志和构建配置使用了预期 mode。

2. 搜索不应出现的内容

不要只检查 API 地址,也应扫描产物中是否出现明显的敏感标识:

grep -R "DATABASE_PASSWORD" dist || true
grep -R "PRIVATE_KEY" dist || true
grep -R "SECRET_TOKEN" dist || true

这不是完备的 Secret 扫描器,但可以发现低级误配置。生产流水线还应使用专门的 Secret 扫描工具。

3. 检查运行时配置

curl -i https://app.example.com/config.json

确认:

  • 文件可访问;
  • JSON 合法;
  • 字段类型正确;
  • API 地址属于当前环境;
  • 缓存策略符合发布要求;
  • 不包含服务端密钥。

4. 检查浏览器启动行为

在 DevTools 中观察:

config.json -> 200
主应用加载
API 请求使用正确的 apiBaseUrl

故意将配置改成非法 JSON 或删除必填字段,应用应显示启动错误页,而不是静默使用错误默认值。

5. 检查 Secret 是否仍在服务端

服务端密钥应该只出现在:

  • 服务端进程环境;
  • Secret Manager;
  • 服务端内存;
  • 受保护的服务端日志上下文。

不应出现在:

  • dist/
  • HTML;
  • /config.json
  • 浏览器 Network 响应;
  • 客户端 source map;
  • 前端错误上报的配置对象。

二十、最终的设计边界

Vite 环境变量解决的是:

构建过程如何获得输入

import.meta.env 解决的是:

如何把经过筛选的构建信息提供给前端源码

运行时注入解决的是:

静态构建完成后,部署环境如何向浏览器提供可变配置

运行时校验解决的是:

如何把不可信的外部数据转换成应用可以依赖的内部配置

Secret 边界解决的是:

哪些值永远不能沿着客户端数据流传播

因此,一个可靠的 Vue 配置系统通常遵循这样的数据流:

服务端 Secret
  -> 仅服务端使用

部署环境公开配置
  -> config.json / HTML 注入
  -> 浏览器启动前读取
  -> 运行时校验
  -> Vue provide / composable
  -> API 客户端和组件使用

而构建变量的数据流是另一条路径:

.env / CI 环境变量
  -> Vite 构建
  -> 静态替换
  -> dist/assets
  -> 浏览器可观察

只要一个值进入了 dist、HTML、运行时配置响应或浏览器请求,它就已经越过了 Secret 边界。区分构建时和运行时,建立显式注入协议,先校验再挂载,并把真正的秘密留在服务端,才是 Vue 应用配置管理中最核心的因果关系。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。