类型安全的环境变量
构建类型安全的环境变量管理系统,涵盖字面量类型、映射类型、Zod 运行时校验、多环境配置与生产级最佳实践。
阅读提示:正文以代码和白话为主,不出现类型论公式。进阶文档中若出现
Γ ⊢ e : τ这类记号,第一遍可完全跳过(完整规则见001-HowToReadThisCourse)。
类型安全的环境变量
前置知识
- 类型安全的状态管理:建议先完成前一篇的学习
学习目标
- 掌握「历史动机与背景」的核心机制、典型用法与常见陷阱
- 掌握「形式化定义」的核心机制、典型用法与常见陷阱
- 掌握「理论推导」的核心机制、典型用法与常见陷阱
- 掌握「代码示例」的核心机制、典型用法与常见陷阱
- 掌握「对比分析」的核心机制、典型用法与常见陷阱
历史动机与背景
前端环境变量的起源
2010 年前后,Node.js 借鉴 Unix Shell 的环境变量机制,将 process.env 作为应用配置的标准入口。这一设计简单有效:操作系统提供字符串键值对,应用按需读取。然而,这种”字符串字典”的访问方式天然缺乏类型约束:
process.env.PORT返回string | undefined,但开发者往往需要number。- 拼写错误(
process.env.POTR)在运行时才暴露,编译期无任何提示。 - 布尔值与数字以字符串形式存储(
"true"、"3000"),需要手动转换且易出错。 - 必需变量缺失时,应用可能在启动一段时间后才崩溃,难以快速定位。
12-Factor App 的推动
2011 年,Heroku 联合创始人 Adam Wiggins 发布了 The Twelve-Factor App 方法论,其中第三条明确提出”配置应存储在环境中,而非代码里”。这一原则推动了环境变量在前端的普及,但也暴露了类型安全的迫切需求:
- 配置与代码分离后,配置的正确性只能由运行时保证。
- 不同环境(dev/staging/prod)的配置差异若缺乏类型约束,极易在部署时出错。
- 密钥泄漏事故频发——开发者无意间把
VITE_AWS_SECRET写入前端 bundle,被攻击者从 sourcemap 中提取。
TypeScript 的应对
TypeScript 2.0(2016)引入了 declare global 与接口合并,允许开发者扩展 NodeJS.ProcessEnv:
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
JWT_SECRET: string;
}
}
这一机制让 process.env.DATABASE_URL 在编译期获得 string 类型,但仍未解决运行时校验问题。2018 年 Vite 的出现带来了 import.meta.env 与 VITE_ 前缀约定,进一步推动了前端环境变量的标准化。
运行时校验的崛起
2020 年后,Zod、envalid、dotenv-flow 等库相继流行。它们的核心思想是:用 Schema 同时驱动编译期类型与运行时校验,实现”单一数据源”(Single Source of Truth)。这一范式已成为 2024 年后生产级应用的事实标准。
形式化定义
环境变量的本质
设 为所有可能的环境变量键值对的全集:
应用启动时,操作系统向进程注入一个有限的子集 。环境变量管理的本质是构造一个映射:
其中 是应用的强类型配置对象。该映射必须满足以下性质:
- 完备性:所有必需字段在 中存在,否则 抛出异常。
- 类型正确性:字符串值被正确转换为 中声明的类型(
number、boolean、URL等)。 - 安全性:敏感字段不被泄漏到不可信环境(如前端 bundle)。
类型声明的形式化
TypeScript 通过接口合并扩展 ProcessEnv。设 为 ProcessEnv 的内置定义,开发者声明 ,合并后的类型为:
这是**交集类型(Intersection Type)**在声明合并中的应用。访问 process.env[k] 时,编译器查找 在 中的属性类型。若 ,则报错 Property 'k' does not exist on type 'ProcessEnv'。
运行时校验的形式化
设 为 Zod Schema,( 为目标类型)。校验函数 parse 的语义:
而 safeParse 返回 tagged union:
关键性质:,即编译期类型由 Schema 自动推导,保证”类型与校验逻辑始终一致”。
多环境配置的形式化
设 为环境集合,每个环境 对应配置 。多环境配置是一个函数:
通常实现为查找表 Record<Environment, Config>。关键约束:所有 必须类型相同(共享接口 ),仅值不同。这保证了应用代码在不同环境下行为一致。
理论推导
推导一:为何 process.env 是字符串字典
Node.js 的 process.env 实质上是一个对操作系统 environ 变量的包装。Unix 与 Windows 的环境变量都以字符串形式存储——操作系统不区分数字、布尔、对象,只有”字符串键 → 字符串值”。
形式化地,process.env 的类型为 NodeJS.ProcessEnv,其内置定义为 Record<string, string | undefined>。这意味着:
- 访问任意键都返回
string | undefined。 - 即使开发者通过
declare global扩展了DATABASE_URL: string,运行时仍可能是undefined(变量未设置)。 - 编译期类型与运行时实际值之间存在”信任鸿沟”。
这一鸿沟只能通过运行时校验弥合——这是 Zod 等库存在的根本理由。
推导二:接口合并的代数性质
TypeScript 的接口合并满足交换律与结合律:
这意味着多个 .d.ts 文件可以分散声明 ProcessEnv 的不同字段,最终合并为一个完整类型。例如:
// env.database.d.ts
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
DB_POOL_SIZE: string;
}
}
// env.auth.d.ts
declare namespace NodeJS {
interface ProcessEnv {
JWT_SECRET: string;
JWT_EXPIRES_IN: string;
}
}
合并后,process.env.DATABASE_URL 与 process.env.JWT_SECRET 都有正确类型。这一性质支持了大型项目的环境变量模块化组织。
推导三:Zod 的类型推导链
Zod 的 z.object({...}) 返回一个 ZodObject,其 infer 类型通过条件类型与映射类型推导:
type Infer<T extends ZodType> = T extends ZodType<infer U> ? U : never;
对于 z.object({ PORT: z.coerce.number() }),推导过程为:
z.coerce.number()返回ZodNumber,其output类型为number。z.object({...})将每个字段的output收集为一个对象类型。Infer<Schema>返回{ PORT: number }。
这一推导链保证了”Schema 即类型”——修改 Schema 自动同步类型,杜绝了”声明与校验不一致”的 bug。
复杂度分析
环境变量校验的复杂度分析:
- 声明扩展:,接口合并是编译期的静态操作。
- Zod 校验:,其中 为字段数。每个字段的校验是常数时间(字符串、数字、URL 等校验器均为 )。
- 默认值填充:,遍历所有字段。
- 错误聚合:,其中 为错误数,通常 。
总体而言,环境变量校验在应用启动时执行一次,性能影响可忽略。即便有 100 个字段,校验耗时通常在 1ms 以内。
代码示例
示例 1:基础类型声明
// env.d.ts - 环境变量类型声明文件
// 通过接口合并扩展 NodeJS.ProcessEnv,使编译器识别自定义环境变量
declare namespace NodeJS {
interface ProcessEnv {
// 必需变量:未设置时类型仍为 string,但运行时校验会捕获
readonly NODE_ENV: 'development' | 'production' | 'test';
readonly DATABASE_URL: string;
readonly JWT_SECRET: string;
// 可选变量:明确标记为可能 undefined
readonly PORT?: string;
readonly REDIS_URL?: string;
readonly LOG_LEVEL?: 'debug' | 'info' | 'warn' | 'error';
// 布尔值与数字以字符串形式存储
readonly DEBUG?: string; // 'true' | 'false'
readonly DB_POOL_SIZE?: string; // 数字字符串
readonly ENABLE_ANALYTICS?: string;
}
}
// 现在访问环境变量会获得类型提示
// process.env.DATABASE_URL -> string
// process.env.PORT -> string | undefined
// process.env.UNKNOWN_VAR -> 编译错误
示例 2:类型安全的访问器函数
// env.ts - 类型安全的访问器封装
// 提供按类型读取环境变量的工具函数,统一处理转换与默认值
/**
* 读取必需的字符串环境变量
* @param key - 环境变量名(必须是 ProcessEnv 的键)
* @returns 环境变量值
* @throws Error - 变量未设置时抛出
*/
function getEnv(key: keyof NodeJS.ProcessEnv): string {
const value = process.env[key];
if (value === undefined) {
throw new Error(`[env] 必需的环境变量 ${key} 未设置`);
}
return value;
}
/**
* 读取可选字符串环境变量,提供默认值
* @param key - 环境变量名
* @param defaultValue - 默认值
*/
function getOptionalEnv(
key: keyof NodeJS.ProcessEnv,
defaultValue: string,
): string {
return process.env[key] ?? defaultValue;
}
/**
* 读取布尔值环境变量
* 接受 'true'/'1'/'yes' 为真,'false'/'0'/'no' 为假
* @param key - 环境变量名
* @param defaultValue - 默认值
*/
function getBooleanEnv(
key: keyof NodeJS.ProcessEnv,
defaultValue = false,
): boolean {
const value = process.env[key];
if (value === undefined) return defaultValue;
return ['true', '1', 'yes'].includes(value.toLowerCase());
}
/**
* 读取数字环境变量
* @param key - 环境变量名
* @param defaultValue - 默认值
* @throws Error - 值不是有效数字时抛出
*/
function getNumberEnv(
key: keyof NodeJS.ProcessEnv,
defaultValue: number,
): number {
const value = process.env[key];
if (value === undefined || value === '') return defaultValue;
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new Error(`[env] 环境变量 ${key} 不是有效数字: ${value}`);
}
return parsed;
}
/**
* 读取 JSON 格式的环境变量
* @param key - 环境变量名
* @param defaultValue - 默认值
*/
function getJsonEnv<T>(key: keyof NodeJS.ProcessEnv, defaultValue: T): T {
const value = process.env[key];
if (value === undefined || value === '') return defaultValue;
try {
return JSON.parse(value) as T;
} catch {
throw new Error(`[env] 环境变量 ${key} 不是有效 JSON: ${value}`);
}
}
// 使用示例
const port = getNumberEnv('PORT', 3000); // number
const debug = getBooleanEnv('DEBUG', false); // boolean
const dbUrl = getEnv('DATABASE_URL'); // string,未设置时抛错
const redisUrl = getOptionalEnv('REDIS_URL', ''); // string
示例 3:构建配置对象
// config.ts - 从环境变量构建强类型配置对象
// 将分散的环境变量聚合为一个结构化的配置对象,供应用全局使用
interface AppConfig {
env: 'development' | 'production' | 'test';
port: number;
database: {
url: string;
poolSize: number;
ssl: boolean;
};
jwt: {
secret: string;
expiresIn: string;
};
redis: {
url: string | null;
ttl: number;
};
logging: {
level: 'debug' | 'info' | 'warn' | 'error';
pretty: boolean;
};
features: {
debug: boolean;
analytics: boolean;
sentry: boolean;
};
}
/**
* 从环境变量加载应用配置
* 集中处理所有类型转换与默认值
*/
function loadConfig(): AppConfig {
return {
env: (process.env.NODE_ENV as AppConfig['env']) ?? 'development',
port: getNumberEnv('PORT', 3000),
database: {
url: getEnv('DATABASE_URL'),
poolSize: getNumberEnv('DB_POOL_SIZE', 10),
ssl: getBooleanEnv('DB_SSL', false),
},
jwt: {
secret: getEnv('JWT_SECRET'),
expiresIn: getOptionalEnv('JWT_EXPIRES_IN', '7d'),
},
redis: {
url: process.env.REDIS_URL ?? null,
ttl: getNumberEnv('REDIS_TTL', 3600),
},
logging: {
level: (process.env.LOG_LEVEL as AppConfig['logging']['level']) ?? 'info',
pretty: getBooleanEnv('LOG_PRETTY', false),
},
features: {
debug: getBooleanEnv('DEBUG', false),
analytics: getBooleanEnv('ENABLE_ANALYTICS', false),
sentry: getBooleanEnv('ENABLE_SENTRY', false),
},
};
}
// 全局单例:应用启动时加载一次
const config = loadConfig();
export { config, type AppConfig };
示例 4:Zod 运行时校验
// env.schema.ts - 使用 Zod 定义环境变量 Schema
// 同时获得编译期类型与运行时校验,单一数据源
import { z } from 'zod';
// 定义环境变量 Schema
// z.coerce 自动将字符串转换为目标类型
const envSchema = z.object({
// 枚举类型:限定取值范围
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
// 数字类型:字符串自动转换
PORT: z.coerce.number().int().positive().max(65535).default(3000),
// URL 类型:必须是合法 URL
DATABASE_URL: z.string().url(),
// 字符串长度约束:密钥至少 32 字符
JWT_SECRET: z
.string()
.min(32, 'JWT_SECRET 至少需要 32 个字符')
.max(256, 'JWT_SECRET 不应超过 256 个字符'),
// 可选 URL
REDIS_URL: z.string().url().optional(),
// 布尔值:接受 'true'/'false'/'1'/'0'
DEBUG: z.coerce.boolean().default(false),
// 枚举日志级别
LOG_LEVEL: z
.enum(['debug', 'info', 'warn', 'error'])
.default('info'),
// 数字范围约束
DB_POOL_SIZE: z.coerce.number().int().positive().max(100).default(10),
// 正则匹配
SENTRY_DSN: z
.string()
.regex(/^https:\/\/.+@.+\.\d+$/, 'SENTRY_DSN 格式不正确')
.optional(),
});
// 自动推导类型:Schema 即类型
type Env = z.infer<typeof envSchema>;
/**
* 解析环境变量
* 校验失败时抛出 ZodError,包含详细错误信息
*/
function parseEnv(): Env {
return envSchema.parse(process.env);
}
/**
* 安全解析环境变量
* 返回 tagged union,不抛出异常
*/
function safeParseEnv():
| { success: true; data: Env }
| { success: false; error: z.ZodError } {
return envSchema.safeParse(process.env);
}
// 应用启动时校验
const envResult = safeParseEnv();
if (!envResult.success) {
console.error('[env] 环境变量校验失败:');
for (const issue of envResult.error.issues) {
console.error(` - ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
const env = envResult.data;
export { env, type Env, envSchema };
示例 5:Vite 前端环境变量
// vite-env.d.ts - Vite 前端环境变量声明
/// <reference types="vite/client" />
// 扩展 ImportMetaEnv,声明应用使用的 VITE_ 前缀变量
interface ImportMetaEnv {
// API 配置
readonly VITE_API_URL: string;
readonly VITE_API_TIMEOUT: string; // 数字字符串
// 应用元信息
readonly VITE_APP_TITLE: string;
readonly VITE_APP_VERSION: string;
// 功能开关
readonly VITE_ENABLE_ANALYTICS?: string;
readonly VITE_ENABLE_SENTRY?: string;
// 构建信息
readonly VITE_BUILD_TIME?: string;
readonly VITE_GIT_COMMIT?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// vite.config.ts 中类型安全地使用环境变量
import { defineConfig, loadEnv } from 'vite';
import type { UserConfig } from 'vite';
export default defineConfig(({ mode }) => {
// 加载 .env 文件
const env = loadEnv(mode, process.cwd(), '');
// 类型安全访问
const apiUrl = env.VITE_API_URL;
if (!apiUrl) {
throw new Error('VITE_API_URL 未设置');
}
return {
define: {
// 将环境变量注入到应用代码中
'import.meta.env.VITE_APP_VERSION': JSON.stringify(
env.VITE_APP_VERSION || '0.0.0',
),
},
server: {
port: Number(env.VITE_DEV_PORT) || 5173,
proxy: {
'/api': {
target: apiUrl,
changeOrigin: true,
},
},
},
} satisfies UserConfig;
});
示例 6:运行时注入的环境变量
// runtime-env.ts - 运行时环境变量注入方案
// 适用于需要在构建后动态切换配置的场景(如 Docker 镜像复用)
interface RuntimeEnv {
API_URL: string;
WS_URL: string;
SENTRY_DSN: string;
VERSION: string;
BUILD_TIME: string;
}
// 通过 window.__ENV__ 注入运行时环境变量
declare global {
interface Window {
__ENV__?: Partial<RuntimeEnv>;
}
}
/**
* 加载运行时环境变量
* 优先从 window.__ENV__ 读取,回退到构建时变量
*/
function loadRuntimeEnv(): RuntimeEnv {
const injected = window.__ENV__ ?? {};
// 校验必需字段
const required: (keyof RuntimeEnv)[] = ['API_URL', 'VERSION'];
const missing = required.filter((key) => !injected[key]);
if (missing.length > 0) {
throw new Error(
`[runtime-env] 以下运行时变量未注入: ${missing.join(', ')}`,
);
}
return {
API_URL: injected.API_URL!,
WS_URL: injected.WS_URL ?? '',
SENTRY_DSN: injected.SENTRY_DSN ?? '',
VERSION: injected.VERSION!,
BUILD_TIME: injected.BUILD_TIME ?? '',
};
}
// 在 index.html 中通过脚本注入
// <script>
// window.__ENV__ = {
// API_URL: '<%= process.env.API_URL %>',
// VERSION: '<%= process.env.npm_package_version %>',
// };
// </script>
const runtimeEnv = loadRuntimeEnv();
export { runtimeEnv, type RuntimeEnv };
示例 7:多环境配置管理
// environments.ts - 多环境配置集中管理
// 每个环境的配置共享同一类型,保证应用行为一致
type Environment = 'development' | 'staging' | 'production';
interface EnvironmentConfig {
apiUrl: string;
cdnUrl: string;
debug: boolean;
logLevel: 'debug' | 'info' | 'warn' | 'error';
enableAnalytics: boolean;
enableSentry: boolean;
sentryDsn: string | null;
sentryTracesSampleRate: number;
featureFlags: {
newDashboard: boolean;
aiAssistant: boolean;
experimentalAPI: boolean;
};
}
// 配置查找表:每个环境一份完整配置
const configs: Record<Environment, EnvironmentConfig> = {
development: {
apiUrl: 'http://localhost:3000',
cdnUrl: 'http://localhost:8080',
debug: true,
logLevel: 'debug',
enableAnalytics: false,
enableSentry: false,
sentryDsn: null,
sentryTracesSampleRate: 1.0,
featureFlags: {
newDashboard: true,
aiAssistant: true,
experimentalAPI: true,
},
},
staging: {
apiUrl: 'https://staging-api.example.com',
cdnUrl: 'https://staging-cdn.example.com',
debug: false,
logLevel: 'info',
enableAnalytics: true,
enableSentry: true,
sentryDsn: 'https://staging@sentry.io/123',
sentryTracesSampleRate: 0.5,
featureFlags: {
newDashboard: true,
aiAssistant: false,
experimentalAPI: true,
},
},
production: {
apiUrl: 'https://api.example.com',
cdnUrl: 'https://cdn.example.com',
debug: false,
logLevel: 'warn',
enableAnalytics: true,
enableSentry: true,
sentryDsn: 'https://prod@sentry.io/456',
sentryTracesSampleRate: 0.1,
featureFlags: {
newDashboard: false,
aiAssistant: false,
experimentalAPI: false,
},
},
};
/**
* 获取当前环境配置
* 环境由 NODE_ENV 决定,默认 development
*/
function getEnvironmentConfig(): EnvironmentConfig {
const env = (process.env.NODE_ENV ?? 'development') as Environment;
if (!(env in configs)) {
throw new Error(`[env] 未知环境: ${env}`);
}
return configs[env];
}
const envConfig = getEnvironmentConfig();
export { envConfig, type EnvironmentConfig, type Environment };
示例 8:Next.js 环境变量
// next.env.d.ts - Next.js 环境变量声明
// Next.js 区分服务端与客户端环境变量
// 服务端环境变量(无 NEXT_PUBLIC_ 前缀)
declare namespace NodeJS {
interface ProcessEnv {
// 数据库
DATABASE_URL: string;
DIRECT_URL: string; // Prisma 直连 URL
// 认证
NEXTAUTH_SECRET: string;
NEXTAUTH_URL: string;
// 第三方服务(服务端密钥)
GITHUB_CLIENT_SECRET: string;
GOOGLE_CLIENT_SECRET: string;
STRIPE_SECRET_KEY: string;
// 可选
REDIS_URL?: string;
SENTRY_SERVER_DSN?: string;
}
}
// 客户端环境变量(NEXT_PUBLIC_ 前缀)
interface NextPublicEnv {
readonly NEXT_PUBLIC_API_URL: string;
readonly NEXT_PUBLIC_APP_NAME: string;
readonly NEXT_PUBLIC_GA_ID?: string;
readonly NEXT_PUBLIC_SENTRY_DSN?: string;
readonly NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: string;
}
// 类型安全的客户端环境变量访问
function getClientEnv(): NextPublicEnv {
const env = process.env;
return {
NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL!,
NEXT_PUBLIC_APP_NAME: env.NEXT_PUBLIC_APP_NAME!,
NEXT_PUBLIC_GA_ID: env.NEXT_PUBLIC_GA_ID,
NEXT_PUBLIC_SENTRY_DSN: env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
};
}
// 使用:服务端组件中可直接访问 process.env
// 客户端组件中使用 getClientEnv()
示例 9:环境变量文档自动生成
// env-doc-generator.ts - 从 Schema 自动生成环境变量文档
// 解决文档与实际配置不同步的问题
import { z } from 'zod';
interface EnvDocEntry {
name: string;
type: string;
required: boolean;
default: string | null;
description: string;
example: string;
deprecated: boolean;
}
/**
* 从 Zod Schema 提取环境变量元信息
* 用于生成 .env.example 与 Markdown 文档
*/
function extractEnvDoc(schema: z.ZodObject<z.ZodRawShape>): EnvDocEntry[] {
const entries: EnvDocEntry[] = [];
for (const [name, field] of Object.entries(schema.shape)) {
// 处理 ZodEffects(如 z.coerce.number())
let inner = field;
while (inner instanceof z.ZodEffects) {
inner = inner.innerType();
}
// 判断是否可选
const isOptional = inner instanceof z.ZodOptional;
const actual = isOptional ? (inner as z.ZodOptional<any>).unwrap() : inner;
// 提取类型名
const typeName = actual.constructor.name
.replace('Zod', '')
.toLowerCase();
// 提取默认值
const hasDefault = '_def' in actual && 'defaultValue' in actual._def;
const defaultValue = hasDefault
? JSON.stringify(actual._def.defaultValue())
: null;
entries.push({
name,
type: typeName,
required: !isOptional && !hasDefault,
default: defaultValue,
description: actual.description ?? '',
example: getExampleValue(typeName),
deprecated: false,
});
}
return entries;
}
function getExampleValue(type: string): string {
switch (type) {
case 'number': return '3000';
case 'boolean': return 'true';
case 'string': return 'example-value';
default: return '';
}
}
/**
* 生成 .env.example 文件内容
*/
function generateEnvExample(entries: EnvDocEntry[]): string {
const lines = ['# 环境变量示例文件', '# 复制此文件为 .env 并填入实际值', ''];
for (const entry of entries) {
lines.push(`# ${entry.description || entry.name}`);
lines.push(`# 类型: ${entry.type}, 必需: ${entry.required}`);
if (entry.default) {
lines.push(`# 默认值: ${entry.default}`);
}
lines.push(`${entry.name}=${entry.example}`);
lines.push('');
}
return lines.join('\n');
}
/**
* 生成 Markdown 文档
*/
function generateMarkdownDoc(entries: EnvDocEntry[]): string {
const lines = [
'# 环境变量文档',
'',
'| 变量名 | 类型 | 必需 | 默认值 | 说明 |',
'|--------|------|------|--------|------|',
];
for (const entry of entries) {
lines.push(
`| \`${entry.name}\` | ${entry.type} | ${entry.required ? '是' : '否'} | ${entry.default ?? '-'} | ${entry.description} |`,
);
}
return lines.join('\n');
}
对比分析
方案对比:声明式 vs. Zod vs. envalid
| 维度 | 纯声明式(.d.ts) | Zod Schema | envalid |
|---|---|---|---|
| 编译期类型 | 优秀 | 优秀(自动推导) | 良好 |
| 运行时校验 | 无 | 完整 | 完整 |
| 默认值 | 手动 | 内置 | 内置 |
| 错误信息 | 无 | 详细(path + message) | 详细 |
| 包大小 | 0 KB | ~12 KB | ~5 KB |
| 学习曲线 | 低 | 中 | 低 |
| 生态集成 | 原生 | 极广(tRPC、React Hook Form) | 较窄 |
| Schema 复用 | 否 | 是(前端表单共用) | 否 |
| 类型推导 | 手动 | 自动 | 自动 |
| 推荐场景 | 小型项目、原型 | 中大型项目 | 轻量后端 |
构建时 vs. 运行时注入
| 维度 | 构建时替换(Vite/webpack) | 运行时注入(window.ENV) |
|---|---|---|
| 灵活性 | 低(每个环境单独构建) | 高(一份构建多环境部署) |
| 性能 | 极佳(值内联到代码) | 略差(多一次 window 读取) |
| Docker 友好 | 否(需多阶段构建) | 是(镜像复用) |
| 调试便利 | 高(值在代码中可见) | 中(需查看注入脚本) |
| 缓存影响 | 改 env 需重新构建 | 不影响构建缓存 |
| 推荐场景 | 静态站点、CDN 部署 | 容器化部署、多租户 |
Vite vs. Next.js 环境变量约定
| 维度 | Vite | Next.js |
|---|---|---|
| 前缀约定 | VITE_ | NEXT_PUBLIC_ |
| 服务端变量 | 不支持(纯前端) | 支持(无前缀) |
| 客户端变量 | import.meta.env | process.env(构建时替换) |
| 运行时注入 | 需手动 | 需手动 |
| 类型声明 | ImportMetaEnv | NodeJS.ProcessEnv |
| .env 加载顺序 | .env, .env.local, .env.[mode] | .env.local, .env.[development/production], .env |
常见陷阱与反模式
陷阱 1:前端泄漏密钥
反模式:在前端代码中直接访问服务端密钥。
// 错误:VITE_ 前缀的变量会被打包到前端 bundle
// .env
// VITE_AWS_SECRET=AKIAIOSFODNN7EXAMPLE
// 代码
const awsSecret = import.meta.env.VITE_AWS_SECRET;
// 此值会出现在构建产物中,任何人都能从浏览器 DevTools 看到
正确做法:服务端密钥不使用 VITE_ 前缀,通过 BFF(Backend for Frontend)代理访问。
// .env(仅服务端可见)
// AWS_SECRET=AKIAIOSFODNN7EXAMPLE
// 前端通过 API 调用,不直接持有密钥
const data = await fetch('/api/upload', { method: 'POST' });
陷阱 2:默认值掩盖配置缺失
反模式:为必需变量提供默认值,导致生产环境配置缺失时不报错。
// 错误:生产环境的 JWT_SECRET 不应有默认值
const jwtSecret = getOptionalEnv('JWT_SECRET', 'default-secret');
// 若生产环境忘记设置 JWT_SECRET,应用会用 'default-secret' 启动
// 攻击者知道默认值后可伪造 token
正确做法:安全相关变量必需,不提供默认值。
const jwtSecret = getEnv('JWT_SECRET'); // 未设置时抛错,应用拒绝启动
陷阱 3:类型声明与运行时不一致
反模式:声明文件说变量是 string,但运行时可能是 undefined。
// env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string; // 声明为 string
}
}
// 代码
const dbUrl = process.env.DATABASE_URL;
// TypeScript 认为是 string,但运行时可能是 undefined
await connect(dbUrl); // 若未设置,运行时崩溃
正确做法:必需变量用 Zod 校验,可选变量声明为 string | undefined。
// 用 Zod 校验
const env = envSchema.parse(process.env);
const dbUrl = env.DATABASE_URL; // 校验通过后必定是 string
陷阱 4:布尔值解析不严谨
反模式:简单的 === 'true' 判断,遗漏边界情况。
// 错误:遗漏 '1'、'yes'、大小写
function getBoolean(key: string): boolean {
return process.env[key] === 'true'; // 'TRUE' 会返回 false
}
正确做法:使用统一的布尔解析函数。
function getBooleanEnv(key: string, defaultValue = false): boolean {
const value = process.env[key]?.toLowerCase();
if (value === undefined) return defaultValue;
return ['true', '1', 'yes', 'on'].includes(value);
}
陷阱 5:数字解析的 NaN 陷阱
反模式:用 parseInt 不检查 NaN。
// 错误:parseInt('abc') 返回 NaN,但 TypeScript 类型是 number
const port = parseInt(process.env.PORT || '3000', 10);
// 若 PORT='abc',port 是 NaN,后续 new Server(port) 行为异常
正确做法:显式检查 NaN。
function getNumberEnv(key: string, defaultValue: number): number {
const value = process.env[key];
if (value === undefined || value === '') return defaultValue;
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new Error(`环境变量 ${key} 不是有效数字: ${value}`);
}
return parsed;
}
陷阱 6:.env 文件提交到版本控制
反模式:把包含真实密钥的 .env 提交到 Git。
# 错误:.env 包含生产密钥
git add .env
git commit -m "add env"
# 密钥永久留在 Git 历史中,即使后续删除也可被恢复
正确做法:
.gitignore中加入.env、.env.local、.env.*.local。- 提交
.env.example作为模板。 - 真实密钥通过 CI/CD Secrets 或密钥管理服务(AWS Secrets Manager、HashiCorp Vault)注入。
陷阱 7:日志泄漏敏感变量
反模式:调试时打印整个 process.env。
// 错误:日志会包含所有环境变量,包括密钥
console.log('环境变量:', process.env);
// 日志聚合系统(如 ELK)会永久存储,存在泄漏风险
正确做法:打印脱敏后的配置。
function maskSecret(value: string): string {
if (value.length <= 8) return '****';
return value.slice(0, 4) + '****' + value.slice(-4);
}
function getSafeConfigLog(config: AppConfig): object {
return {
...config,
jwt: { ...config.jwt, secret: maskSecret(config.jwt.secret) },
database: {
...config.database,
url: config.database.url.replace(/\/\/.*@/, '//****@'),
},
};
}
console.log('应用配置:', getSafeConfigLog(config));
// 输出:jwt.secret: "abcd****wxyz",database.url: "postgres://****@db.example.com"
陷阱 8:异步加载环境变量
反模式:在模块顶层使用 await 加载环境变量。
// 错误:顶层 await 限制模块使用
const env = await loadEnvFromRemote(); // 从远程配置中心拉取
export const config = buildConfig(env);
// 依赖此模块的代码必须支持顶层 await,兼容性差
正确做法:提供同步访问接口,异步初始化在应用入口完成。
// config.ts
let _config: AppConfig | null = null;
export function getConfig(): AppConfig {
if (_config === null) {
throw new Error('配置未初始化,请先调用 initConfig()');
}
return _config;
}
export async function initConfig(): Promise<void> {
const remoteEnv = await loadEnvFromRemote();
_config = buildConfig({ ...process.env, ...remoteEnv });
}
// 应用入口
async function main() {
await initConfig();
startServer();
}
main();
工程实践
实践 1:分层架构组织
生产级项目应将环境变量管理分为三层:
flowchart TD
T0["src/"]
T1["config/"]
T2["env.schema.ts # Zod Schema 定义(单一数据源)"]
T3["env.ts # 校验与加载逻辑"]
T4["config.ts # 应用配置对象"]
T5["environments.ts # 多环境配置"]
T6["types/"]
T7["env.d.ts # 类型声明(如不用 Zod)"]
T8["env/ # .env 文件目录(不入版本控制)"]
T9[".env # 通用配置"]
T10[".env.development # 开发环境"]
T11[".env.staging # 预发环境"]
T12[".env.production # 生产环境"]
T13[".env.example # 模板(入版本控制)"]
T0 --> T1
T5 --> T6
T7 --> T8
T8 --> T9
T8 --> T10
T8 --> T11
T8 --> T12
T8 --> T13
实践 2:CI/CD 中校验环境变量
在部署前校验环境变量,避免运行时崩溃:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Validate environment variables
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
NODE_ENV: production
run: npx tsx scripts/validate-env.ts
- name: Deploy
run: npm run deploy
typescript
// scripts/validate-env.ts - CI 环境变量校验脚本
import { envSchema } from '../src/config/env.schema';
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('环境变量校验失败:');
for (const issue of result.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
console.log('环境变量校验通过');
实践 3:密钥轮换支持
设计环境变量结构时,预留密钥轮换的支持:
const envSchema = z.object({
// 当前密钥
JWT_SECRET: z.string().min(32),
// 旧密钥(轮换期间双密钥共存)
JWT_SECRET_PREVIOUS: z.string().min(32).optional(),
});
// 认证逻辑支持双密钥验证
function verifyToken(token: string): JWTPayload {
try {
return jwt.verify(token, env.JWT_SECRET);
} catch {
if (env.JWT_SECRET_PREVIOUS) {
return jwt.verify(token, env.JWT_SECRET_PREVIOUS);
}
throw new Error('Invalid token');
}
}
实践 4:开发环境热重载
利用 chokidar 监听 .env 文件变化,自动重载配置:
import chokidar from 'chokidar';
import { envSchema } from './env.schema';
let config = envSchema.parse(process.env);
if (process.env.NODE_ENV === 'development') {
chokidar.watch('.env').on('change', () => {
console.log('[env] 检测到 .env 变化,重新加载');
// 重新加载 dotenv
require('dotenv').config({ override: true });
try {
config = envSchema.parse(process.env);
console.log('[env] 配置重载成功');
} catch (error) {
console.error('[env] 配置重载失败:', error);
}
});
}
export function getConfig() {
return config;
}
实践 5:Monorepo 共享配置
在 pnpm Monorepo 中,通过共享 package 统一环境变量管理:
flowchart TD
T0["packages/"]
T1["shared-config/ # 共享配置包"]
T2["src/"]
T3["env.schema.ts # 统一 Schema"]
T4["index.ts"]
T5["package.json"]
T6["web-app/ # 前端应用"]
T7["api-server/ # 后端服务"]
T8["worker/ # 后台任务"]
T0 --> T1
T5 --> T6
T5 --> T7
T5 --> T8
typescript
// packages/shared-config/src/env.schema.ts
import { z } from 'zod';
export const baseEnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export const webEnvSchema = baseEnvSchema.extend({
VITE_API_URL: z.string().url(),
});
export const apiEnvSchema = baseEnvSchema.extend({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export type WebEnv = z.infer<typeof webEnvSchema>;
export type ApiEnv = z.infer<typeof apiEnvSchema>;
实践 6:性能优化——延迟加载
对于体积较大的环境变量(如 JSON 配置),使用懒加载避免启动开销:
// 懒加载大型配置
let _featureFlags: FeatureFlags | null = null;
function getFeatureFlags(): FeatureFlags {
if (_featureFlags === null) {
const json = process.env.FEATURE_FLAGS_JSON;
_featureFlags = json ? JSON.parse(json) : defaultFlags;
}
return _featureFlags;
}
// 首次访问时才解析,避免冷启动延迟
案例研究
案例一:Vite + React 前端项目
背景:某 SaaS 前端项目使用 Vite + React + TypeScript,需支持 dev/staging/prod 三环境。
挑战:
- 前端 bundle 不应包含服务端密钥。
- 不同环境的 API 地址、CDN 地址、Sentry DSN 不同。
- 需要支持 Docker 镜像复用(运行时注入)。
方案:
// src/config/env.ts
import { z } from 'zod';
// 构建时变量(VITE_ 前缀)
const buildEnvSchema = z.object({
VITE_API_URL: z.string().url(),
VITE_CDN_URL: z.string().url(),
VITE_APP_VERSION: z.string(),
VITE_SENTRY_DSN: z.string().optional(),
});
// 运行时注入变量(window.__ENV__)
declare global {
interface Window {
__ENV__?: Record<string, string>;
}
}
type BuildEnv = z.infer<typeof buildEnvSchema>;
interface AppEnv {
apiUrl: string;
cdnUrl: string;
version: string;
sentryDsn: string;
// 运行时注入的额外配置
featureFlags: Record<string, boolean>;
}
function loadEnv(): AppEnv {
// 1. 校验构建时变量
const buildEnv = buildEnvSchema.parse(import.meta.env);
// 2. 合并运行时注入变量
const runtimeEnv = window.__ENV__ ?? {};
return {
apiUrl: runtimeEnv.API_URL ?? buildEnv.VITE_API_URL,
cdnUrl: runtimeEnv.CDN_URL ?? buildEnv.VITE_CDN_URL,
version: buildEnv.VITE_APP_VERSION,
sentryDsn: runtimeEnv.SENTRY_DSN ?? buildEnv.VITE_SENTRY_DSN ?? '',
featureFlags: JSON.parse(runtimeEnv.FEATURE_FLAGS ?? '{}'),
};
}
export const env = loadEnv();
部署策略:
- 开发环境:使用
.env.development,Vite 自动加载。 - 生产环境:Docker 镜像构建时用占位值,启动脚本通过
sed替换为真实值。
# Dockerfile
FROM node:20-alpine as builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# 启动时注入环境变量
COPY docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
bash
#!/bin/sh
# docker-entrypoint.sh
# 将运行时环境变量注入到 index.html
envsubst < /usr/share/nginx/html/env.template.js > /usr/share/nginx/html/env.js
exec "$@"
效果:
- 一份 Docker 镜像可在 dev/staging/prod 复用。
- 密钥不进入 bundle,安全性提升。
- 配置变更无需重新构建,部署时间从 10 分钟降至 30 秒。
案例二:Next.js 全栈应用
背景:某电商平台使用 Next.js App Router,前端需要 Stripe 公钥,后端需要 Stripe 私钥。
方案:
// src/config/stripe.ts
import Stripe from 'stripe';
// 客户端配置(公钥,可暴露到前端)
const publicStripeSchema = z.object({
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
});
// 服务端配置(私钥,绝不暴露)
const privateStripeSchema = z.object({
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
});
// 服务端组件中
export function getStripeServer(): Stripe {
const env = privateStripeSchema.parse(process.env);
return new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
});
}
// 客户端组件中
export function getStripeClient() {
const env = publicStripeSchema.parse(process.env);
return env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
}
关键决策:
- 公钥用
NEXT_PUBLIC_前缀,构建时注入。 - 私钥无前缀,仅在服务端组件/API 路由中可访问。
- Webhook 签名密钥独立配置,防止与 API 密钥混淆。
踩坑记录:早期版本误将 STRIPE_SECRET_KEY 加了 NEXT_PUBLIC_ 前缀,导致私钥泄漏到客户端 bundle。后引入 CI 校验脚本,扫描构建产物中是否包含 sk_ 开头的字符串,杜绝此类问题。
案例三:Node.js 微服务
背景:某微服务架构包含 12 个服务,每个服务有独立的环境变量配置。
挑战:
- 各服务环境变量不一致,维护成本高。
- 新增服务时容易遗漏必需变量。
- 跨服务共享配置(如数据库连接)重复定义。
方案:
// packages/shared-config/src/index.ts
import { z } from 'zod';
// 基础 Schema(所有服务共享)
const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
DATADOG_API_KEY: z.string().optional(),
SENTRY_DSN: z.string().url().optional(),
});
// 服务特化 Schema
export const userServiceSchema = baseSchema.extend({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3001),
});
export const orderServiceSchema = baseSchema.extend({
DATABASE_URL: z.string().url(),
RABBITMQ_URL: z.string().url(),
PAYMENT_SERVICE_URL: z.string().url(),
PORT: z.coerce.number().default(3002),
});
export const paymentServiceSchema = baseSchema.extend({
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
PORT: z.coerce.number().default(3003),
});
// 统一加载函数
export function loadEnv<T extends z.ZodObject<any>>(
schema: T,
): z.infer<T> {
const result = schema.safeParse(process.env);
if (!result.success) {
console.error(`[${process.env.SERVICE_NAME}] 环境变量校验失败:`);
for (const issue of result.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
return result.data;
}
typescript
// services/user-service/src/env.ts
import { loadEnv, userServiceSchema } from '@shared/config';
export const env = loadEnv(userServiceSchema);
// env 类型自动推导为 { NODE_ENV, LOG_LEVEL, ..., DATABASE_URL, PORT }
效果:
- 新增服务时,只需在 shared-config 中定义 Schema,自动获得校验。
- 跨服务共享配置(如
DATADOG_API_KEY)通过基础 Schema 统一。 - CI 流水线扫描各服务的 Schema,生成配置清单,便于运维审计。
案例四:Vercel 部署的 Next.js 应用
背景:某博客平台部署在 Vercel,需要支持 Preview Deployment(每个 PR 一个预览环境)。
挑战:
- 预览环境的 API 地址动态变化(
<branch>.example.com)。 - 需要根据是否为预览环境禁用 Analytics。
- 数据库连接串因环境而异。
方案:
// src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
// Vercel 自动注入的环境变量
VERCEL_ENV: z.enum(['development', 'preview', 'production']),
VERCEL_URL: z.string().optional(), // 预览部署的 URL
VERCEL_GIT_COMMIT_SHA: z.string().optional(),
// 应用配置
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().optional(),
DATABASE_URL: z.string().url(),
});
type Env = z.infer<typeof envSchema>;
function loadEnv(): Env {
const env = envSchema.parse(process.env);
// 预览环境特殊处理
if (env.VERCEL_ENV === 'preview') {
return {
...env,
// 预览环境不启用 GA
NEXT_PUBLIC_GA_ID: undefined,
// 预览环境使用独立的 API 地址
NEXT_PUBLIC_API_URL: `https://preview-api.example.com`,
};
}
return env;
}
export const env = loadEnv();
效果:
- 每个 PR 自动创建预览环境,配置自动适配。
- 生产环境的 Analytics 在预览环境被禁用,避免污染数据。
- 数据库连接串通过 Vercel 环境变量面板按环境配置,无需改代码。
案例五:Electron 桌面应用
背景:某 Electron 应用需要区分开发与打包模式,并支持用户自定义配置。
方案:
// main/config.ts
import { app } from 'electron';
import path from 'path';
import fs from 'fs';
interface ElectronEnv {
isDev: boolean;
appVersion: string;
userDataPath: string;
apiUrl: string;
enableAutoUpdate: boolean;
}
function loadElectronEnv(): ElectronEnv {
// 判断是否为开发模式
const isDev = !app.isPackaged;
// 读取用户级配置(覆盖默认值)
const configPath = path.join(app.getPath('userData'), 'config.json');
let userConfig: Partial<ElectronEnv> = {};
if (fs.existsSync(configPath)) {
userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
return {
isDev,
appVersion: app.getVersion(),
userDataPath: app.getPath('userData'),
apiUrl: userConfig.apiUrl ?? (
isDev ? 'http://localhost:3000' : 'https://api.example.com'
),
enableAutoUpdate: userConfig.enableAutoUpdate ?? !isDev,
};
}
export const env = loadElectronEnv();
关键决策:
- 开发模式通过
app.isPackaged判断,不依赖NODE_ENV(Electron 主进程不设置)。 - 用户配置存储在
userData目录,与安装目录分离,升级时不丢失。 - API 地址允许用户覆盖,支持自建服务器的用户。
案例六:容器化部署的运行时配置
背景:某 Kubernetes 部署的应用,需要根据 Namespace 动态加载配置。
方案:
// src/config/k8s-env.ts
import { z } from 'zod';
import fs from 'fs';
// 从 Kubernetes ConfigMap 挂载的文件读取
function loadConfigMapEnv(): Record<string, string> {
const configDir = '/etc/app-config';
const env: Record<string, string> = {};
if (fs.existsSync(configDir)) {
for (const file of fs.readdirSync(configDir)) {
const value = fs.readFileSync(path.join(configDir, file), 'utf-8').trim();
env[file] = value;
}
}
return env;
}
// 从 Kubernetes Secret 挂载的文件读取
function loadSecretEnv(): Record<string, string> {
const secretDir = '/etc/app-secrets';
const env: Record<string, string> = {};
if (fs.existsSync(secretDir)) {
for (const file of fs.readdirSync(secretDir)) {
const value = fs.readFileSync(path.join(secretDir, file), 'utf-8').trim();
env[file] = value;
}
}
return env;
}
// 合并所有配置源
function loadK8sEnv() {
const env = {
...process.env,
...loadConfigMapEnv(),
...loadSecretEnv(),
};
return envSchema.parse(env);
}
部署清单:
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
template:
spec:
containers:
- name: app
env:
- name: NODE_ENV
value: production
envFrom:
- configMapRef:
name: app-config
volumeMounts:
- name: secrets
mountPath: /etc/app-secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: app-secrets
效果:
- 配置与密钥分离:ConfigMap 存普通配置,Secret 存敏感数据。
- 应用启动时统一加载,类型校验通过后才提供服务。
- Namespace 隔离:每个 Namespace 有独立的 ConfigMap 与 Secret。
基础题
题目 1:编写一个 .d.ts 文件,声明以下环境变量:
API_URL(string,必需)TIMEOUT(数字字符串,可选)ENABLE_CORS(布尔字符串,可选)
参考答案要点:
declare namespace NodeJS {
interface ProcessEnv {
readonly API_URL: string;
readonly TIMEOUT?: string;
readonly ENABLE_CORS?: string;
}
}
题目 2:解释为什么 process.env.PORT 即使声明为 string,运行时仍可能是 undefined。
参考答案要点:接口声明仅影响编译期类型检查,不改变运行时行为。process.env 是操作系统注入的字典,声明为 string 只是告诉 TypeScript 编译器”假设它是 string”,但运行时是否真的设置取决于环境。需要运行时校验(如 Zod)才能保证类型正确。
题目 3:编写一个 getEnumEnv 函数,读取枚举类型的环境变量。
参考答案要点:
function getEnumEnv<T extends string>(
key: string,
values: readonly T[],
defaultValue: T,
): T {
const value = process.env[key];
if (value === undefined) return defaultValue;
if (!values.includes(value as T)) {
throw new Error(
`环境变量 ${key} 必须是 ${values.join('|')} 之一,实际: ${value}`,
);
}
return value as T;
}
// 使用
const logLevel = getEnumEnv(
'LOG_LEVEL',
['debug', 'info', 'warn', 'error'] as const,
'info',
);
进阶题
题目 4:使用 Zod 构建一个环境变量 Schema,满足:
NODE_ENV:枚举development/production/test,默认developmentPORT:数字,范围 1024-65535,默认 3000DATABASE_URL:PostgreSQL URL(以postgresql://开头)CORS_ORIGINS:逗号分隔的 URL 列表,自动解析为数组
参考答案要点:
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
PORT: z.coerce
.number()
.int()
.min(1024, '端口必须大于 1023')
.max(65535, '端口必须小于 65536')
.default(3000),
DATABASE_URL: z
.string()
.url()
.refine(url => url.startsWith('postgresql://'), {
message: 'DATABASE_URL 必须是 PostgreSQL URL',
}),
CORS_ORIGINS: z
.string()
.transform(s => s.split(',').map(u => u.trim()))
.pipe(z.array(z.string().url()))
.default([]),
});
type Env = z.infer<typeof envSchema>;
题目 5:分析以下代码的问题并修复:
// 有问题的代码
const config = {
port: parseInt(process.env.PORT || '3000'),
debug: process.env.DEBUG === 'true',
dbUrl: process.env.DATABASE_URL!,
};
export default config;
参考答案要点:
问题:
parseInt不检查NaN,PORT=abc时port为NaN。debug仅匹配'true',遗漏'1'、'yes'、大小写。DATABASE_URL!使用非空断言,运行时可能是undefined。- 无运行时校验,配置错误只在运行时暴露。
修复:
import { z } from 'zod';
const configSchema = z.object({
port: z.coerce.number().int().positive().default(3000),
debug: z.coerce.boolean().default(false),
dbUrl: z.string().url(),
});
export const config = configSchema.parse({
port: process.env.PORT,
debug: process.env.DEBUG,
dbUrl: process.env.DATABASE_URL,
});
题目 6:设计一个支持密钥轮换的环境变量结构。
参考答案要点:
const envSchema = z.object({
JWT_SECRET: z.string().min(32),
JWT_SECRET_PREVIOUS: z.string().min(32).optional(),
JWT_SECRET_ROTATION_DEADLINE: z.string().datetime().optional(),
});
// 验证 token 时双密钥兼容
function verifyToken(token: string): JWTPayload {
try {
return jwt.verify(token, env.JWT_SECRET);
} catch {
if (env.JWT_SECRET_PREVIOUS) {
return jwt.verify(token, env.JWT_SECRET_PREVIOUS);
}
throw new Error('Invalid token');
}
}
// 轮换流程:
// 1. 设置 JWT_SECRET_PREVIOUS = 旧密钥
// 2. 设置 JWT_SECRET = 新密钥
// 3. 等待所有旧 token 过期
// 4. 移除 JWT_SECRET_PREVIOUS
挑战题
题目 7:为 Monorepo 设计一个共享环境变量管理方案,满足:
- 基础配置(日志、监控)所有包共享
- 各服务有独立配置
- 支持本地开发覆盖
- CI 自动校验
参考答案要点:
// packages/shared-config/src/index.ts
import { z } from 'zod';
// 基础 Schema
export const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
SENTRY_DSN: z.string().url().optional(),
});
// 服务 Schema 模板
export function createServiceSchema<T extends z.ZodRawShape>(
shape: T,
) {
return baseSchema.extend(shape);
}
// 各服务定义自己的 Schema
export const apiSchema = createServiceSchema({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
});
export const workerSchema = createServiceSchema({
REDIS_URL: z.string().url(),
QUEUE_CONCURRENCY: z.coerce.number().default(4),
});
// 统一加载函数
export function loadEnv<S extends z.ZodObject<any>>(
schema: S,
): z.infer<S> {
const result = schema.safeParse(process.env);
if (!result.success) {
console.error('环境变量校验失败:');
result.error.issues.forEach(issue => {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
});
process.exit(1);
}
return result.data;
}
题目 8:实现一个环境变量脱敏日志工具,要求:
- 自动识别密钥类变量(名称包含
SECRET、PASSWORD、KEY、TOKEN) - 部分脱敏(保留前 4 位与后 4 位)
- 支持嵌套对象
参考答案要点:
const SENSITIVE_PATTERNS = [
/SECRET/i,
/PASSWORD/i,
/KEY/i,
/TOKEN/i,
/CREDENTIAL/i,
];
function isSensitive(key: string): boolean {
return SENSITIVE_PATTERNS.some(pattern => pattern.test(key));
}
function maskValue(value: string): string {
if (value.length <= 8) return '****';
return value.slice(0, 4) + '****' + value.slice(-4);
}
function deepMask<T>(obj: T): T {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) {
return obj.map(deepMask) as unknown as T;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (typeof value === 'string' && isSensitive(key)) {
result[key] = maskValue(value);
} else if (typeof value === 'object' && value !== null) {
result[key] = deepMask(value);
} else {
result[key] = value;
}
}
return result as T;
}
// 使用
const config = loadConfig();
console.log('应用配置:', deepMask(config));
// jwt.secret: "abcd****wxyz"
// database.password: "pass****1234"
题目 9:实现一个环境变量变更检测工具,在 CI 中对比当前与上次部署的环境变量,检测:
- 新增的必需变量
- 移除的变量
- 类型变更的变量
参考答案要点:
// scripts/diff-env.ts
import { envSchema } from '../src/config/env.schema';
interface EnvSnapshot {
fields: Record<string, { type: string; required: boolean }>;
}
function snapshotSchema(): EnvSnapshot {
const fields: EnvSnapshot['fields'] = {};
for (const [name, field] of Object.entries(envSchema.shape)) {
const isOptional = field instanceof z.ZodOptional;
fields[name] = {
type: field.constructor.name,
required: !isOptional,
};
}
return { fields };
}
function diffSnapshots(
old: EnvSnapshot,
current: EnvSnapshot,
): string[] {
const changes: string[] = [];
for (const [name, currentField] of Object.entries(current.fields)) {
const oldField = old.fields[name];
if (!oldField) {
changes.push(`[新增] ${name}: ${currentField.type}`);
} else if (oldField.type !== currentField.type) {
changes.push(
`[类型变更] ${name}: ${oldField.type} -> ${currentField.type}`,
);
} else if (oldField.required !== currentField.required) {
changes.push(
`[必需性变更] ${name}: ${oldField.required} -> ${currentField.required}`,
);
}
}
for (const name of Object.keys(old.fields)) {
if (!current.fields[name]) {
changes.push(`[移除] ${name}`);
}
}
return changes;
}
// 读取上次快照
const oldSnapshot = JSON.parse(
fs.readFileSync('.env-snapshot.json', 'utf-8'),
);
const currentSnapshot = snapshotSchema();
const changes = diffSnapshots(oldSnapshot, currentSnapshot);
if (changes.length > 0) {
console.log('环境变量变更:');
changes.forEach(c => console.log(' ' + c));
}
// 保存当前快照
fs.writeFileSync(
'.env-snapshot.json',
JSON.stringify(currentSnapshot, null, 2),
);
题目 10:设计一个支持远程配置中心(如 Apollo、Nacos)的环境变量同步方案,要求:
- 本地环境变量优先于远程
- 远程配置变更时热更新
- 网络故障时使用本地缓存
参考答案要点:
interface RemoteConfigClient {
getConfig(namespace: string): Promise<Record<string, string>>;
subscribe(namespace: string, callback: (config: Record<string, string>) => void): void;
}
class EnvManager {
private config: Record<string, string> = {};
private remoteClient: RemoteConfigClient;
private localCachePath: string;
constructor(remoteClient: RemoteConfigClient, localCachePath: string) {
this.remoteClient = remoteClient;
this.localCachePath = localCachePath;
}
async init(): Promise<void> {
// 1. 加载本地缓存
const cached = await this.loadLocalCache();
// 2. 合并:本地环境变量 > 远程 > 缓存
this.config = {
...cached,
...await this.fetchRemote(),
...process.env,
};
// 3. 订阅远程变更
this.remoteClient.subscribe('app', (remoteConfig) => {
this.config = { ...remoteConfig, ...process.env };
this.saveLocalCache();
this.emitChange();
});
}
private async fetchRemote(): Promise<Record<string, string>> {
try {
return await this.remoteClient.getConfig('app');
} catch (error) {
console.warn('[env] 远程配置拉取失败,使用本地缓存');
return await this.loadLocalCache();
}
}
private async loadLocalCache(): Promise<Record<string, string>> {
try {
return JSON.parse(await fs.readFile(this.localCachePath, 'utf-8'));
} catch {
return {};
}
}
private async saveLocalCache(): Promise<void> {
await fs.writeFile(
this.localCachePath,
JSON.stringify(this.config, null, 2),
);
}
private emitChange(): void {
// 通知应用配置已更新
}
get(key: string): string | undefined {
return this.config[key];
}
}
官方文档
-
Vite 环境变量与模式:https://vitejs.dev/guide/env-and-mode.html 官方权威指南,详细介绍
VITE_前缀约定、模式切换、.env文件加载顺序。 -
Next.js 环境变量:https://nextjs.org/docs/app/building-your-application/configuring/environment-variables Next.js 14+ 的环境变量机制,包含服务端/客户端区分、运行时注入、Preview Deployment。
-
Node.js process.env 文档:https://nodejs.org/api/process.html#processenv Node.js 官方对
process.env的详细说明,包含 Windows 与 Unix 的差异。 -
Zod 文档:https://zod.dev/ Zod 官方文档,涵盖 Schema 定义、类型推导、错误处理、高级用法(transform、refine、coerce)。
经典教材
-
《TypeScript: The Complete Developer’s Guide》(Matt Pocock, 2024) 第 11 章”Environment Configuration”详细讨论了类型安全的环境变量管理。
-
《Effective TypeScript》(Dan Vanderkam, 2nd ed., 2024) 第 9 章涉及环境变量声明合并与类型安全访问。
-
《Building Microservices》(Sam Newman, 2nd ed., 2021) 第 7 章”Configuration Management”讨论微服务架构下的环境变量与配置中心。
前沿论文
-
Cito, J., et al. (2022). “An Empirical Study of Configuration Management in Node.js Applications.” ICSE 2022. 对 1,200 个 Node.js 项目的环境变量使用模式进行实证研究,揭示常见反模式。
-
Rahman, A., et al. (2021). “Secrets in Source Code: A Study of 100,000 Repositories.” MSR 2021. 分析 GitHub 上 10 万个仓库中的密钥泄漏问题,提出检测与防护建议。
相关开源项目
-
envalid:https://github.com/af/envalid 轻量级 Node.js 环境变量校验库,API 简洁,适合小型后端项目。
-
dotenv-flow:https://github.com/kerimdzhanov/dotenv-flow 支持
.env.development、.env.production等多环境文件的 dotenv 扩展。 -
convict:https://github.com/mozilla/convict Mozilla 出品的配置管理库,支持 Schema 校验、默认值、环境覆盖。
-
t3-env:https://github.com/t3-oss/t3-env T3 Stack 使用的环境变量管理方案,针对 Next.js、Vite 优化,集成 Zod。
附录 A:常见环境变量命名约定
前缀约定
| 前缀 | 含义 | 示例 |
|---|---|---|
VITE_ | Vite 前端变量(暴露到 bundle) | VITE_API_URL |
NEXT_PUBLIC_ | Next.js 客户端变量 | NEXT_PUBLIC_GA_ID |
REACT_APP_ | CRA 前端变量 | REACT_APP_API_URL |
npm_package_ | npm 自动注入的包信息 | npm_package_version |
命名风格
- 全大写 + 下划线:
DATABASE_URL、JWT_SECRET(Unix 传统,最常见) - PascalCase:
DatabaseUrl(少数项目使用) - camelCase:
databaseUrl(不推荐,与 Shell 不兼容)
常见变量名
| 变量名 | 用途 | 类型 |
|---|---|---|
NODE_ENV | 运行环境 | enum |
PORT / HTTP_PORT | 服务端口 | number |
DATABASE_URL / DB_URL | 数据库连接串 | url |
JWT_SECRET | JWT 签名密钥 | string |
LOG_LEVEL | 日志级别 | enum |
DEBUG | 调试模式开关 | boolean |
CORS_ORIGINS | CORS 允许的源 | string[] |
REDIS_URL | Redis 连接串 | url |
SENTRY_DSN | Sentry 错误上报 DSN | url |
附录 B:环境变量校验失败的标准处理流程
flowchart TD
A[应用启动] --> B[读取环境变量]
B --> C{Zod 校验}
C -- 通过 --> D[继续启动]
C -- 失败 --> E[输出详细错误]
E --> E1[路径(变量名)]
E --> E2[期望类型]
E --> E3[实际值(脱敏)]
E --> E4[修复建议]
E --> E5[process.exit(1)]
D --> F[加载配置对象]
F --> G[注册全局单例]
附录 C:不同框架的 .env 文件加载顺序
Vite 加载顺序(从低到高优先级)
.env.env.local.env.[mode].env.[mode].local
Next.js 加载顺序(从低到高优先级)
.env.env.local(除 test 环境外始终加载).env.[development|production|test].env.[development|production|test].local
Node.js(dotenv)加载顺序
- 手动调用
dotenv.config()加载.env dotenv-flow支持NODE_ENV自动加载对应文件
附录 D:安全检查清单
部署前检查以下安全项:
-
.gitignore包含.env、.env.local、.env.*.local -
.env.example不包含真实密钥 - 前端 bundle 中无
sk_、pk_live等敏感前缀字符串 - CI 流水线扫描构建产物中的密钥模式
- 日志中不直接打印
process.env - 错误信息不泄露环境变量值
- 密钥通过 Secrets Manager 或 CI Secrets 注入
- 密钥轮换流程文档化
- 生产环境数据库连接串使用 SSL
- JWT 密钥长度 ≥ 32 字符
- CORS 允许列表不包含
*(生产环境) - CSP 头部配置正确
附录 E:TypeScript 版本兼容性
| TypeScript 版本 | 特性支持 |
|---|---|
| 2.0+ | declare global 接口合并 |
| 3.7+ | 断言函数(asserts x is T) |
| 4.0+ | 可变元组类型(用于多变量校验) |
| 4.1+ | 模板字面量类型(用于变量名约束) |
| 4.9+ | satisfies 操作符(用于配置对象) |
| 5.0+ | 装饰器(用于配置注入) |
| 5.4+ | NoInfer<T>(用于默认值推导) |
附录 F:调试技巧
技巧 1:打印所有环境变量(脱敏)
function logEnv(): void {
const safeEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (isSensitive(key)) {
safeEnv[key] = maskValue(value ?? '');
} else {
safeEnv[key] = value ?? '';
}
}
console.table(safeEnv);
}
技巧 2:验证特定变量
// 启动时添加 --debug-env 参数打印特定变量
if (process.argv.includes('--debug-env')) {
const target = process.argv[process.argv.indexOf('--debug-env') + 1];
console.log(`[env] ${target} = ${process.env[target]}`);
}
技巧 3:Zod 错误友好化
function formatZodError(error: z.ZodError): string {
return error.issues
.map(issue => {
const path = issue.path.join('.') || '(root)';
return ` - ${path}: ${issue.message}`;
})
.join('\n');
}
// 使用
try {
envSchema.parse(process.env);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('环境变量校验失败:\n' + formatZodError(error));
}
process.exit(1);
}
附录 G:与其他语言对比
Go 的环境变量管理
// Go 标准库
port := os.Getenv("PORT")
// 流行库 envconfig
type Config struct {
Port int `envconfig:"PORT" default:"3000"`
DBUrl string `envconfig:"DATABASE_URL" required:"true"`
}
特点:使用 struct tag 声明,编译期无类型检查(依赖运行时),但库生态成熟。
Python 的环境变量管理
# 标准库
import os
port = os.environ.get('PORT', '3000')
# pydantic-settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
port: int = 3000
db_url: str
class Config:
env_file = '.env'
settings = Settings()
特点:pydantic-settings 与 Zod 理念一致,Schema 驱动类型与校验。
Rust 的环境变量管理
use std::env;
// 标准库
let port: u16 = env::var("PORT")
.unwrap_or("3000".to_string())
.parse()
.expect("PORT must be a number");
// dotenv crate
dotenv::dotenv().ok();
特点:标准库简洁但无校验,config crate 提供更完整的方案。
附录 H:未来演进方向
趋势 1:Bun 原生支持
Bun 运行时内置 .env 文件加载,无需 dotenv:
// Bun 自动加载 .env,直接使用
const port = process.env.PORT;
未来 TypeScript 项目可能减少对 dotenv 的依赖。
趋势 2:类型安全的配置注入
TypeScript 5.0+ 的装饰器为依赖注入提供了基础,未来可能出现:
@Configurable()
class AppService {
@Env('API_URL')
apiUrl!: string;
@Env('PORT', { type: 'number', default: 3000 })
port!: number;
}
趋势 3:远程配置与本地环境变量融合
随着微服务与云原生普及,远程配置中心(Apollo、Nacos、Consul KV)与本地环境变量的边界将模糊。未来可能出现统一的”配置订阅”接口,屏蔽本地与远程差异。
趋势 4:AI 辅助配置审计
利用 LLM 分析环境变量配置,自动检测:
- 弱密钥(如
JWT_SECRET=password) - 配置不一致(如 dev 与 prod 的 CORS 策略差异)
- 潜在泄漏(代码中硬编码的值与环境变量重复)
附录 I:术语对照表
| 英文术语 | 中文翻译 | 说明 |
|---|---|---|
| Environment Variable | 环境变量 | 操作系统向进程注入的配置 |
| Process Env | 进程环境 | process.env 对象 |
| Import Meta Env | 导入元环境 | import.meta.env(Vite/ESM) |
| Schema | 模式/架构 | 描述数据结构的声明 |
| Type Inference | 类型推导 | 编译器自动推断类型 |
| Runtime Validation | 运行时校验 | 程序运行时检查数据合法性 |
| Safe Parse | 安全解析 | 不抛异常的校验,返回 tagged union |
| Coerce | 强制转换 | 字符串自动转目标类型 |
| Default Value | 默认值 | 变量未设置时使用的回退值 |
| Required | 必需 | 变量必须设置 |
| Optional | 可选 | 变量可不设置 |
| Config Map | 配置映射 | Kubernetes 中的配置资源 |
| Secret | 密钥/机密 | 敏感配置数据 |
| Rotation | 轮换 | 定期更换密钥的流程 |
| Build-time | 构建时 | 编译打包阶段 |
| Runtime | 运行时 | 程序执行阶段 |
| Hot Reload | 热重载 | 不重启应用更新配置 |
| Single Source of Truth | 单一数据源 | 配置唯一来源原则 |
| Frontmatter | 前置元数据 | Markdown 顶部的 YAML 块 |
| Interface Merging | 接口合并 | 同名接口自动合并 |
| Discriminated Union | 判别式联合 | 共享字面量属性的联合类型 |