目录
核心概念
什么是构建时(Build Time)?
- 定义:执行
npm run build
或bun run build
的时候 - 时机:在开发者的机器上或 CI/CD 环境中
- 产物:生成静态的 HTML、CSS、JS 文件
- 特点:一旦构建完成,这些值就被”烘焙”(baked)进代码中,无法更改
什么是运行时(Runtime)?
- 定义:应用程序实际运行的时候
- 时机:用户访问网站时,服务器处理请求时
- 环境:生产服务器、Docker 容器、Vercel、Zeabur 等
- 特点:可以读取当前环境的变量,动态响应
构建时环境变量
NEXT_PUBLIC_ 前缀的变量
javascript
12345
// 构建时,Next.js 会将这个替换为实际的值
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// 构建后的代码实际变成了:
const apiUrl = "https://api.production.com";
工作原理
- 构建阶段:Next.js 扫描所有代码
- 文本替换:将
process.env.NEXT_PUBLIC_*
替换为实际值 - 打包输出:生成的 JS 文件中包含硬编码的值
示例流程
bash
123456789
# 构建时的环境
NEXT_PUBLIC_IMAGE_BASE_URL=https://cdn.production.com
# 执行构建
npm run build
# 生成的代码(简化示例)
// 原代码:const url = process.env.NEXT_PUBLIC_IMAGE_BASE_URL
// 变成了:const url = "https://cdn.production.com"
问题所在
1. 不同环境需要不同镜像
yaml
1234567891011
# 生产环境构建
NEXT_PUBLIC_IMAGE_BASE_URL=https://cdn.production.com npm run build
# 生成 Docker 镜像 A
# 测试环境构建
NEXT_PUBLIC_IMAGE_BASE_URL=https://cdn.staging.com npm run build
# 生成 Docker 镜像 B
# 开发环境构建
NEXT_PUBLIC_IMAGE_BASE_URL=https://cdn.dev.com npm run build
# 生成 Docker 镜像 C
2. 每次更改都需要重新构建
- 修改 CDN 地址?重新构建!
- 切换环境?重新构建!
- 临时测试?还是重新构建!
3. 构建时间成本
- Next.js 构建:3-5 分钟
- Docker 镜像构建:5-10 分钟
- 推送到仓库:1-2 分钟
- 总计:10-20 分钟等待
运行时环境变量
服务端环境变量
javascript
12
// 服务端代码(API Routes、Server Components)
const apiKey = process.env.API_KEY; // 运行时读取
工作原理
- 代码保持原样:
process.env.API_KEY
不会被替换 - 运行时读取:每次执行时从当前环境读取
- 动态响应:可以根据不同环境返回不同值
客户端的挑战
客户端(浏览器)没有 process.env
,所以需要特殊处理:
javascript
123456789
// ❌ 客户端无法直接使用
const apiKey = process.env.API_KEY; // undefined
// ✅ 需要通过 API 获取
fetch('/api/config')
.then(res => res.json())
.then(config => {
console.log(config.imageBaseUrl); // 运行时配置
});
实际案例分析
之前的问题(构建时配置)
typescript
123456
// lib/image-url.ts(旧版本)
export function getImageUrl(path: string): string {
// NEXT_PUBLIC_IMAGE_BASE_URL 在构建时被替换
const baseUrl = process.env.NEXT_PUBLIC_IMAGE_BASE_URL || '';
return `${baseUrl}${path}`;
}
问题场景:
- 在本地构建:
NEXT_PUBLIC_IMAGE_BASE_URL=https://dev.oss.com
- 生成的代码:
const baseUrl = "https://dev.oss.com" || '';
- 部署到生产环境:仍然使用
https://dev.oss.com
❌
我们的解决方案(运行时配置)
1. 服务端 API 提供配置
typescript
123456789
// app/api/config/route.ts
export async function GET() {
// 运行时读取环境变量
const config = {
imageBaseUrl: process.env.IMAGE_BASE_URL || '',
};
return NextResponse.json(config);
}
2. 客户端获取配置
typescript
1234567891011121314151617
// contexts/config-context.tsx
export function ConfigProvider({ children }) {
const [config, setConfig] = useState(null);
useEffect(() => {
// 运行时获取配置
fetch('/api/config')
.then(res => res.json())
.then(setConfig);
}, []);
return (
<ConfigContext.Provider value={config}>
{children}
</ConfigContext.Provider>
);
}
3. 使用运行时配置
typescript
123456789101112
// lib/image-url.ts(新版本)
export function getImageUrl(path: string): string {
if (typeof window !== 'undefined') {
// 客户端:从 window 对象获取运行时配置
const config = window.__APP_CONFIG__;
const baseUrl = config?.imageBaseUrl || '';
return `${baseUrl}${path}`;
}
// 服务端:可以直接读取环境变量
return path;
}
我们的解决方案
架构图
plaintext
12345678910111213141516171819202122232425262728
构建阶段:
┌─────────────────┐
│ 源代码 │
│ │
│ 无环境变量硬编码 │
└────────┬────────┘
↓
npm run build
↓
┌─────────────────┐
│ Docker 镜像 │
│ │
│ 通用镜像 │
└─────────────────┘
运行阶段:
┌─────────────────────────────────────────┐
│ Docker 容器运行 │
├─────────────┬──────────────┬─────────────┤
│ 开发环境 │ 测试环境 │ 生产环境 │
├─────────────┼──────────────┼─────────────┤
│ IMAGE_BASE_ │ IMAGE_BASE_ │ IMAGE_BASE_ │
│ URL=dev.com │ URL=test.com │ URL=prod.com│
└─────────────┴──────────────┴─────────────┘
↓ ↓ ↓
读取环境变量 读取环境变量 读取环境变量
↓ ↓ ↓
返回对应配置 返回对应配置 返回对应配置
优势对比
方面 | 构建时配置 | 运行时配置 |
---|---|---|
构建次数 | 每个环境一次 | 只需一次 |
镜像数量 | N个环境 = N个镜像 | 1个镜像 |
部署速度 | 需要重新构建(10-20分钟) | 直接部署(1-2分钟) |
灵活性 | 低(需要重新构建) | 高(修改环境变量即可) |
回滚能力 | 需要保存多个版本 | 同一镜像,改变配置 |
存储成本 | 高(多个镜像) | 低(单个镜像) |
配置错误 | 构建时发现 | 运行时发现 |
实际收益
- 部署提速:从 20 分钟降至 2 分钟
- 运维简化:一个镜像管理所有环境
- 快速切换:修改环境变量即可切换配置
- 应急响应:紧急修改配置无需重新构建
最佳实践建议
1. 选择合适的配置类型
使用构建时配置的场景:
- 不会改变的常量(如应用名称)
- 需要在编译时优化的代码
- 需要 Tree Shaking 的功能开关
javascript
123
// 适合构建时
const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME;
const ENABLE_ANALYTICS = process.env.NEXT_PUBLIC_ENABLE_ANALYTICS;
使用运行时配置的场景:
- API 端点、CDN 地址
- 功能开关、A/B 测试配置
- 第三方服务的配置
javascript
1234
// 适合运行时
const API_ENDPOINT = getConfig('apiEndpoint');
const CDN_URL = getConfig('cdnUrl');
const FEATURE_FLAGS = getConfig('featureFlags');
2. 混合策略
typescript
1234567
// 构建时:基础配置
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
// 运行时:环境特定配置
const API_URL = IS_PRODUCTION
? getRuntimeConfig('productionApi')
: getRuntimeConfig('developmentApi');
3. 配置验证
typescript
12345678910
// 启动时验证必需的环境变量
function validateEnv() {
const required = ['DATABASE_URL', 'API_KEY', 'IMAGE_BASE_URL'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required env var: ${key}`);
}
}
}
4. 配置缓存
typescript
12345678910
// 避免每次都获取配置
let cachedConfig: Config | null = null;
async function getConfig(): Promise<Config> {
if (!cachedConfig) {
const res = await fetch('/api/config');
cachedConfig = await res.json();
}
return cachedConfig;
}
总结
关键要点
- 构建时变量:像烤面包,一旦烤好就定型了
- 运行时变量:像自动售货机,根据投币(环境)提供不同商品(配置)
- NEXT_PUBLIC_ 前缀:专门用于客户端的构建时变量
- 无前缀变量:服务端运行时变量
我们的改造成果
- ✅ 移除了
NEXT_PUBLIC_IMAGE_BASE_URL
构建时依赖 - ✅ 实现了运行时配置系统
- ✅ 一个 Docker 镜像适配所有环境
- ✅ 部署时间从 20 分钟缩短到 2 分钟
- ✅ 保持了向后兼容性
记住这个原则
“Build once, deploy anywhere”(构建一次,到处部署)
这就是为什么大型企业都倾向于使用运行时配置而不是构建时配置。配置应该是灵活的,而不是被”烘焙”进代码里的。
最后更新:2025-01-13 作者:Claude AI Assistant 项目:Zetar Mold Production System