Vue 基础体系 · 第 61/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 环境与运行时配置:构建变量、注入、校验和 Secret 边界
在 Vue 应用中,“配置”至少有两个不同的时间点:
- 构建时:Vite 读取环境文件,把变量替换或注入到构建产物中。
- 运行时:浏览器已经加载了静态 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 生态中常见的环境变量,通常表示development、production等运行状态。
例如:
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
如果每套环境都重新执行一次构建,流程是:
测试配置 -> 构建 -> 测试部署
预发布配置 -> 构建 -> 预发布部署
生产配置 -> 构建 -> 生产部署
这会带来两个问题:
- 同一个源代码提交产生了多份不同构建产物;
- 部署系统必须拥有完整的前端构建能力,而不仅是静态文件发布能力。
如果要求“一个构建产物,多环境部署”,则流程应变成:
源代码 -> 构建一次 -> 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'
它隐藏了三个问题:
- 生产环境到底使用了哪个值;
- 运行时配置拼写错误时是否悄悄回退;
- 默认值是否被误部署到生产环境。
如果运行时配置是生产必需项,应采用 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 响应:运行时事实
校验函数:把事实转换成可信内部值
因此需要三层检查:
- 编辑器和编译检查:防止代码把字符串当数字使用;
- 启动时运行时校验:防止部署文件格式错误;
- 服务端或部署流水线校验:尽量在用户访问前发现错误。
如果项目已经使用 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 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vite 插件开发:Hook、虚拟模块、转换、HMR 和调试
- 下一篇:Vue 微前端:路由、状态、样式、依赖隔离和迁移取舍
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论