Next.js SSR 国际化极致优化最佳方案(P7+ 详细落地版)
适配场景:NestJS+Next.js SSR架构、20+语言支持、翻译团队统一维护文案(API侧提供全量精准数据)、客户端纯服务端水合翻译JSON、本地/测试/生产环境差异化加载、LRU+Redis双层缓存、API故障本地Baseline兜底、开发侧本地文案补全
核心痛点解决:解决20+语言全量本地打包体积爆炸、翻译团队与开发团队协作低效、客户端二次请求影响性能、API故障导致页面空白、缓存命中率低、Baseline与API数据混淆等核心问题
核心优化目标:本地翻译打包体积<200KB、缓存命中率>90%、SSR首屏P99延迟<10ms、翻译更新线上无感知、开发效率提升80%、生产环境高可用(API故障核心功能正常)
核心说明:本文档全程贴合公司实际业务,明确「本地Baseline」与「API翻译数据」的核心区别——Baseline是纯本地静态核心兜底文件(开发维护),不包含任何API数据;API数据是远程全量精准数据(翻译团队维护),二者仅在本地开发(API缺失补全)和API故障(兜底)时产生动态合并,正常流程无任何交集,彻底解决此前的认知混淆。
一、核心设计原则(贴合公司实际,每一条均对应业务痛点)
所有设计均围绕「文案准确性(翻译团队)、开发效率(前端)、性能(用户)、可用性(生产)」四大核心,拒绝纯技术堆砌,每一条原则均明确设计初衷和落地价值。
-
环境差异化加载(核心原则)
-
设计初衷:解决「翻译团队统一维护需准确性」与「前端开发需高效补全」的矛盾
-
具体规则:本地开发(dev)「API数据覆盖+本地Baseline补全」,测试/生产(test/prod)「统一走API拉取,本地Baseline仅故障兜底」
-
核心价值:既保证测试/生产文案由翻译团队统一维护,避免本地文案污染线上;又保证本地开发无需等待API同步,提升开发效率
-
-
纯服务端水合(客户端零请求)
-
设计初衷:解决客户端单独请求翻译导致的二次网络开销、首屏渲染慢、服务端与客户端文案不一致问题
-
具体规则:客户端不发起任何翻译相关API请求,完全复用服务端拼接好的翻译JSON(注入全局变量),通过统一Hook读取使用
-
核心价值:减少客户端网络开销,提升首屏渲染速度,保证全链路文案一致性,降低客户端代码复杂度
-
-
按需精准加载(最小化传输体积)
-
设计初衷:解决20+语言全量加载导致的传输体积大、加载慢,以及单路由无需其他路由文案的冗余问题
-
具体规则:服务端仅拉取「当前语言+当前路由+common公共层」的翻译数据,拒绝全量加载任何无关语言/路由的文案
-
核心价值:最小化翻译数据传输体积,提升API拉取速度和缓存命中率,适配20+语言的规模化扩展
-
-
LRU+Redis双层缓存(高性能+高可用)
-
设计初衷:解决API拉取频率高、响应慢,以及集群部署时缓存不一致、单实例缓存失效导致的性能瓶颈问题
-
具体规则:服务端一级缓存(LRU内存缓存,单实例高性能)+ 二级缓存(Redis分布式缓存,集群一致性),缓存粒度精准到「语言+路由」,配置合理过期时间和主动失效策略
-
核心价值:缓存命中率>90%,单实例缓存读取耗时<1ms,集群部署缓存一致,翻译更新及时失效,避免脏数据
-
-
本地Baseline备用(兜底+补全,不影响线上)
-
设计初衷:解决本地开发API未同步文案导致的开发阻塞,以及生产/测试API故障导致的页面空白、功能不可用问题
-
具体规则:Baseline是纯本地静态文件,仅存核心兜底文案(首屏标题、关键按钮),不包含任何API数据;仅在「本地开发API缺失」和「API故障」时生效,正常流程(测试/生产)完全隔离
-
核心价值:本地开发无阻塞,生产/测试API故障时核心功能正常,不影响线上文案准确性,兼顾开发效率和生产可用性
-
-
工程化提效(标准化+自动化,减少手动操作)
-
设计初衷:解决20+语言手动新建文件繁琐、易缺失,开发与翻译团队协作混乱,线上易出现文案缺失、格式错误等问题
-
具体规则:标准化目录/命名/key规范,自动化脚本生成多语言空文件、校验文件完整性,git hooks拦截错误提交,类型安全保证开发无拼写错误
-
核心价值:减少80%手动操作,从源头规避线上文案相关问题,规范开发与翻译团队协作流程,提升团队效率
-
二、核心落地步骤(详细可落地,含完整配置+代码片段+团队协作)
本章节按「基础配置→核心逻辑→工程化→优化→协作」的顺序,拆解8个核心步骤,每个步骤均包含「操作细节+代码示例+注意事项+责任分工」,前端开发、翻译团队、运维均可对照执行。
步骤1:标准化翻译文件目录&命名(一劳永逸,开发+翻译+API侧统一)
核心目标:统一本地Baseline、API侧、翻译团队的目录结构和命名规范,确保三者无缝对接,避免因结构不一致导致的加载/合并失败,支持20+语言无缝扩展。
责任分工:前端开发负责本地Baseline目录搭建和规范定义,翻译团队负责按规范维护API侧文案,运维负责API侧目录部署和CDN配置。
1.1 本地Baseline目录结构(仅存核心兜底文案,体积极小)
# 项目本地目录(仅前端开发维护,不上线核心逻辑,仅作为兜底/开发补全)
locales/
├─ baseline/ # 本地Baseline根目录(所有语言整体体积<200KB)
│ ├─ common/ # 公共通用层:所有页面共享的核心文案(按钮、提示、导航)
│ │ ├─ en.json # 英文核心文案(仅核心key,无次要说明)
│ │ ├─ zh-CN.json # 中文核心文案(与en.json的key完全对应,数量一致)
│ │ ├─ ja.json # 日文核心文案(同上,所有语言key统一)
│ │ └─ ...(20+语言) # 按ISO 639-1标准码命名(如ko/fr/de/es等)
│ ├─ home/ # 路由层:首页首屏必显的核心文案
│ │ ├─ en.json
│ │ ├─ zh-CN.json
│ │ └─ ...(20+语言)
│ ├─ trade/ # 路由层:交易页核心文案(与项目实际路由一一对应)
│ │ ├─ en.json
│ │ └─ ...(20+语言)
│ └─ [route]/ # 其他业务路由:按项目路由结构逐一创建(如order/user/pay等)
└─ config/ # 国际化相关配置文件(前端开发维护)
├─ locale.config.js# 语言+路由配置(新增语言/路由仅改此文件)
├─ core-keys.config.js # 核心key配置(定义哪些key需要放入Baseline)
└─ i18n.types.ts # 翻译key类型定义(类型安全用)
1.2 API侧目录结构(翻译团队维护,全量精准文案)
与本地Baseline目录结构完全对称,确保服务端拉取、合并逻辑统一,API侧支持按「语言+路由」按需返回数据,部署在CDN/API服务端,开启gzip/brotli压缩。
# API侧/CDN目录(翻译团队维护,前端不直接修改,通过API拉取)
api-domain/locales/ # 或 cdn-domain/locales/
├─ common/ # 与本地baseline/common完全对称,存全量公共文案
│ ├─ en.json # 英文全量文案(包含核心key+次要说明,覆盖Baseline的key)
│ ├─ zh-CN.json # 中文全量文案(翻译团队维护,精准规范)
│ └─ ...(20+语言)
├─ home/ # 与本地baseline/home完全对称,存首页全量文案
│ ├─ en.json
│ └─ ...(20+语言)
├─ trade/ # 与本地baseline/trade完全对称,存交易页全量文案
│ ├─ en.json
│ └─ ...(20+语言)
└─ [route]/ # 其他业务路由:与本地baseline完全对称
1.3 命名规范(严格遵循,避免混乱)
-
语言命名:采用「ISO 639-1标准码」,如中文(zh-CN)、英文(en)、日文(ja)、韩文(ko),禁止自定义命名(如中文不用cn/zh,英文不用english);
-
路由命名:与Next.js项目路由完全一致(如pages/home.tsx对应home目录,pages/trade/order.tsx对应trade目录),禁止大小写混用;
-
文件命名:每个语言的文案文件命名为「{locale}.json」(如zh-CN.json),禁止添加额外后缀(如zh-CN-baseline.json);
-
key命名:采用「小写字母+下划线」命名法,跨语言/跨路由统一,格式为「{模块}{功能}{描述}」(如common_submit_btn、home_index_title),禁止使用中文、大写字母、特殊符号,禁止重复命名。
1.4 示例文件内容(明确Baseline与API侧的区别)
本地Baseline示例(仅核心key,简洁兜底)
# locales/baseline/zh-CN/home.json
{
"home_index_title": "首页", # 首屏核心标题(兜底必显)
"home_submit_btn": "提交", # 关键按钮(核心功能)
"home_empty_tip": "暂无数据", # 通用提示(核心体验)
"home_back_btn": "返回" # 关键按钮(核心功能)
}
API侧示例(全量key,精准详细,翻译团队维护)
# api-domain/locales/home/zh-CN.json
{
"home_index_title": "XX平台-首页(官方正版)", # 覆盖Baseline,更精准
"home_submit_btn": "立即提交(提交后不可修改)", # 覆盖Baseline,补充说明
"home_empty_tip": "暂无相关数据,点击右上角刷新重试", # 覆盖Baseline,更详细
"home_back_btn": "返回上一页", # 覆盖Baseline,补充说明
"home_desc": "本平台提供XX服务,专注于XX领域,为用户提供优质体验", # API独有,次要说明
"home_tab1": "首页",
"home_tab2": "产品中心",
"home_footer_tip": "版权所有 © 2026 XX公司" # API独有,次要内容
}
1.5 关键配置文件(locale.config.js,一劳永逸)
# locales/config/locale.config.js
// 支持的语言列表(ISO 639-1标准码,新增语言仅添加此数组)
export const SUPPORTED_LOCALES = [
'en', 'zh-CN', 'ja', 'ko', 'fr', 'de', 'es', 'ru', 'pt', 'it',
// ... 其余10+语言,按实际需求添加
];
// 默认语言(API故障/未配置语言时兜底)
export const DEFAULT_LOCALE = 'en';
// 项目所有路由列表(与Next.js路由一致,新增路由仅添加此数组)
export const SUPPORTED_ROUTES = [
'home', 'trade', 'order', 'user', 'pay', 'detail',
// ... 其余业务路由
];
// API侧翻译数据请求地址(前端开发无需修改,运维配置)
export const API_I18N_BASE_URL = process.env.NEXT_PUBLIC_API_I18N_BASE_URL || 'https://api-domain/locales';
// 本地Baseline根目录路径(前端开发无需修改)
export const BASELINE_DIR = process.env.NODE_ENV === 'development'
? './locales/baseline'
: path.resolve(process.cwd(), 'locales/baseline');
步骤2:翻译文件拆分规则(开发+翻译团队统一遵循,避免冲突)
核心目标:明确common公共层、路由层的文案拆分标准,区分Baseline核心key与API全量key,避免冗余、缺失,确保开发与翻译团队协作顺畅。
责任分工:前端开发定义拆分规则和核心key列表,翻译团队按规则维护全量文案,确保API侧文案覆盖Baseline核心key。
2.1 拆分核心原则
-
「common公共层」:所有页面都会用到的文案,仅保留核心短文案作为Baseline,API侧补充完整说明,禁止存放路由专属文案;
-
「路由层」:每个路由仅存放自身页面的文案,按「首屏必显(核心)+ 次要说明(非核心)」拆分,Baseline仅存首屏必显,API侧存全量;
-
「key唯一性」:同一key不能出现在不同路由目录下,common层与路由层key无交集,避免合并时冲突;
-
「Baseline子集原则」:Baseline的key必须是API侧key的子集,确保API数据能完全覆盖Baseline,避免合并时出现数据混乱。
2.2 各层级具体拆分标准
2.2.1 common公共层拆分(所有页面共享)
| 类型 | 包含文案 | Baseline是否包含 | API侧是否包含 | 示例 |
|---|---|---|---|---|
| 关键按钮 | 提交、取消、确认、返回、刷新 | 是(核心) | 是(覆盖+补充说明) | common_submit_btn: "提交"(Baseline)→ "立即提交(不可修改)"(API) |
| 通用提示 | 网络错误、加载中、暂无数据、操作成功/失败 | 是(核心) | 是(覆盖+补充说明) | common_network_error: "网络错误"(Baseline)→ "网络异常,请检查网络后重试"(API) |
| 导航栏 | 首页、我的、消息、设置等导航标题 | 是(核心) | 是(覆盖,无需额外说明) | common_nav_home: "首页"(Baseline/API一致) |
| 次要提示 | 版权信息、隐私提示、操作说明 | 否(非核心) | 是(仅API侧维护) | common_copyright: "版权所有 © 2026 XX公司"(仅API) |
2.2.2 路由层拆分(单个路由专属)
| 类型 | 包含文案 | Baseline是否包含 | API侧是否包含 | 示例(home路由) |
|---|---|---|---|---|
| 首屏标题 | 页面主标题、副标题(首屏必显) | 是(核心) | 是(覆盖+补充) | home_index_title: "首页"(Baseline)→ "XX平台-首页(官方正版)"(API) |
| 核心操作区 | 页面核心按钮、输入框提示、操作说明(核心功能) | 是(核心) | 是(覆盖+补充) | home_search_placeholder: "搜索"(Baseline)→ "搜索产品/服务,快速找到所需内容"(API) |
| 次要内容区 | 详情描述、标签、次要提示(非首屏必显) | 否(非核心) | 是(仅API侧维护) | home_desc: "本平台提供XX服务,专注于XX领域"(仅API) |
| 底部内容 | 页面底部提示、链接文案(非核心) | 否(非核心) | 是(仅API侧维护) | home_footer_tip: "如有疑问,请联系客服:400-XXXX-XXXX"(仅API) |
2.3 体积限制标准(确保Baseline体积极小)
-
common公共层Baseline:单个语言文件<50KB(仅存核心key,约50-80个key);
-
单个路由Baseline:单个语言文件<10KB(仅存首屏必显key,约20-30个key);
-
所有语言Baseline整体体积<200KB(20+语言,平均每个语言<10KB);
-
API侧单文件体积:无严格限制,但建议单个语言+单个路由<50KB,开启gzip后可压缩至20KB以内。
2.4 核心key配置(core-keys.config.js,前端维护)
定义哪些key需要放入Baseline,翻译团队按此列表确保API侧覆盖这些key,脚本按此列表自动拆分Baseline(后续脚本会用到)。
# locales/config/core-keys.config.js
// 核心key配置:按「common/路由」分类,仅放入Baseline需要的核心key
export const CORE_KEYS = {
// common公共层核心key
common: [
'common_submit_btn', 'common_cancel_btn', 'common_confirm_btn',
'common_back_btn', 'common_refresh_btn', 'common_network_error',
'common_loading', 'common_empty_tip', 'common_success_tip',
'common_fail_tip', 'common_nav_home', 'common_nav_user',
// ... 其他common核心key
],
// home路由核心key
home: [
'home_index_title', 'home_submit_btn', 'home_empty_tip',
'home_back_btn', 'home_search_placeholder',
// ... 其他home核心key
],
// trade路由核心key
trade: [
'trade_index_title', 'trade_pay_btn', 'trade_cancel_btn',
// ... 其他trade核心key
],
// ... 其他路由的核心key配置
};
步骤3:服务端核心加载逻辑(NestJS+Next.js Server,核心中的核心)
核心目标:实现「缓存优先→API拉取→Baseline兜底/合并→数据注入」的全流程,适配环境差异化加载,确保性能、准确性和可用性,补充完整代码片段,可直接复制落地。
责任分工:前端开发负责Next.js Server Component和NestJS服务封装,运维负责缓存和API服务部署。
核心逻辑拆解:服务端接收请求后,先解析语言和路由,再按「LRU→Redis→API→Baseline」的顺序获取翻译数据,最后拼接数据并注入客户端,全程耗时控制在10ms以内(P99)。
3.1 前置准备:依赖安装
# 安装核心依赖(NestJS/Next.js通用)
npm install lru-cache ioredis jsonminify axios
npm install -D @types/lru-cache @types/ioredis
3.2 工具函数封装(读取Baseline+合并数据+语言解析)
封装通用工具函数,与业务逻辑解耦,便于复用和维护,重点区分Baseline和API数据的合并逻辑,明确API数据优先级高于Baseline。
3.2.1 语言解析工具(src/utils/i18n/localeParser.ts)
// src/utils/i18n/localeParser.ts
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '@/locales/config/locale.config';
import type { NextRequest } from 'next/server';
import type { Request } from '@nestjs/common';
/**
* 解析用户语言(适配Next.js Request和NestJS Request)
* 优先级:URL参数(?locale=zh-CN)> Cookie(locale=zh-CN)> 请求头(Accept-Language)> 默认语言(en)
* @param req Next.js Request / NestJS Request
* @returns 解析后的语言ISO码(如zh-CN/en)
*/
export const parseLocale = (req: NextRequest | Request): string => {
// 1. 从URL参数解析
let locale = '';
if (req instanceof NextRequest) {
// Next.js Request
locale = req.nextUrl.searchParams.get('locale') || '';
} else {
// NestJS Request
const query = req.query as Record<string, string>;
locale = query.locale || '';
}
if (SUPPORTED_LOCALES.includes(locale)) {
return locale;
}
// 2. 从Cookie解析
const cookieStr = req.headers.cookie || '';
const localeCookie = cookieStr.split('; ').find(item => item.startsWith('locale='));
if (localeCookie) {
const cookieLocale = localeCookie.split('=')[1];
if (SUPPORTED_LOCALES.includes(cookieLocale)) {
return cookieLocale;
}
}
// 3. 从请求头Accept-Language解析
const acceptLanguage = req.headers['accept-language'] || '';
if (typeof acceptLanguage === 'string') {
const headerLocale = acceptLanguage.split(',')[0].split(';')[0];
// 处理如zh-CN、zh的情况,匹配SUPPORTED_LOCALES
const matchedLocale = SUPPORTED_LOCALES.find(loc =>
loc.startsWith(headerLocale.split('-')[0]) || headerLocale.startsWith(loc.split('-')[0])
);
if (matchedLocale) {
return matchedLocale;
}
}
// 4. 兜底返回默认语言
return DEFAULT_LOCALE;
};
3.2.2 Baseline读取与数据合并工具(src/utils/i18n/baseline.ts)
// src/utils/i18n/baseline.ts
import fs from 'fs';
import path from 'path';
import jsonminify from 'jsonminify';
import { BASELINE_DIR, SUPPORTED_LOCALES, DEFAULT_LOCALE } from '@/locales/config/locale.config';
import { CORE_KEYS } from '@/locales/config/core-keys.config';
/**
* 读取本地Baseline翻译数据(仅核心key)
* @param locale 语言ISO码(zh-CN/en)
* @param routes 需要读取的路由列表(如['common', 'home'])
* @returns 合并后的本地Baseline数据 { common: {}, home: {} }
*/
export const getLocalBaseline = (locale: string, routes: string[]): Record<string, any> => {
try {
// 校验语言是否支持,不支持则降级到默认语言
const validLocale = SUPPORTED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
const result = {};
for (const route of routes) {
// 拼接Baseline文件路径
const filePath = path.join(BASELINE_DIR, route, `${validLocale}.json`);
// 若文件不存在,降级到默认语言的对应文件
const finalFilePath = fs.existsSync(filePath) ? filePath : path.join(BASELINE_DIR, route, `${DEFAULT_LOCALE}.json`);
// 读取文件并压缩(移除空格/换行,减小内存占用)
let fileContent = fs.readFileSync(finalFilePath, 'utf-8');
fileContent = jsonminify(fileContent) || fileContent; // 极致压缩
// 解析JSON,仅保留核心key(防止开发误加非核心key)
const fullBaseline = JSON.parse(fileContent);
const coreKeys = CORE_KEYS[route as keyof typeof CORE_KEYS] || [];
const coreBaseline = coreKeys.reduce((obj, key) => {
if (fullBaseline[key] !== undefined) {
obj[key] = fullBaseline[key];
}
return obj;
}, {} as Record<string, string>);
result[route] = coreBaseline;
}
return result;
} catch (err) {
// 读取失败时返回空对象,避免程序崩溃,同时上报监控
console.error('[getLocalBaseline] 读取本地Baseline失败:', err);
return {};
}
};
/**
* 合并API数据与本地Baseline数据(API数据优先级高于Baseline)
* 核心规则:API有则用API,API无则用Baseline,二者都无则返回key本身
* @param apiData API返回的翻译数据({ common: {}, [route]: {} })
* @param baselineData 本地Baseline数据({ common: {}, [route]: {} })
* @returns 合并后的最终翻译数据
*/
export const mergeI18nData = (
apiData: Record<string, any>,
baselineData: Record<string, any>
): Record<string, any> => {
// 递归合并,确保嵌套结构也能正确覆盖(若有嵌套key)
const deepMerge = (apiObj: any, baselineObj: any): any => {
if (typeof apiObj !== 'object' || apiObj === null) {
return apiObj ?? baselineObj ?? '';
}
const merged = { ...baselineObj, ...apiObj };
Object.keys(merged).forEach(key => {
merged[key] = deepMerge(apiObj[key], baselineObj[key]);
});
return merged;
};
// 遍历所有路由,合并对应的数据
const mergedResult = {};
const allRoutes = [...new Set([...Object.keys(apiData), ...Object.keys(baselineData)])];
allRoutes.forEach(route => {
const apiRouteData = apiData[route] || {};
const baselineRouteData = baselineData[route] || {};
mergedResult[route] = deepMerge(apiRouteData, baselineRouteData);
});
return mergedResult;
};
3.2.3 API请求工具(src/api/i18n.ts)
// src/api/i18n.ts
import axios from 'axios';
import { API_I18N_BASE_URL, SUPPORTED_LOCALES, SUPPORTED_ROUTES } from '@/locales/config/locale.config';
/**
* 从API拉取翻译数据(按需拉取:当前语言+指定路由)
* @param locale 语言ISO码(zh-CN/en)
* @param routes 需要拉取的路由列表(如['common', 'home'])
* @returns API返回的全量翻译数据 { common: {}, home: {} }
*/
export const fetchI18nFromApi = async (locale: string, routes: string[]): Promise<Record<string, any>> => {
// 校验参数合法性,避免无效请求
if (!SUPPORTED_LOCALES.includes(locale)) {
throw new Error(`不支持的语言:${locale}`);
}
const validRoutes = routes.filter(route => SUPPORTED_ROUTES.includes(route));
if (validRoutes.length === 0) {
throw new Error(`无有效路由:${routes.join(',')}`);
}
try {
// 并发拉取指定路由的翻译数据(提升效率)
const requests = validRoutes.map(route => {
const url = `${API_I18N_BASE_URL}/${route}/${locale}.json`;
return axios.get(url, {
headers: {
'Accept-Encoding': 'gzip, deflate, br' // 开启压缩传输
},
timeout: 3000, // 超时时间3s,避免阻塞服务
responseType: 'json'
});
});
const responses = await Promise.all(requests);
const result = {};
validRoutes.forEach((route, index) => {
result[route] = responses[index].data;
});
return result;
} catch (err) {
// 请求失败(超时/404/500),抛出错误,触发兜底逻辑
console.error(`[fetchI18nFromApi] 拉取API翻译数据失败(locale: ${locale}, routes: ${validRoutes.join(',')}):`, err);
throw err;
}
};
3.3 缓存实例初始化(src/cache/index.ts)
// src/cache/index.ts
import LRU from 'lru-cache';
import Redis from 'ioredis';
// 1. LRU内存缓存实例(一级缓存,单实例高性能)
export const lruCache = new LRU({
max: 1024 * 1024 * 100, // 缓存容量:100MB(足够存储20+语言×所有路由的翻译数据)
maxAge: 1000 * 60 * 15, // 15分钟过期(被动淘汰,主动失效优先)
updateAgeOnGet: true, // 获取缓存时更新过期时间,提升常用数据命中率
});
// 2. Redis分布式缓存实例(二级缓存,集群部署)
export const redisCache = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD,
db: Number(process.env.REDIS_DB) || 0,
connectTimeout: 3000, // 连接超时3s
retryStrategy: (times) => {
// 重试策略:失败后重试,最多重试3次,每次间隔1s
if (times > 3) {
return null; // 停止重试
}
return 1000;
},
});
// 缓存key生成工具(统一格式,便于管理和失效)
export const generateCacheKey = (locale: string, route: string): string => {
return `i18n:${locale}:${route}`;
};
3.4 核心服务封装(NestJS服务,Next.js可直接调用)
// src/services/i18n.service.ts(NestJS服务)
import { Injectable, Logger } from '@nestjs/common';
import { lruCache, redisCache, generateCacheKey } from '@/cache';
import { fetchI18nFromApi } from '@/api/i18n';
import { getLocalBaseline, mergeI18nData } from '@/utils/i18n/baseline';
import { reportMonitor } from '@/utils/monitor'; // 监控上报工具(自行实现)
import { SUPPORTED_ROUTES } from '@/locales/config/locale.config';
@Injectable()
export class I18nService {
private readonly logger = new Logger(I18nService.name);
private readonly CACHE_EXPIRE = 3600; // Redis缓存过期时间:1h(平衡命中率和更新及时性)
/**
* 核心方法:获取翻译数据(适配所有环境,集成缓存+API+Baseline兜底)
* @param locale 语言ISO码(zh-CN/en)
* @param route 当前路由(home/trade/order)
* @returns 最终翻译数据(供服务端注入客户端)
*/
async getI18nData(locale: string, route: string): Promise<Record<string, any>> {
// 校验当前路由是否有效
if (!SUPPORTED_ROUTES.includes(route)) {
this.logger.warn(`[getI18nData] 无效路由:${route},降级到默认路由home`);
route = 'home';
}
const needRoutes = ['common', route]; // 仅拉取common+当前路由,按需加载
const isDev = process.env.APP_ENV === 'development';
// 步骤1:优先从LRU内存缓存读取(一级缓存,最快)
const lruCacheKeys = needRoutes.map(r => generateCacheKey(locale, r));
const lruData = this.getFromLruCache(lruCacheKeys, needRoutes);
if (lruData) {
this.logger.debug(`[getI18nData] LRU缓存命中(locale: ${locale}, route: ${route})`);
return lruData;
}
// 步骤2:LRU未命中,从Redis读取(二级缓存,集群一致)
const redisData = await this.getFromRedisCache(locale, needRoutes);
if (redisData) {
this.logger.debug(`[getI18nData] Redis缓存命中(locale: ${locale}, route: ${route})`);
// 更新LRU缓存,提升后续读取效率
this.setToLruCache(locale, needRoutes, redisData);
return redisData;
}
// 步骤3:双层缓存未命中,拉取API数据(核心数据源)
let apiData = {};
let finalData = {};
try {
apiData = await fetchI18nFromApi(locale, needRoutes);
this.logger.debug(`[getI18nData] API拉取成功(locale: ${locale}, route: ${route})`);
// 步骤4:本地开发环境,合并Baseline补全API缺失数据
if (isDev) {
const baselineData = getLocalBaseline(locale, needRoutes);
finalData = mergeI18nData(apiData, baselineData);
} else {
// 测试/生产环境,直接使用API数据,不合并Baseline
finalData = apiData;
}
// 步骤5:更新双层缓存(仅缓存API正常返回的数据,避免脏数据)
this.setToLruCache(locale, needRoutes, finalData);
await this.setToRedisCache(locale, needRoutes, finalData);
return finalData;
} catch (err) {
// 步骤6:API拉取失败,触发Baseline兜底(所有环境均生效)
this.logger.error(`[getI18nData] API拉取失败,触发Baseline兜底(locale: ${locale}, route: ${route})`, err.stack);
// 上报监控告警(附带环境、语言、路由、错误信息,便于排查)
reportMonitor({
type: 'i18n_api_error',
env: process.env.APP_ENV,
locale,
route,
error: err.message,
timestamp: new Date().getTime(),
});
// 读取本地Baseline作为兜底数据
const baselineData = getLocalBaseline(locale, needRoutes);
finalData = baselineData;
// 兜底数据不写入缓存,避免脏数据(API恢复后需重新拉取)
return finalData;
}
}
/**
* 从LRU缓存读取数据(批量读取common+当前路由)
*/
private getFromLruCache(cacheKeys: string[], routes: string[]): Record<string, any> | null {
const data = {};
for (let i = 0; i < cacheKeys.length; i++) {
const key = cacheKeys[i];
const route = routes[i];
const routeData = lruCache.get(key);
if (!routeData) {
return null; // 有一个路由未命中,视为整体未命中
}
data[route] = routeData;
}
return data;
}
/**
* 写入LRU缓存(批量写入common+当前路由)
*/
private setToLruCache(locale: string, routes: string[], data: Record<string, any>): void {
routes.forEach(route => {
const cacheKey = generateCacheKey(locale, route);
const routeData = data[route] || {};
lruCache.set(cacheKey, routeData);
});
}
/**
* 从Redis缓存读取数据(批量读取common+当前路由)
*/
private async getFromRedisCache(locale: string, routes: string[]): Promise<Record<string, any> | null> {
const data = {};
for (const route of routes) {
const cacheKey = generateCacheKey(locale, route);
const routeDataStr = await redisCache.get(cacheKey);
if (!routeDataStr) {
return null; // 有一个路由未命中,视为整体未命中
}
data[route] = JSON.parse(routeDataStr);
}
return data;
}
/**
* 写入Redis缓存(批量写入common+当前路由)
*/
private async setToRedisCache(locale: string, routes: string[], data: Record<string, any>): Promise<void> {
const pipeline = redisCache.pipeline(); // 管道操作,提升效率
routes.forEach(route => {
const cacheKey = generateCacheKey(locale, route);
const routeData = data[route] || {};
pipeline.set(cacheKey, JSON.stringify(routeData), 'EX', this.CACHE_EXPIRE);
});
await pipeline.exec();
}
/**
* 主动清除缓存(翻译更新时调用,避免脏数据)
* @param locale 语言ISO码(可选,不传递则清除所有语言)
* @param route 路由(可选,不传递则清除所有路由)
*/
async clearCache(locale?: string, route?: string): Promise<void> {
// 清除LRU缓存
if (locale && route) {
// 清除指定语言+指定路由的LRU缓存
const cacheKey = generateCacheKey(locale, route);
lruCache.delete(cacheKey);
// 同时清除common的缓存(若有依赖)
const commonCacheKey = generateCacheKey(locale, 'common');
lruCache.delete(commonCacheKey);
} else if (locale) {
// 清除指定语言的所有路由LRU缓存
lruCache.forEach((_, key) => {
if (key.startsWith(`i18n:${locale}:`)) {
lruCache.delete(key);
}
});
} else {
// 清除所有LRU缓存
lruCache.reset();
}
// 清除Redis缓存
if (locale && route) {
// 清除指定语言+指定路由的Redis缓存
const cacheKey = generateCacheKey(locale, route);
await redisCache.del(cacheKey);
const commonCacheKey = generateCacheKey(locale, 'common');
await redisCache.del(commonCacheKey);
} else if (locale) {
// 清除指定语言的所有路由Redis缓存
(注:文档部分内容可能由 AI 生成)