环境管理与配置
问题
在前端项目中,如何管理不同环境(开发、测试、生产)的配置?环境变量如何安全地注入到前端应用中?Feature Flags 和配置中心在实际项目中如何落地?
答案
环境管理与配置是前端工程化的核心基础设施之一。一个成熟的前端项目通常需要对接多套后端环境(开发、测试、预发布、生产),不同环境下的 API 地址、第三方服务 Key、功能开关等都有所不同。如何优雅、安全地管理这些配置,是每位前端工程师都需要掌握的能力。
1. 环境变量管理
1.1 .env 文件体系
.env 文件是前端项目管理环境变量的标准方式。不同的构建工具对 .env 文件的加载规则略有不同,但基本遵循以下优先级:
.env # 所有环境都会加载
.env.local # 所有环境加载,被 git 忽略
.env.[mode] # 只在指定模式下加载,如 .env.production
.env.[mode].local # 只在指定模式下加载,被 git 忽略
后加载的文件会覆盖先加载的同名变量。即 .env.production 中的变量会覆盖 .env 中的同名变量。.local 后缀的文件优先级最高,且应该被加入 .gitignore。
一个典型的 .env 文件体系如下:
# 应用名称
VITE_APP_TITLE=My App
# 默认日志级别
VITE_LOG_LEVEL=info
# 开发环境 API 地址
VITE_API_BASE_URL=http://localhost:3000/api
# 开启调试模式
VITE_DEBUG=true
# Mock 数据开关
VITE_ENABLE_MOCK=true
# 预发布环境 API 地址
VITE_API_BASE_URL=https://staging-api.example.com/api
# 关闭调试
VITE_DEBUG=false
VITE_ENABLE_MOCK=false
# 生产环境 API 地址
VITE_API_BASE_URL=https://api.example.com/api
# 关闭调试
VITE_DEBUG=false
VITE_ENABLE_MOCK=false
# Sentry DSN
VITE_SENTRY_DSN=https://xxx@sentry.io/xxx
1.2 dotenv 原理
dotenv 是 Node.js 生态中最流行的环境变量加载工具。它的核心原理非常简单:读取 .env 文件,解析其中的键值对,注入到 process.env 中。
import * as fs from 'fs';
import * as path from 'path';
interface ParsedEnv {
[key: string]: string;
}
function parse(src: string): ParsedEnv {
const result: ParsedEnv = {};
// 按行分割,跳过注释和空行
const lines = src.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([^=]+)=(.*)$/);
if (match) {
const key = match[1].trim();
let value = match[2].trim();
// 去除引号
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
result[key] = value;
}
}
return result;
}
function config(envPath: string = '.env'): ParsedEnv {
const absolutePath = path.resolve(process.cwd(), envPath);
const content = fs.readFileSync(absolutePath, 'utf-8');
const parsed = parse(content);
// 注入到 process.env,已存在的变量不会被覆盖
for (const [key, value] of Object.entries(parsed)) {
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
return parsed;
}
dotenv 默认不会覆盖已经存在于 process.env 中的变量。这意味着系统级环境变量(如 CI/CD 平台注入的变量)优先级高于 .env 文件。如果需要强制覆盖,可以使用 dotenv 的 override: true 选项。
1.3 Vite 的 import.meta.env vs Webpack 的 process.env
这是面试高频考点。Vite 和 Webpack 在环境变量注入机制上有本质区别:
| 特性 | Vite (import.meta.env) | Webpack (process.env) |
|---|---|---|
| 注入方式 | 编译时静态替换 | DefinePlugin 字符串替换 |
| 前缀要求 | VITE_ 前缀 | REACT_APP_(CRA)或自定义 |
| 内置变量 | MODE、BASE_URL、DEV、PROD、SSR | NODE_ENV |
| 类型支持 | 可通过 env.d.ts 声明 | 需手动声明 |
| Tree Shaking | 天然支持(静态替换后死代码消除) | 支持(替换后压缩时消除) |
| 运行时访问 | 仅暴露 VITE_ 前缀的变量 | 取决于 DefinePlugin 配置 |
| 底层标准 | ESM 标准 import.meta | Node.js process 对象模拟 |
Vite 环境变量注入原理:
Vite 在构建时会将 import.meta.env.VITE_XXX 直接替换为对应的字符串字面量。这不是运行时行为,而是编译时的静态替换。
// 你写的代码
const apiUrl = import.meta.env.VITE_API_BASE_URL;
console.log(import.meta.env.MODE);
if (import.meta.env.DEV) {
console.log('开发模式');
}
// Vite 构建后的代码(production)
const apiUrl = "https://api.example.com/api";
console.log("production");
// DEV 为 false,整个 if 块会被 Tree Shaking 移除
// if (false) { console.log('开发模式'); }
Webpack 环境变量注入原理:
Webpack 通过 DefinePlugin 实现环境变量注入,本质是全局字符串替换:
import webpack from 'webpack';
import dotenv from 'dotenv';
// 加载 .env 文件
const env = dotenv.config().parsed ?? {};
const config: webpack.Configuration = {
plugins: [
new webpack.DefinePlugin({
// 将 process.env.XXX 替换为字符串字面量
'process.env.API_URL': JSON.stringify(env.API_URL),
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
// 也可以一次性注入所有以特定前缀开头的变量
...Object.keys(env)
.filter((key) => key.startsWith('APP_'))
.reduce(
(acc, key) => {
acc[`process.env.${key}`] = JSON.stringify(env[key]);
return acc;
},
{} as Record<string, string>
),
}),
],
};
DefinePlugin 是纯文本替换,因此 process.env.API_URL 必须用 JSON.stringify() 包裹。如果写成 'process.env.API_URL': env.API_URL(假设值为 https://api.example.com),替换后代码会变成 const url = https://api.example.com,这不是合法的 JavaScript,会导致语法错误。
2. 多环境配置
2.1 环境分类
一个典型的前端项目通常需要以下环境:
| 环境 | 用途 | 特点 |
|---|---|---|
local | 本地开发 | 使用 Mock 数据,开启 HMR、SourceMap |
development | 联调环境 | 对接开发服务器,可能开启调试工具 |
staging | 预发布环境 | 与生产配置一致,但连接预发布后端 |
production | 生产环境 | 代码压缩、关闭调试、错误上报 |
2.2 多环境配置实践
以下是一个完整的多环境配置方案:
// 环境类型定义
type Environment = 'local' | 'development' | 'staging' | 'production';
interface AppConfig {
env: Environment;
apiBaseUrl: string;
sentryDsn: string;
enableMock: boolean;
enableDebug: boolean;
logLevel: 'debug' | 'info' | 'warn' | 'error';
cdnBaseUrl: string;
featureFlagEndpoint: string;
}
// 各环境配置映射
const configs: Record<Environment, AppConfig> = {
local: {
env: 'local',
apiBaseUrl: 'http://localhost:3000/api',
sentryDsn: '',
enableMock: true,
enableDebug: true,
logLevel: 'debug',
cdnBaseUrl: '',
featureFlagEndpoint: '',
},
development: {
env: 'development',
apiBaseUrl: 'https://dev-api.example.com/api',
sentryDsn: '',
enableMock: false,
enableDebug: true,
logLevel: 'debug',
cdnBaseUrl: 'https://dev-cdn.example.com',
featureFlagEndpoint: 'https://dev-flags.example.com',
},
staging: {
env: 'staging',
apiBaseUrl: 'https://staging-api.example.com/api',
sentryDsn: 'https://xxx@sentry.io/staging',
enableMock: false,
enableDebug: false,
logLevel: 'warn',
cdnBaseUrl: 'https://staging-cdn.example.com',
featureFlagEndpoint: 'https://staging-flags.example.com',
},
production: {
env: 'production',
apiBaseUrl: 'https://api.example.com/api',
sentryDsn: 'https://xxx@sentry.io/prod',
enableMock: false,
enableDebug: false,
logLevel: 'error',
cdnBaseUrl: 'https://cdn.example.com',
featureFlagEndpoint: 'https://flags.example.com',
},
};
// 获取当前环境
function getCurrentEnv(): Environment {
const mode = import.meta.env.MODE as string;
if (mode in configs) {
return mode as Environment;
}
return 'development'; // 默认 fallback
}
// 导出当前配置(冻结以防止运行时修改)
export const appConfig: Readonly<AppConfig> = Object.freeze(
configs[getCurrentEnv()]
);
2.3 Vite 自定义模式
Vite 支持通过 --mode 参数指定自定义模式,对应加载不同的 .env.[mode] 文件:
{
"scripts": {
"dev": "vite --mode local",
"dev:remote": "vite --mode development",
"build:staging": "vite build --mode staging",
"build": "vite build --mode production",
"preview": "vite preview"
}
}
在 vite.config.ts 中也可以根据模式进行动态配置:
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
// 加载对应模式的环境变量
const env = loadEnv(mode, process.cwd(), '');
return {
define: {
// 如果需要注入非 VITE_ 前缀的变量
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
},
server: {
proxy: {
'/api': {
target: env.VITE_API_BASE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
build: {
sourcemap: mode !== 'production',
minify: mode === 'production' ? 'terser' : false,
},
};
});
3. TypeScript 类型安全的环境变量
在 TypeScript 项目中,import.meta.env 的变量默认是 string | undefined 类型。通过声明文件可以获得完整的类型提示和编译时校验。
3.1 Vite 环境变量类型声明
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** API 基础地址 */
readonly VITE_API_BASE_URL: string;
/** 是否开启调试模式 */
readonly VITE_DEBUG: string;
/** 是否开启 Mock */
readonly VITE_ENABLE_MOCK: string;
/** Sentry DSN */
readonly VITE_SENTRY_DSN: string;
/** 日志级别 */
readonly VITE_LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
/** CDN 基础地址 */
readonly VITE_CDN_BASE_URL: string;
// 更多自定义环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
3.2 环境变量验证
在应用启动时校验必要的环境变量是否存在,避免运行时出现意外的 undefined:
interface EnvRule {
key: string;
required: boolean;
pattern?: RegExp;
defaultValue?: string;
}
const envRules: EnvRule[] = [
{
key: 'VITE_API_BASE_URL',
required: true,
pattern: /^https?:\/\/.+/,
},
{
key: 'VITE_SENTRY_DSN',
required: import.meta.env.PROD, // 仅生产环境必填
},
{
key: 'VITE_LOG_LEVEL',
required: false,
defaultValue: 'info',
},
];
function validateEnv(): void {
const errors: string[] = [];
for (const rule of envRules) {
const value = import.meta.env[rule.key as keyof ImportMetaEnv];
if (rule.required && !value) {
errors.push(`Missing required env variable: ${rule.key}`);
continue;
}
if (value && rule.pattern && !rule.pattern.test(value)) {
errors.push(
`Env variable ${rule.key} does not match pattern: ${rule.pattern}`
);
}
}
if (errors.length > 0) {
const message = `Environment validation failed:\n${errors.join('\n')}`;
if (import.meta.env.PROD) {
// 生产环境上报错误但不阻断
console.error(message);
} else {
// 开发环境直接抛出,便于发现问题
throw new Error(message);
}
}
}
// 在应用入口调用
validateEnv();
3.3 Webpack 项目(如 CRA)的类型声明
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV: 'development' | 'production' | 'test';
readonly REACT_APP_API_BASE_URL: string;
readonly REACT_APP_SENTRY_DSN: string;
readonly REACT_APP_ENABLE_DEBUG: string;
// 更多自定义环境变量...
}
}
4. Feature Flags(功能开关)
Feature Flags(也叫 Feature Toggles)是一种可以在不发布新代码的情况下控制功能上下线的技术。它是现代前端工程化中非常重要的实践。
4.1 核心原理
Feature Flags 的核心思想是将功能发布与代码部署解耦。代码可以随时部署到生产环境,但功能是否对用户可见由远程配置决定。
4.2 简单实现
// Feature Flag 类型定义
interface FeatureFlag {
key: string;
enabled: boolean;
/** 灰度比例 0-100 */
percentage?: number;
/** 白名单用户 */
whitelist?: string[];
/** 环境限制 */
environments?: string[];
}
interface UserContext {
userId: string;
role: string;
environment: string;
}
class FeatureFlagManager {
private flags: Map<string, FeatureFlag> = new Map();
private userContext: UserContext;
constructor(userContext: UserContext) {
this.userContext = userContext;
}
// 从远程加载 flags
async loadFlags(endpoint: string): Promise<void> {
try {
const response = await fetch(endpoint, {
headers: { 'X-User-Id': this.userContext.userId },
});
const data: FeatureFlag[] = await response.json();
for (const flag of data) {
this.flags.set(flag.key, flag);
}
} catch (error) {
console.error('Failed to load feature flags:', error);
// 加载失败时使用默认值(全部关闭)
}
}
// 判断功能是否开启
isEnabled(flagKey: string): boolean {
const flag = this.flags.get(flagKey);
if (!flag) return false;
// 基础开关
if (!flag.enabled) return false;
// 环境限制
if (
flag.environments &&
!flag.environments.includes(this.userContext.environment)
) {
return false;
}
// 白名单优先
if (flag.whitelist?.includes(this.userContext.userId)) {
return true;
}
// 灰度百分比(基于用户 ID 哈希,确保同一用户结果一致)
if (flag.percentage !== undefined && flag.percentage < 100) {
const hash = this.hashUserId(this.userContext.userId);
return hash % 100 < flag.percentage;
}
return true;
}
private hashUserId(userId: string): number {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
const char = userId.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0; // 转换为 32 位整数
}
return Math.abs(hash);
}
}
// 使用示例
const featureFlags = new FeatureFlagManager({
userId: 'user_123',
role: 'admin',
environment: import.meta.env.MODE,
});
await featureFlags.loadFlags(appConfig.featureFlagEndpoint);
if (featureFlags.isEnabled('new-dashboard')) {
// 渲染新版仪表盘
} else {
// 渲染旧版仪表盘
}
4.3 React 中使用 Feature Flags
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react';
interface FeatureFlagContextType {
isEnabled: (key: string) => boolean;
isLoading: boolean;
}
const FeatureFlagContext = createContext<FeatureFlagContextType>({
isEnabled: () => false,
isLoading: true,
});
export function FeatureFlagProvider({ children }: { children: ReactNode }) {
const [flags, setFlags] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch('/api/feature-flags')
.then((res) => res.json())
.then((data: Record<string, boolean>) => {
setFlags(data);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}, []);
const isEnabled = (key: string): boolean => flags[key] ?? false;
return (
<FeatureFlagContext.Provider value={{ isEnabled, isLoading }}>
{children}
</FeatureFlagContext.Provider>
);
}
// 自定义 Hook
export function useFeatureFlag(key: string): {
enabled: boolean;
loading: boolean;
} {
const { isEnabled, isLoading } = useContext(FeatureFlagContext);
return { enabled: isEnabled(key), loading: isLoading };
}
// 条件渲染组件
export function Feature({
flag,
children,
fallback = null,
}: {
flag: string;
children: ReactNode;
fallback?: ReactNode;
}) {
const { enabled, loading } = useFeatureFlag(flag);
if (loading) return null;
return <>{enabled ? children : fallback}</>;
}
使用方式:
import { Feature, useFeatureFlag } from './feature-flags/FeatureFlagProvider';
function Dashboard() {
const { enabled: showNewChart } = useFeatureFlag('new-chart');
return (
<div>
<Feature flag="new-header" fallback={<OldHeader />}>
<NewHeader />
</Feature>
{showNewChart ? <NewChart /> : <OldChart />}
<Feature flag="beta-export">
<ExportButton />
</Feature>
</div>
);
}
4.4 主流 Feature Flag 平台对比
| 特性 | LaunchDarkly | Unleash | Flagsmith | 自研方案 |
|---|---|---|---|---|
| 开源 | 否(商业) | 是(开源) | 是(开源) | - |
| 灰度发布 | 支持 | 支持 | 支持 | 需自研 |
| A/B 测试 | 内置 | 需扩展 | 支持 | 需自研 |
| SDK 语言 | 全平台 | 多语言 | 多语言 | 自定义 |
| 实时更新 | SSE/WebSocket | 轮询/WebHook | 轮询 | 自定义 |
| 用户分群 | 强大 | 基础 | 中等 | 需自研 |
| 成本 | 高 | 低(自托管) | 中 | 开发成本 |
| 适用场景 | 大型企业 | 中小型团队 | 中型项目 | 特殊需求 |
5. 配置中心(动态配置)
与编译时注入的环境变量不同,配置中心支持在运行时动态获取配置,无需重新构建部署。
5.1 运行时配置注入方案
方案一:HTML 模板注入(推荐)
在 index.html 中通过全局变量注入配置,部署时由 CI/CD 或 Nginx 替换:
<!doctype html>
<html>
<head>
<script>
// 由部署脚本或 Nginx sub_filter 替换占位符
window.__APP_CONFIG__ = {
apiBaseUrl: '{{API_BASE_URL}}',
cdnBaseUrl: '{{CDN_BASE_URL}}',
sentryDsn: '{{SENTRY_DSN}}',
version: '{{APP_VERSION}}',
};
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
// 运行时配置类型
interface RuntimeConfig {
apiBaseUrl: string;
cdnBaseUrl: string;
sentryDsn: string;
version: string;
}
// 扩展 Window 类型
declare global {
interface Window {
__APP_CONFIG__?: RuntimeConfig;
}
}
// 获取运行时配置,带默认值 fallback
export function getRuntimeConfig(): RuntimeConfig {
const config = window.__APP_CONFIG__;
if (!config || config.apiBaseUrl.startsWith('{{')) {
// 占位符未被替换,使用编译时环境变量作为 fallback
return {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '',
cdnBaseUrl: import.meta.env.VITE_CDN_BASE_URL ?? '',
sentryDsn: import.meta.env.VITE_SENTRY_DSN ?? '',
version: import.meta.env.VITE_APP_VERSION ?? '0.0.0',
};
}
return config;
}
方案二:远程配置接口
interface RemoteConfig {
features: Record<string, boolean>;
themeColor: string;
announcement: string | null;
maintenanceMode: boolean;
apiRateLimit: number;
}
class ConfigService {
private config: RemoteConfig | null = null;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
async init(): Promise<RemoteConfig> {
this.config = await this.fetchConfig();
// 定时刷新配置(每 5 分钟)
this.refreshTimer = setInterval(
() => {
this.fetchConfig().then((c) => (this.config = c));
},
5 * 60 * 1000
);
return this.config;
}
private async fetchConfig(): Promise<RemoteConfig> {
const response = await fetch('/api/config', {
headers: { 'Cache-Control': 'no-cache' },
});
if (!response.ok) {
throw new Error(`Config fetch failed: ${response.status}`);
}
return response.json();
}
get<K extends keyof RemoteConfig>(key: K): RemoteConfig[K] {
if (!this.config) {
throw new Error('ConfigService not initialized');
}
return this.config[key];
}
destroy(): void {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
}
}
export const configService = new ConfigService();
5.2 编译时配置 vs 运行时配置
| 维度 | 编译时配置(.env) | 运行时配置(配置中心) |
|---|---|---|
| 生效方式 | 构建时写入代码 | 运行时动态加载 |
| 修改流程 | 修改 .env + 重新构建部署 | 修改配置中心,即时生效 |
| 适用场景 | API 地址、第三方 SDK Key | Feature Flags、公告、灰度 |
| 安全性 | 会被打包到产物中 | 可控制暴露范围 |
| 可靠性 | 不依赖外部服务 | 依赖配置接口可用性 |
| 复杂度 | 低 | 中高(需考虑缓存、降级) |
实际项目中建议两者结合使用:基础的、变化不频繁的配置(如 API 地址)使用编译时环境变量;需要动态调整的配置(如功能开关、公告信息)使用运行时配置中心。
6. 安全注意事项
环境变量安全是面试中经常被追问的重点。核心原则只有一条:任何注入到前端代码中的值,最终都会暴露给用户。
6.1 哪些信息不能放在前端
即使使用了 VITE_ 或 REACT_APP_ 前缀过滤,也只是防止无意中暴露非前缀变量。前缀变量本身仍然会被打包到前端产物中,用户可以通过浏览器 DevTools 或查看 JS 源码看到这些值。因此,敏感信息必须通过后端 API 间接使用,永远不要直接写入前端环境变量。
6.2 安全实践清单
import * as fs from 'fs';
import * as path from 'path';
// 敏感关键词列表
const sensitivePatterns: RegExp[] = [
/SECRET/i,
/PASSWORD/i,
/PRIVATE_KEY/i,
/DB_URL/i,
/DATABASE/i,
/MONGO_URI/i,
/AWS_SECRET/i,
/CREDENTIALS/i,
];
// 允许的前缀(这些变量会被注入前端)
const clientPrefixes = ['VITE_', 'REACT_APP_', 'NEXT_PUBLIC_'];
function checkEnvFile(filePath: string): string[] {
const warnings: string[] = [];
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key] = trimmed.split('=');
if (!key) continue;
const isClientVar = clientPrefixes.some((prefix) =>
key.startsWith(prefix)
);
const isSensitive = sensitivePatterns.some((pattern) =>
pattern.test(key)
);
if (isClientVar && isSensitive) {
warnings.push(
`[DANGER] ${filePath}: "${key}" looks sensitive but has client prefix!`
);
}
}
return warnings;
}
// 扫描项目中的所有 .env 文件
const envFiles = fs
.readdirSync(process.cwd())
.filter((f) => f.startsWith('.env'));
const allWarnings = envFiles.flatMap((f) =>
checkEnvFile(path.join(process.cwd(), f))
);
if (allWarnings.length > 0) {
console.error('Environment security check failed:');
allWarnings.forEach((w) => console.error(w));
process.exit(1);
} else {
console.log('Environment security check passed.');
}
6.3 .gitignore 配置
确保敏感的环境文件不会被提交到版本控制:
# 环境变量文件
.env.local
.env.*.local
.env.development.local
.env.staging.local
.env.production.local
# 注意:.env 和 .env.development 等非 local 文件
# 通常可以提交(包含非敏感默认值)
# 但 .env.production 视情况决定是否提交
常见面试问题
Q1: Vite 的 import.meta.env 和 Webpack 的 process.env 有什么区别?为什么 Vite 要用 import.meta.env?
答案:
两者本质上都是编译时静态替换,但在实现机制和设计理念上有显著差异。
1. 标准差异
process.env是 Node.js 的全局对象,浏览器中原本不存在。Webpack 通过DefinePlugin模拟了这个对象,本质是文本字符串替换。import.meta是 ESM 标准 的一部分,是浏览器原生支持的语法。Vite 选择import.meta.env更符合现代 Web 标准。
2. 安全前缀机制
| 工具 | 前缀 | 说明 |
|---|---|---|
| Vite | VITE_ | 只有 VITE_ 前缀的变量才暴露给前端代码 |
| CRA (Webpack) | REACT_APP_ | 只有 REACT_APP_ 前缀的变量才暴露 |
| Next.js | NEXT_PUBLIC_ | 只有 NEXT_PUBLIC_ 前缀的变量才暴露 |
3. 替换时机对比
// 源代码
if (import.meta.env.DEV) {
enableDevTools();
}
// 生产构建后:整个 if 块被移除(Tree Shaking)
// import.meta.env.DEV 被替换为 false,压缩工具识别为死代码
// 源代码
if (process.env.NODE_ENV === 'development') {
enableDevTools();
}
// 生产构建后:DefinePlugin 替换为字面量
// if ("production" === "development") { enableDevTools(); }
// 压缩工具(Terser)识别为恒假条件,移除整个块
4. 为什么 Vite 不用 process.env?
Vite 在开发模式下使用原生 ESM,不做完整的打包。浏览器没有 process 全局对象。如果使用 process.env,开发模式下直接访问会报 ReferenceError: process is not defined。而 import.meta 是浏览器原生支持的,开发和生产模式行为一致。
Q2: Feature Flags 有哪些使用场景?在前端项目中如何实现灰度发布?
答案:
Feature Flags 的核心价值在于将代码部署和功能发布解耦。常见使用场景包括:
1. 灰度发布(渐进式发布)
先对 1% 用户开放新功能,观察监控指标,逐步扩大到 10% -> 50% -> 100%。如果出现问题,可以立即关闭开关进行回滚,无需重新部署。
2. A/B 测试
通过 Feature Flag 将用户随机分到实验组和对照组,对比不同方案的数据表现(转化率、停留时间等)。
3. 长期功能开关
权限控制、付费功能等需要长期存在的功能开关。例如:只有 VIP 用户才能看到某些功能。
4. Kill Switch
当某个功能出现严重问题时,通过关闭 Feature Flag 立即下线该功能。
灰度发布的实现要点:
interface GrayReleaseRule {
percentage: number; // 灰度比例 0-100
whitelist: string[]; // 白名单用户
blacklist: string[]; // 黑名单用户
conditions: {
// 条件规则
field: string; // 如 'region', 'platform', 'version'
operator: 'eq' | 'neq' | 'in' | 'gt' | 'lt';
value: string | string[] | number;
}[];
}
function shouldEnableForUser(
rule: GrayReleaseRule,
userId: string,
userProps: Record<string, string | number>
): boolean {
// 1. 黑名单直接拒绝
if (rule.blacklist.includes(userId)) return false;
// 2. 白名单直接通过
if (rule.whitelist.includes(userId)) return true;
// 3. 条件规则匹配
const conditionsMet = rule.conditions.every((cond) => {
const actual = userProps[cond.field];
switch (cond.operator) {
case 'eq':
return actual === cond.value;
case 'neq':
return actual !== cond.value;
case 'in':
return (cond.value as string[]).includes(String(actual));
case 'gt':
return Number(actual) > Number(cond.value);
case 'lt':
return Number(actual) < Number(cond.value);
default:
return false;
}
});
if (!conditionsMet) return false;
// 4. 灰度百分比(确定性哈希)
const hash = deterministicHash(userId);
return hash % 100 < rule.percentage;
}
// 确定性哈希:确保同一个用户每次结果一致
function deterministicHash(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return Math.abs(hash);
}
灰度发布的哈希必须是确定性的(同一用户 ID 每次计算结果相同),否则用户刷新页面可能看到不同的功能版本,体验非常糟糕。常用的方法是对用户 ID 进行哈希取模。
Q3: 如何保证前端环境变量的安全性?哪些信息不能放在前端环境变量中?
答案:
核心原则:所有注入到前端构建产物中的环境变量,都会暴露给用户。 无论是 VITE_ 前缀还是 REACT_APP_ 前缀的变量,构建后都会以明文形式存在于 JS 文件中,任何人都可以通过 DevTools 或直接阅读源码获取。
绝对不能放在前端的信息:
- 数据库连接串(
DATABASE_URL) - 服务端密钥(
JWT_SECRET、SESSION_SECRET) - 第三方服务的 Secret Key(
AWS_SECRET_ACCESS_KEY、STRIPE_SECRET_KEY) - 内网服务地址(
INTERNAL_API_URL) - OAuth Client Secret
可以放在前端的信息:
- 公开的 API 地址
- 公开的第三方 Key(如 Google Maps API Key,但需配合域名白名单、配额限制)
- CDN 地址
- 应用版本号
- 功能开关标识
安全防护策略:
| 策略 | 说明 | 示例 |
|---|---|---|
| 前缀过滤 | 只有特定前缀变量暴露给前端 | VITE_、REACT_APP_ |
.gitignore | 包含真实密钥的文件不提交 | .env.local、.env.*.local |
| CI/CD 注入 | 敏感变量通过 CI/CD 平台的 Secret 管理 | GitHub Secrets、GitLab CI Variables |
| 后端代理 | 需要密钥的第三方 API 通过后端代理访问 | 前端调后端,后端带密钥调第三方 |
| 密钥轮换 | 定期更换密钥,减少泄露影响 | 每季度更换 API Key |
| 预提交检查 | 在 Git Hook 中扫描敏感信息 | git-secrets、detect-secrets |
// 前端代码:不需要知道第三方 API 密钥
async function getMapData(location: string): Promise<MapData> {
// 调用自己的后端接口
const response = await fetch(
`/api/map?location=${encodeURIComponent(location)}`
);
return response.json();
}
// 后端代码(Node.js):密钥安全地保存在服务端
import express from 'express';
const app = express();
app.get('/api/map', async (req, res) => {
const { location } = req.query;
// 密钥只存在于服务端环境变量中,前端永远看不到
const apiKey = process.env.GOOGLE_MAPS_SECRET_KEY;
const response = await fetch(
`https://maps.googleapis.com/maps/api/geocode/json?address=${location}&key=${apiKey}`
);
const data = await response.json();
res.json(data);
});
相关链接
- Vite 环境变量和模式 - Vite 官方文档
- Webpack DefinePlugin - Webpack 官方文档
- dotenv - dotenv GitHub 仓库
- import.meta - MDN - MDN 文档
- LaunchDarkly - Feature Flag 商业平台
- Unleash - 开源 Feature Flag 平台
- Flagsmith - 开源 Feature Flag 平台
- The Twelve-Factor App - Config - 十二要素应用之配置