一、完整实现方案:启动自动拉取最新翻译 + 环境区分加载
以下是可直接落地的代码,实现启动时自动拉取最新翻译JSON,且生产/测试环境优先远程API、不依赖本地JSON,开发环境可兜底本地JSON。
1. 环境配置文件(统一管理环境变量)
// toolkit/lib/localization/env.ts
import path from 'path';
// 环境枚举
export enum AppEnv {
DEV = 'development',
STAG = 'staging',
PROD = 'production',
}
// 获取当前环境
export const getAppEnv = (): AppEnv => {
const env = process.env.APP_ENV || process.env.NODE_ENV;
switch (env) {
case 'staging':
return AppEnv.STAG;
case 'production':
return AppEnv.PROD;
default:
return AppEnv.DEV;
}
};
// 基础配置
export const LOCALIZATION_CONFIG = {
// API域名(区分环境)
API_DOMAINS: {
[AppEnv.DEV]: 'dev-localization-api.crypto.com',
[AppEnv.STAG]: 'stag-localization-api.3ona.co',
[AppEnv.PROD]: 'localization-api.crypto.com',
},
// 本地翻译文件目录
LOCALES_DIR: path.join(process.cwd(), 'locales'),
// 缓存TTL(生产/测试30分钟,开发5分钟)
CACHE_TTL: getAppEnv() === AppEnv.DEV ? 5 * 60 : 30 * 60,
// 是否启用本地JSON(仅开发环境)
ENABLE_LOCAL_JSON: getAppEnv() === AppEnv.DEV,
};
// 获取API基础URL
export const getLocalizationApiBaseUrl = () => {
const env = getAppEnv();
return `https://${LOCALIZATION_CONFIG.API_DOMAINS[env]}`;
};
2. 启动时自动拉取翻译的脚本
// scripts/localization/fetch-locales.ts
import fs from 'fs/promises';
import path from 'path';
import { getAppEnv, LOCALIZATION_CONFIG, getLocalizationApiBaseUrl } from '../../toolkit/lib/localization/env';
// 类型定义
type LocaleOverview = {
locales: Array<{
code: string;
file_urls: {
'frontend-web__cdc_web': string;
};
}>;
version: string;
};
type TranslationData = Record<string, string>;
// 确保locales目录存在
async function ensureLocalesDir() {
try {
await fs.access(LOCALIZATION_CONFIG.LOCALES_DIR);
} catch {
await fs.mkdir(LOCALIZATION_CONFIG.LOCALES_DIR, { recursive: true });
}
}
// 拉取overview(语言列表+文件URL)
async function fetchLocaleOverview(): Promise<LocaleOverview> {
const baseUrl = getLocalizationApiBaseUrl();
const response = await fetch(`${baseUrl}/locales/overview.json`);
if (!response.ok) {
throw new Error(`Failed to fetch overview: ${response.statusText}`);
}
return response.json();
}
// 拉取单个语言的翻译并清理
async function fetchSingleLocale(localeCode: string, fileUrl: string): Promise<TranslationData> {
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch ${localeCode}: ${response.statusText}`);
}
const rawData = await response.json() as TranslationData;
// 清理不兼容的key(next-intl不支持带点的key)
const cleanedData: TranslationData = {};
for (const [key, value] of Object.entries(rawData)) {
if (!key.includes('.')) {
cleanedData[key] = value.replaceAll('\\n', '\n');
}
}
return cleanedData;
}
// 保存翻译到本地JSON
async function saveLocaleToFile(localeCode: string, data: TranslationData) {
const filePath = path.join(LOCALIZATION_CONFIG.LOCALES_DIR, `${localeCode.replace('_', '-')}.json`);
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`✅ Saved ${localeCode} to ${filePath}`);
}
// 主函数:拉取所有语言翻译
async function fetchAllLocales() {
console.log(`🔍 Current environment: ${getAppEnv()}`);
console.log(`📥 Starting to fetch latest locales...`);
try {
// 确保目录存在
await ensureLocalesDir();
// 拉取overview
const overview = await fetchLocaleOverview();
console.log(`📋 Found ${overview.locales.length} locales (version: ${overview.version})`);
// 并行拉取+保存所有语言
await Promise.all(
overview.locales.map(async (locale) => {
const fileUrl = locale.file_urls['frontend-web__cdc_web'];
const translation = await fetchSingleLocale(locale.code, fileUrl);
// 仅开发环境保存到本地(生产/测试不需要本地文件)
if (LOCALIZATION_CONFIG.ENABLE_LOCAL_JSON) {
await saveLocaleToFile(locale.code, translation);
}
})
);
console.log(`🎉 All locales fetched successfully!`);
} catch (error) {
console.error(`❌ Failed to fetch locales:`, error);
// 开发环境允许失败(兜底本地文件),生产/测试直接退出
if (getAppEnv() !== AppEnv.DEV) {
process.exit(1);
}
}
}
// 执行主函数
fetchAllLocales();
3. 运行时翻译加载逻辑(区分环境)
// src/server/services/localization/localeLoader.ts
import { getAppEnv, LOCALIZATION_CONFIG, getLocalizationApiBaseUrl } from '../../../toolkit/lib/localization/env';
import fs from 'fs/promises';
import path from 'path';
// 类型定义
export type LocaleMessages = Record<string, string>;
type LocaleOverview = {
locales: Array<{
code: string;
file_urls: {
'frontend-web__cdc_web': string;
};
}>;
version: string;
};
// ------------------------------
// 1. 远程加载(生产/测试环境优先)
// ------------------------------
async function fetchLocaleOverview(): Promise<LocaleOverview> {
const baseUrl = getLocalizationApiBaseUrl();
const response = await fetch(`${baseUrl}/locales/overview.json`);
if (!response.ok) {
throw new Error(`Failed to fetch overview: ${response.status}`);
}
return response.json();
}
async function loadLocaleFromRemote(localeCode: string): Promise<LocaleMessages> {
const overview = await fetchLocaleOverview();
const locale = overview.locales.find(item => item.code.replace('_', '-') === localeCode);
if (!locale) {
throw new Error(`Locale ${localeCode} not found in remote`);
}
const response = await fetch(locale.file_urls['frontend-web__cdc_web']);
if (!response.ok) {
throw new Error(`Failed to load ${localeCode} from remote: ${response.status}`);
}
const rawData = await response.json() as LocaleMessages;
// 清理key
const cleanedData: LocaleMessages = {};
for (const [key, value] of Object.entries(rawData)) {
if (!key.includes('.')) {
cleanedData[key] = value.replaceAll('\\n', '\n');
}
}
return cleanedData;
}
// ------------------------------
// 2. 本地加载(仅开发环境兜底)
// ------------------------------
async function loadLocaleFromLocal(localeCode: string): Promise<LocaleMessages | null> {
if (!LOCALIZATION_CONFIG.ENABLE_LOCAL_JSON) {
return null;
}
try {
const filePath = path.join(LOCALIZATION_CONFIG.LOCALES_DIR, `${localeCode}.json`);
const fileContent = await fs.readFile(filePath, 'utf-8');
return JSON.parse(fileContent) as LocaleMessages;
} catch {
return null;
}
}
// ------------------------------
// 3. 统一加载入口(区分环境)
// ------------------------------
export async function loadLocaleMessages(localeCode: string): Promise<LocaleMessages> {
const env = getAppEnv();
// 生产/测试环境:仅远程加载(不依赖本地JSON)
if (env === AppEnv.PROD || env === AppEnv.STAG) {
return loadLocaleFromRemote(localeCode);
}
// 开发环境:先远程,失败则兜底本地
try {
return await loadLocaleFromRemote(localeCode);
} catch (remoteError) {
console.warn(`⚠️ Remote load failed (${localeCode}), fallback to local:`, remoteError.message);
const localData = await loadLocaleFromLocal(localeCode);
if (!localData) {
throw new Error(`Both remote and local load failed for ${localeCode}`);
}
return localData;
}
}
4. 缓存层整合(Stale-While-Revalidate)
// src/server/services/localization/localeCache.ts
import { Cacheable } from 'cacheable';
import { loadLocaleMessages, LocaleMessages } from './localeLoader';
import { LOCALIZATION_CONFIG, getAppEnv } from '../../../toolkit/lib/localization/env';
// 缓存实例
const cache = new Cacheable({
ttl: LOCALIZATION_CONFIG.CACHE_TTL * 1000, // 转毫秒
namespace: 'locale-cache',
});
// 带缓存的加载逻辑
export async function getCachedLocaleMessages(localeCode: string): Promise<LocaleMessages> {
const cacheKey = `locale:${localeCode}`;
// 使用Stale-While-Revalidate策略
return cache.cacheable(
() => loadLocaleMessages(localeCode), // 实际加载函数
cacheKey,
{
cachePolicy: 'stale-while-revalidate',
maxAge: LOCALIZATION_CONFIG.CACHE_TTL * 1000,
}
);
}
// 清空缓存(可选:手动触发更新)
export function clearLocaleCache(localeCode?: string) {
if (localeCode) {
cache.delete(`locale:${localeCode}`);
} else {
cache.clear();
}
}
5. package.json 脚本配置(启动自动拉取)
{
"scripts": {
"predev": "tsx scripts/localization/fetch-locales.ts", // 开发启动前拉取
"dev": "next dev",
"prebuild": "tsx scripts/localization/fetch-locales.ts", // 构建前拉取(开发环境)
"build": "next build",
"start": "next start",
"fetch:locales": "tsx scripts/localization/fetch-locales.ts" // 手动拉取命令
},
"dependencies": {
"cacheable": "^4.0.0",
"next": "^15.0.0",
"next-intl": "^3.0.0",
"tsx": "^4.0.0"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
二、核心逻辑说明
1. 启动自动拉取
- 通过
predev/prebuild钩子,启动/构建前自动执行fetch-locales.ts - 开发环境:拉取后保存到本地
locales/目录,作为兜底 - 生产/测试环境:仅拉取验证,不保存本地文件(完全依赖远程API)
2. 环境区分加载
| 环境 | 加载策略 | 是否依赖本地JSON |
|---|---|---|
| 开发 (DEV) | 优先远程 → 失败则兜底本地 | ✅ 是(兜底) |
| 测试 (STAG) | 仅远程加载(失败直接报错) | ❌ 否 |
| 生产 (PROD) | 仅远程加载(失败直接报错) | ❌ 否 |
3. 缓存策略
- 生产/测试环境:缓存30分钟,过期后先返回旧数据 + 后台刷新,无感知更新
- 开发环境:缓存5分钟,方便调试
三、使用方式
- 安装依赖:
npm install cacheable next-intl tsx - 配置环境变量:
- 开发:默认无需配置(APP_ENV=development)
- 测试:
APP_ENV=staging npm run build && npm run start - 生产:
APP_ENV=production npm run build && npm run start
- 启动项目:
npm run dev(自动拉取最新翻译后启动)
四、扩展建议
- 错误监控:生产环境可接入Sentry,监控远程翻译加载失败
- 手动更新:提供接口触发
clearLocaleCache(),实现翻译热更新 - CI/CD集成:生产构建时校验翻译完整性,避免缺失key
- 本地调试:开发环境可通过
ENABLE_LOCAL_JSON=true强制使用本地文件