跳到主要内容

环境管理与配置

问题

在前端项目中,如何管理不同环境(开发、测试、生产)的配置?环境变量如何安全地注入到前端应用中?Feature Flags 和配置中心在实际项目中如何落地?

答案

环境管理与配置是前端工程化的核心基础设施之一。一个成熟的前端项目通常需要对接多套后端环境(开发、测试、预发布、生产),不同环境下的 API 地址、第三方服务 Key、功能开关等都有所不同。如何优雅、安全地管理这些配置,是每位前端工程师都需要掌握的能力。

1. 环境变量管理

1.1 .env 文件体系

.env 文件是前端项目管理环境变量的标准方式。不同的构建工具对 .env 文件的加载规则略有不同,但基本遵循以下优先级:

.env 文件优先级(以 Vite 为例)
.env                  # 所有环境都会加载
.env.local # 所有环境加载,被 git 忽略
.env.[mode] # 只在指定模式下加载,如 .env.production
.env.[mode].local # 只在指定模式下加载,被 git 忽略
加载优先级

后加载的文件会覆盖先加载的同名变量。即 .env.production 中的变量会覆盖 .env 中的同名变量。.local 后缀的文件优先级最高,且应该被加入 .gitignore

一个典型的 .env 文件体系如下:

.env(通用默认值)
# 应用名称
VITE_APP_TITLE=My App
# 默认日志级别
VITE_LOG_LEVEL=info
.env.development
# 开发环境 API 地址
VITE_API_BASE_URL=http://localhost:3000/api
# 开启调试模式
VITE_DEBUG=true
# Mock 数据开关
VITE_ENABLE_MOCK=true
.env.staging
# 预发布环境 API 地址
VITE_API_BASE_URL=https://staging-api.example.com/api
# 关闭调试
VITE_DEBUG=false
VITE_ENABLE_MOCK=false
.env.production
# 生产环境 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 中。

dotenv 核心原理简化实现
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 文件。如果需要强制覆盖,可以使用 dotenvoverride: 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)或自定义
内置变量MODEBASE_URLDEVPRODSSRNODE_ENV
类型支持可通过 env.d.ts 声明需手动声明
Tree Shaking天然支持(静态替换后死代码消除)支持(替换后压缩时消除)
运行时访问仅暴露 VITE_ 前缀的变量取决于 DefinePlugin 配置
底层标准ESM 标准 import.metaNode.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('开发模式');
}
构建产物(production 模式)
// Vite 构建后的代码(production)
const apiUrl = "https://api.example.com/api";
console.log("production");

// DEV 为 false,整个 if 块会被 Tree Shaking 移除
// if (false) { console.log('开发模式'); }

Webpack 环境变量注入原理:

Webpack 通过 DefinePlugin 实现环境变量注入,本质是全局字符串替换:

webpack.config.ts
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 多环境配置实践

以下是一个完整的多环境配置方案:

src/config/index.ts
// 环境类型定义
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] 文件:

package.json
{
"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 中也可以根据模式进行动态配置:

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 环境变量类型声明

src/env.d.ts
/// <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

src/config/env-validator.ts
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)的类型声明

src/react-app-env.d.ts
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 简单实现

src/feature-flags/index.ts
// 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

src/feature-flags/FeatureFlagProvider.tsx
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}</>;
}

使用方式:

src/App.tsx
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 平台对比

特性LaunchDarklyUnleashFlagsmith自研方案
开源否(商业)是(开源)是(开源)-
灰度发布支持支持支持需自研
A/B 测试内置需扩展支持需自研
SDK 语言全平台多语言多语言自定义
实时更新SSE/WebSocket轮询/WebHook轮询自定义
用户分群强大基础中等需自研
成本低(自托管)开发成本
适用场景大型企业中小型团队中型项目特殊需求

5. 配置中心(动态配置)

与编译时注入的环境变量不同,配置中心支持在运行时动态获取配置,无需重新构建部署。

5.1 运行时配置注入方案

方案一:HTML 模板注入(推荐)

index.html 中通过全局变量注入配置,部署时由 CI/CD 或 Nginx 替换:

index.html
<!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>
src/config/runtime-config.ts
// 运行时配置类型
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;
}

方案二:远程配置接口

src/config/remote-config.ts
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 KeyFeature Flags、公告、灰度
安全性会被打包到产物中可控制暴露范围
可靠性不依赖外部服务依赖配置接口可用性
复杂度中高(需考虑缓存、降级)
最佳实践

实际项目中建议两者结合使用:基础的、变化不频繁的配置(如 API 地址)使用编译时环境变量;需要动态调整的配置(如功能开关、公告信息)使用运行时配置中心。

6. 安全注意事项

环境变量安全是面试中经常被追问的重点。核心原则只有一条:任何注入到前端代码中的值,最终都会暴露给用户。

6.1 哪些信息不能放在前端

注意

即使使用了 VITE_REACT_APP_ 前缀过滤,也只是防止无意中暴露非前缀变量。前缀变量本身仍然会被打包到前端产物中,用户可以通过浏览器 DevTools 或查看 JS 源码看到这些值。因此,敏感信息必须通过后端 API 间接使用,永远不要直接写入前端环境变量。

6.2 安全实践清单

安全检查脚本 scripts/check-env-security.ts
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 配置

确保敏感的环境文件不会被提交到版本控制:

.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.metaESM 标准 的一部分,是浏览器原生支持的语法。Vite 选择 import.meta.env 更符合现代 Web 标准。

2. 安全前缀机制

工具前缀说明
ViteVITE_只有 VITE_ 前缀的变量才暴露给前端代码
CRA (Webpack)REACT_APP_只有 REACT_APP_ 前缀的变量才暴露
Next.jsNEXT_PUBLIC_只有 NEXT_PUBLIC_ 前缀的变量才暴露

3. 替换时机对比

Vite 的替换
// 源代码
if (import.meta.env.DEV) {
enableDevTools();
}

// 生产构建后:整个 if 块被移除(Tree Shaking)
// import.meta.env.DEV 被替换为 false,压缩工具识别为死代码
Webpack 的替换
// 源代码
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_SECRETSESSION_SECRET
  • 第三方服务的 Secret Key(AWS_SECRET_ACCESS_KEYSTRIPE_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-secretsdetect-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);
});

相关链接