设计多语言管理系统
需求分析
功能需求
- 语言切换:用户可在页面内无刷新切换语言,切换后所有文案实时更新
- 文案管理:支持按模块/页面组织翻译文案,支持嵌套 key 和命名空间
- 翻译工作流:翻译人员在线编辑、审核、发布,支持版本控制和回滚
- 动态加载:语言包按需加载,避免首屏加载全量翻译文案
- 格式化能力:
- 复数:
1 itemvs2 items(不同语言复数规则不同) - 日期/时间:
2026-02-27vsFeb 27, 2026vs27/02/2026 - 货币:
¥100.00vs$100.00vs€100,00 - 性别:
He liked your photovsShe liked your photo
- 复数:
非功能需求
| 需求 | 说明 |
|---|---|
| 按需加载 | 单个语言包体积 < 50KB,首屏只加载当前语言 |
| SEO 友好 | 每种语言有独立 URL,支持 hreflang 标签 |
| 开发体验 | 类型安全的翻译 key、IDE 自动补全、缺失 key 提示 |
| 大规模管理 | 支持 10 万+ 翻译条目、50+ 语言的高效管理 |
| 可扩展性 | 易于接入机器翻译、AI 翻译、翻译记忆库 |
面试中讨论 i18n 系统设计时,面试官最关注三个方面:运行时性能(语言包加载策略)、开发体验(类型安全、工作流)、可扩展性(大规模翻译管理)。
整体架构
整体架构分为三层:前端 SDK 负责运行时翻译和格式化;翻译管理平台(TMS) 提供翻译人员的工作界面和工作流;CI/CD 集成 打通代码仓库和翻译平台的双向同步。
核心模块设计
1. 前端 i18n SDK
核心数据结构
/** 支持的语言代码 */
type Locale = 'zh-CN' | 'en-US' | 'ja-JP' | 'ko-KR' | 'ar-SA';
/** 嵌套翻译资源 */
interface TranslationResource {
[key: string]: string | TranslationResource;
}
/** 命名空间翻译 */
interface NamespacedResource {
[namespace: string]: TranslationResource;
}
/** i18n 配置 */
interface I18nConfig {
defaultLocale: Locale;
fallbackLocale: Locale;
supportedLocales: Locale[];
/** 按需加载函数 */
loadResource: (locale: Locale, namespace: string) => Promise<TranslationResource>;
/** 语言检测策略链 */
detection: DetectionStrategy[];
/** 缺失 key 的处理 */
missingKeyHandler?: (locale: Locale, key: string) => string;
}
/** 语言检测策略 */
type DetectionStrategy = 'url' | 'cookie' | 'localStorage' | 'navigator' | 'header';
/** ICU MessageFormat 插值参数 */
interface InterpolationParams {
[key: string]: string | number | Date;
}
ICU MessageFormat 支持
ICU MessageFormat 是国际化文案的行业标准,支持复数、性别、选择等复杂场景:
{
"greeting": "Hello, {name}!",
"items": "{count, plural, =0 {No items} one {1 item} other {{count} items}}",
"gender": "{gender, select, male {He} female {She} other {They}} liked your photo",
"richText": "Please <bold>confirm</bold> your <link>email address</link>"
}
{
"greeting": "你好,{name}!",
"items": "{count, plural, =0 {没有项目} other {{count} 个项目}}",
"gender": "{gender, select, male {他} female {她} other {Ta}} 点赞了你的照片",
"richText": "请<bold>确认</bold>你的<link>邮箱地址</link>"
}
中文没有单复数之分,但日语、阿拉伯语等有复杂的复数规则。使用 ICU MessageFormat 可以让翻译人员为每种语言独立处理复数形式,而不需要开发者在代码中用 if-else 判断。
2. 语言检测与路由
三种 URL 方案对比
| 方案 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径前缀 | example.com/zh/about | SEO 最优、易于实现 | URL 较长 |
| 子域名 | zh.example.com/about | URL 简洁、CDN 灵活 | DNS 配置复杂、Cookie 共享问题 |
| Cookie / Header | example.com/about | URL 不变、体验好 | SEO 不友好、不可分享特定语言 |
URL 路径前缀是目前最主流的方案,Next.js、Nuxt 等框架均原生支持。既保证 SEO 友好,又便于用户分享特定语言的链接。
Middleware 语言检测链
检测链按优先级依次执行,命中后停止:
import { type NextRequest, NextResponse } from 'next/server';
const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ja-JP'] as const;
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
const DEFAULT_LOCALE: SupportedLocale = 'zh-CN';
const COOKIE_NAME = 'NEXT_LOCALE';
/** 从 URL 路径提取 locale */
function getLocaleFromPath(pathname: string): SupportedLocale | null {
const segments = pathname.split('/');
const maybeLocale = segments[1];
return SUPPORTED_LOCALES.includes(maybeLocale as SupportedLocale)
? (maybeLocale as SupportedLocale)
: null;
}
/** 解析 Accept-Language Header */
function getLocaleFromHeader(acceptLanguage: string | null): SupportedLocale {
if (!acceptLanguage) return DEFAULT_LOCALE;
// 解析 "zh-CN,zh;q=0.9,en;q=0.8" 格式
const languages = acceptLanguage
.split(',')
.map((lang) => {
const [code, q] = lang.trim().split(';q=');
return { code: code.trim(), quality: q ? parseFloat(q) : 1.0 };
})
.sort((a, b) => b.quality - a.quality);
for (const { code } of languages) {
// 精确匹配
if (SUPPORTED_LOCALES.includes(code as SupportedLocale)) {
return code as SupportedLocale;
}
// 前缀匹配: "zh" -> "zh-CN"
const match = SUPPORTED_LOCALES.find((l) => l.startsWith(code.split('-')[0]));
if (match) return match;
}
return DEFAULT_LOCALE;
}
export function middleware(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
// 1. URL 路径中已有 locale
const pathLocale = getLocaleFromPath(pathname);
if (pathLocale) {
const response = NextResponse.next();
response.cookies.set(COOKIE_NAME, pathLocale, { maxAge: 365 * 24 * 60 * 60 });
return response;
}
// 2. Cookie 中记住的 locale
const cookieLocale = request.cookies.get(COOKIE_NAME)?.value as SupportedLocale | undefined;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return NextResponse.redirect(new URL(`/${cookieLocale}${pathname}`, request.url));
}
// 3. Accept-Language Header
const headerLocale = getLocaleFromHeader(request.headers.get('accept-language'));
// 4. 重定向到检测到的 locale
const response = NextResponse.redirect(new URL(`/${headerLocale}${pathname}`, request.url));
response.cookies.set(COOKIE_NAME, headerLocale, { maxAge: 365 * 24 * 60 * 60 });
return response;
}
export const config = {
matcher: ['/((?!api|_next|favicon.ico|.*\\..*).*)'],
};
3. 翻译文案管理平台(TMS)
平台架构
翻译工作流状态机
核心数据模型
/** 翻译条目 */
interface TranslationEntry {
id: string;
/** 翻译 key,支持嵌套如 "common.button.submit" */
key: string;
/** 命名空间 */
namespace: string;
/** 各语言的翻译 */
translations: Record<string, LocaleTranslation>;
/** 标签,用于分组筛选 */
tags: string[];
/** 截图上下文 */
screenshots: string[];
createdAt: Date;
updatedAt: Date;
}
/** 单语言翻译 */
interface LocaleTranslation {
value: string;
status: 'draft' | 'machine_translated' | 'in_review' | 'approved' | 'published';
translator?: string;
reviewer?: string;
/** 翻译记忆匹配度 */
tmMatch?: number;
updatedAt: Date;
}
/** 翻译版本快照 */
interface TranslationSnapshot {
id: string;
locale: string;
namespace: string;
/** 完整的翻译内容快照 */
content: Record<string, string>;
version: string;
publishedAt: Date;
publishedBy: string;
}
4. 按需加载策略
语言包拆分维度
加载策略对比
| 策略 | 首屏加载 | 切换速度 | 实现复杂度 |
|---|---|---|---|
| 全量打包 | 慢(所有语言) | 即时 | 低 |
| 按语言拆分 | 中等(单语言全量) | 需加载 | 低 |
| 按语言 + 页面拆分 | 快(当前语言当前页) | 需加载 | 中 |
| CDN 动态加载 + 缓存 | 最快 | 缓存命中即时 | 高 |
interface LoaderConfig {
/** CDN 基础路径 */
cdnBase: string;
/** 版本号,用于缓存更新 */
version: string;
/** 缓存策略 */
cacheStrategy: 'memory' | 'localStorage' | 'indexedDB';
/** 预加载的命名空间 */
preloadNamespaces: string[];
}
class ResourceLoader {
private cache = new Map<string, TranslationResource>();
private loading = new Map<string, Promise<TranslationResource>>();
private config: LoaderConfig;
constructor(config: LoaderConfig) {
this.config = config;
}
/**
* 加载语言资源(带去重和缓存)
*/
async load(locale: string, namespace: string): Promise<TranslationResource> {
const cacheKey = `${locale}:${namespace}`;
// 1. 内存缓存命中
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
// 2. 正在加载中(请求去重)
if (this.loading.has(cacheKey)) {
return this.loading.get(cacheKey)!;
}
// 3. 本地持久化缓存
const persisted = await this.getPersistedCache(cacheKey);
if (persisted) {
this.cache.set(cacheKey, persisted);
return persisted;
}
// 4. 从 CDN 加载
const loadPromise = this.fetchFromCDN(locale, namespace);
this.loading.set(cacheKey, loadPromise);
try {
const resource = await loadPromise;
this.cache.set(cacheKey, resource);
await this.setPersistedCache(cacheKey, resource);
return resource;
} finally {
this.loading.delete(cacheKey);
}
}
/**
* 预加载指定语言的核心命名空间
*/
async preload(locale: string): Promise<void> {
const promises = this.config.preloadNamespaces.map((ns) => this.load(locale, ns));
await Promise.all(promises);
}
private async fetchFromCDN(locale: string, namespace: string): Promise<TranslationResource> {
const url = `${this.config.cdnBase}/${this.config.version}/${locale}/${namespace}.json`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load i18n resource: ${url}`);
}
return response.json();
}
private async getPersistedCache(key: string): Promise<TranslationResource | null> {
if (this.config.cacheStrategy === 'localStorage') {
const item = localStorage.getItem(`i18n:${this.config.version}:${key}`);
return item ? JSON.parse(item) : null;
}
// IndexedDB 实现省略
return null;
}
private async setPersistedCache(key: string, resource: TranslationResource): Promise<void> {
if (this.config.cacheStrategy === 'localStorage') {
localStorage.setItem(`i18n:${this.config.version}:${key}`, JSON.stringify(resource));
}
}
/**
* 版本更新时清理旧缓存
*/
clearStaleCache(currentVersion: string): void {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('i18n:') && !key.startsWith(`i18n:${currentVersion}:`)) {
localStorage.removeItem(key);
}
}
}
}
5. 格式化引擎
Intl API 封装
浏览器内置的 Intl API 提供了日期、数字、货币等本地化格式化能力:
class I18nFormatter {
private locale: string;
/** 缓存 Intl 实例,避免重复创建 */
private dateFormatters = new Map<string, Intl.DateTimeFormat>();
private numberFormatters = new Map<string, Intl.NumberFormat>();
private relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
private pluralRules: Intl.PluralRules;
constructor(locale: string) {
this.locale = locale;
this.pluralRules = new Intl.PluralRules(locale);
}
/** 日期格式化 */
formatDate(date: Date, style: 'short' | 'medium' | 'long' | 'full' = 'medium'): string {
const options: Record<string, Intl.DateTimeFormatOptions> = {
short: { year: 'numeric', month: 'numeric', day: 'numeric' },
medium: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric' },
full: { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' },
};
return this.getDateFormatter(style, options[style]).format(date);
}
/** 数字格式化 */
formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
const key = JSON.stringify(options ?? {});
if (!this.numberFormatters.has(key)) {
this.numberFormatters.set(key, new Intl.NumberFormat(this.locale, options));
}
return this.numberFormatters.get(key)!.format(value);
}
/** 货币格式化 */
formatCurrency(value: number, currency: string = 'CNY'): string {
return this.formatNumber(value, { style: 'currency', currency });
}
/** 相对时间格式化:"3 天前"、"2 小时后" */
formatRelativeTime(date: Date, baseDate: Date = new Date()): string {
const diffMs = date.getTime() - baseDate.getTime();
const absDiff = Math.abs(diffMs);
// 选择合适的时间单位
const units: Array<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
{ unit: 'year', ms: 365 * 24 * 60 * 60 * 1000 },
{ unit: 'month', ms: 30 * 24 * 60 * 60 * 1000 },
{ unit: 'week', ms: 7 * 24 * 60 * 60 * 1000 },
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
{ unit: 'hour', ms: 60 * 60 * 1000 },
{ unit: 'minute', ms: 60 * 1000 },
{ unit: 'second', ms: 1000 },
];
for (const { unit, ms } of units) {
if (absDiff >= ms) {
const value = Math.round(diffMs / ms);
return this.getRelativeFormatter(unit).format(value, unit);
}
}
return this.getRelativeFormatter('second').format(0, 'second');
}
/** 复数类别:zero | one | two | few | many | other */
getPluralCategory(count: number): Intl.LDMLPluralRule {
return this.pluralRules.select(count);
}
/** 列表格式化:"苹果、香蕉和橘子" */
formatList(items: string[], type: 'conjunction' | 'disjunction' = 'conjunction'): string {
return new Intl.ListFormat(this.locale, { type }).format(items);
}
private getDateFormatter(key: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
if (!this.dateFormatters.has(key)) {
this.dateFormatters.set(key, new Intl.DateTimeFormat(this.locale, options));
}
return this.dateFormatters.get(key)!;
}
private getRelativeFormatter(unit: string): Intl.RelativeTimeFormat {
if (!this.relativeFormatters.has(unit)) {
this.relativeFormatters.set(unit, new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' }));
}
return this.relativeFormatters.get(unit)!;
}
}
不同语言的格式化结果示例:
| 格式化 | zh-CN | en-US | ja-JP |
|---|---|---|---|
formatDate(date, 'long') | 2026年2月27日 | February 27, 2026 | 2026年2月27日 |
formatCurrency(1234.5, 'USD') | US$1,234.50 | $1,234.50 | $1,234.50 |
formatRelativeTime(-3天) | 3天前 | 3 days ago | 3日前 |
formatList(['A','B','C']) | A、B和C | A, B, and C | A、B、C |
RTL 布局支持
阿拉伯语、希伯来语等从右到左(RTL)书写的语言需要特殊处理:
const RTL_LOCALES = ['ar', 'ar-SA', 'he', 'he-IL', 'fa', 'fa-IR', 'ur'];
function isRTL(locale: string): boolean {
return RTL_LOCALES.some((rtl) => locale.startsWith(rtl));
}
/** 在 HTML 根元素设置 dir 属性 */
function applyDirection(locale: string): void {
const dir = isRTL(locale) ? 'rtl' : 'ltr';
document.documentElement.setAttribute('dir', dir);
document.documentElement.setAttribute('lang', locale);
}
/* 使用逻辑属性替代物理属性,自动适配 RTL */
.card {
/* 不要用 margin-left / margin-right */
margin-inline-start: 16px;
padding-inline-end: 24px;
/* 不要用 text-align: left */
text-align: start;
/* 不要用 border-left */
border-inline-start: 2px solid #eee;
}
RTL 适配的关键是使用 CSS 逻辑属性(inline-start/end、block-start/end)替代物理属性(left/right/top/bottom),这样布局会自动跟随文字方向翻转。
关键技术实现
I18n 核心类
type TranslationListener = (locale: string) => void;
class I18n {
private locale: string;
private resources = new Map<string, TranslationResource>();
private loader: ResourceLoader;
private formatter: I18nFormatter;
private listeners = new Set<TranslationListener>();
private config: I18nConfig;
constructor(config: I18nConfig) {
this.config = config;
this.locale = config.defaultLocale;
this.formatter = new I18nFormatter(this.locale);
this.loader = new ResourceLoader({
cdnBase: 'https://cdn.example.com/i18n',
version: '1.0.0',
cacheStrategy: 'localStorage',
preloadNamespaces: ['common'],
});
}
/** 初始化:检测语言并加载资源 */
async init(): Promise<void> {
const detected = this.detectLocale();
await this.changeLocale(detected);
}
/**
* 核心翻译函数
* @param key - 翻译 key,支持命名空间前缀 "ns:key.nested"
* @param params - 插值参数
*/
t(key: string, params?: InterpolationParams): string {
const { namespace, actualKey } = this.parseKey(key);
const resource = this.resources.get(`${this.locale}:${namespace}`);
if (!resource) {
return this.handleMissing(key);
}
// 获取嵌套值: "button.submit" -> resource.button.submit
const template = this.getNestedValue(resource, actualKey);
if (typeof template !== 'string') {
return this.handleMissing(key);
}
// ICU MessageFormat 插值
return params ? this.interpolate(template, params) : template;
}
/** 切换语言 */
async changeLocale(locale: string): Promise<void> {
if (!this.config.supportedLocales.includes(locale as Locale)) {
locale = this.config.fallbackLocale;
}
// 加载新语言的资源
await this.loader.preload(locale);
const namespaces = ['common']; // 核心命名空间
for (const ns of namespaces) {
const resource = await this.loader.load(locale, ns);
this.resources.set(`${locale}:${ns}`, resource);
}
this.locale = locale;
this.formatter = new I18nFormatter(locale);
applyDirection(locale);
// 通知所有监听者
this.listeners.forEach((listener) => listener(locale));
}
/** 动态加载命名空间 */
async loadNamespace(namespace: string): Promise<void> {
const resource = await this.loader.load(this.locale, namespace);
this.resources.set(`${this.locale}:${namespace}`, resource);
}
/** 注册语言变更监听 */
onLocaleChange(listener: TranslationListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
get currentLocale(): string {
return this.locale;
}
get format(): I18nFormatter {
return this.formatter;
}
/** 解析 key:"namespace:nested.key" -> { namespace, actualKey } */
private parseKey(key: string): { namespace: string; actualKey: string } {
const colonIndex = key.indexOf(':');
if (colonIndex > 0) {
return {
namespace: key.slice(0, colonIndex),
actualKey: key.slice(colonIndex + 1),
};
}
return { namespace: 'common', actualKey: key };
}
/** 获取嵌套对象的值 */
private getNestedValue(obj: TranslationResource, path: string): string | undefined {
const keys = path.split('.');
let current: string | TranslationResource = obj;
for (const key of keys) {
if (typeof current !== 'object' || current === null) return undefined;
current = (current as TranslationResource)[key];
}
return typeof current === 'string' ? current : undefined;
}
/** 简化的 ICU MessageFormat 插值 */
private interpolate(template: string, params: InterpolationParams): string {
return template.replace(/\{(\w+)\}/g, (_, key) => {
const value = params[key];
if (value === undefined) return `{${key}}`;
if (value instanceof Date) return this.formatter.formatDate(value);
if (typeof value === 'number') return this.formatter.formatNumber(value);
return String(value);
});
}
/** 语言检测 */
private detectLocale(): string {
for (const strategy of this.config.detection) {
const detected = this.detectByStrategy(strategy);
if (detected && this.config.supportedLocales.includes(detected as Locale)) {
return detected;
}
}
return this.config.defaultLocale;
}
private detectByStrategy(strategy: DetectionStrategy): string | null {
switch (strategy) {
case 'url': {
const match = window.location.pathname.match(/^\/([a-z]{2}-[A-Z]{2})\//);
return match?.[1] ?? null;
}
case 'cookie': {
const match = document.cookie.match(/NEXT_LOCALE=([^;]+)/);
return match?.[1] ?? null;
}
case 'localStorage':
return localStorage.getItem('i18n_locale');
case 'navigator':
return navigator.language;
default:
return null;
}
}
private handleMissing(key: string): string {
if (this.config.missingKeyHandler) {
return this.config.missingKeyHandler(this.locale as Locale, key);
}
// 开发环境打印警告
if (process.env.NODE_ENV === 'development') {
console.warn(`[i18n] Missing translation: ${key} (${this.locale})`);
}
return key;
}
}
React useTranslation Hook
import { useContext, useCallback, useSyncExternalStore, useEffect, useState } from 'react';
import { I18nContext } from './I18nProvider';
interface UseTranslationReturn {
/** 翻译函数 */
t: (key: string, params?: InterpolationParams) => string;
/** 当前语言 */
locale: string;
/** 切换语言 */
changeLocale: (locale: string) => Promise<void>;
/** 格式化工具 */
format: I18nFormatter;
/** 命名空间是否加载完成 */
ready: boolean;
}
function useTranslation(namespace?: string): UseTranslationReturn {
const i18n = useContext(I18nContext);
const [ready, setReady] = useState(!namespace);
if (!i18n) {
throw new Error('useTranslation must be used within an I18nProvider');
}
// 动态加载命名空间
useEffect(() => {
if (namespace) {
i18n.loadNamespace(namespace).then(() => setReady(true));
}
}, [i18n, namespace]);
// 使用 useSyncExternalStore 监听语言变化,触发组件重渲染
const locale = useSyncExternalStore(
(callback) => i18n.onLocaleChange(callback),
() => i18n.currentLocale,
);
const t = useCallback(
(key: string, params?: InterpolationParams) => {
// 如果指定了命名空间,自动添加前缀
const fullKey = namespace ? `${namespace}:${key}` : key;
return i18n.t(fullKey, params);
},
// locale 变化时重新创建 t 函数,确保使用最新翻译
// eslint-disable-next-line react-hooks/exhaustive-deps
[i18n, namespace, locale],
);
const changeLocale = useCallback((newLocale: string) => i18n.changeLocale(newLocale), [i18n]);
return { t, locale, changeLocale, format: i18n.format, ready };
}
使用示例
import { useTranslation } from '@/hooks/useTranslation';
function UserProfile({ user }: { user: { name: string; joinDate: Date; followers: number } }) {
// 自动加载 "profile" 命名空间
const { t, format, ready } = useTranslation('profile');
if (!ready) return <Skeleton />;
return (
<div>
{/* 简单插值 */}
<h1>{t('greeting', { name: user.name })}</h1>
{/* 复数处理 */}
<p>{t('followerCount', { count: user.followers })}</p>
{/* 日期格式化 */}
<p>{t('joinDate', { date: format.formatDate(user.joinDate, 'long') })}</p>
{/* 相对时间 */}
<p>{format.formatRelativeTime(user.joinDate)}</p>
</div>
);
}
类型安全的翻译 Key
利用 TypeScript 模板字面量类型,实现翻译 key 的编译时检查和自动补全:
// 从 JSON 文件推断出所有可能的 key 路径
type NestedKeyOf<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${K}` | `${K}.${NestedKeyOf<T[K]>}`
: `${K}`;
}[keyof T & string]
: never;
// 示例:从翻译文件推断类型
interface CommonTranslations {
button: {
submit: string;
cancel: string;
confirm: string;
};
message: {
success: string;
error: string;
loading: string;
};
greeting: string;
}
// 自动推断出所有合法 key:
// "button" | "button.submit" | "button.cancel" | "button.confirm"
// | "message" | "message.success" | "message.error" | "message.loading"
// | "greeting"
type CommonKeys = NestedKeyOf<CommonTranslations>;
// 类型安全的 t 函数
function useTypedTranslation<T extends TranslationResource>(namespace: string) {
const { t, ...rest } = useTranslation(namespace);
const typedT = (key: NestedKeyOf<T>, params?: InterpolationParams): string => {
return t(key as string, params);
};
return { t: typedT, ...rest };
}
// 使用时获得完整的自动补全
const { t } = useTypedTranslation<CommonTranslations>('common');
t('button.submit'); // OK
t('button.unknown'); // TypeScript 编译错误
结合 VS Code 插件(如 i18n Ally),可以在编辑器中直接预览翻译内容、跳转到定义、检测缺失 key。这比纯类型检查更直观。
性能优化
1. 语言包体积优化
Tree Shaking 未使用的语言包
/**
* Webpack/Vite 插件:构建时扫描代码中实际使用的翻译 key,
* 移除未使用的条目,显著减小语言包体积
*/
interface I18nTreeShakePluginOptions {
/** 翻译文件所在目录 */
localesDir: string;
/** 源代码扫描目录 */
srcDir: string;
/** t() 函数名 */
functionNames: string[];
}
function extractUsedKeys(srcDir: string, functionNames: string[]): Set<string> {
const usedKeys = new Set<string>();
// 正则匹配 t('key') 或 t("key") 调用
const patterns = functionNames.map(
(fn) => new RegExp(`${fn}\\(['"]([^'"]+)['"]`, 'g'),
);
// 递归扫描所有源文件
// ...扫描逻辑
return usedKeys;
}
function shakeUnusedTranslations(
translations: Record<string, string>,
usedKeys: Set<string>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(translations)) {
if (usedKeys.has(key)) {
result[key] = value;
}
}
return result;
}
按路由拆分语言包
import { lazy, Suspense } from 'react';
// 路由组件和对应的语言包一起懒加载
const Dashboard = lazy(() =>
Promise.all([
import('./pages/Dashboard'),
// 预加载该页面的翻译命名空间
i18n.loadNamespace('dashboard'),
]).then(([module]) => module),
);
function AppRoutes() {
return (
<Routes>
<Route
path="/dashboard"
element={
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
}
/>
</Routes>
);
}
2. 切换性能优化
class LocaleSwitcher {
private i18n: I18n;
constructor(i18n: I18n) {
this.i18n = i18n;
}
/**
* 预加载用户可能切换的语言
* 在语言选择器 hover 时触发,而非点击时
*/
preloadOnHover(locale: string): void {
// 使用 requestIdleCallback 在空闲时加载
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
this.i18n.changeLocale(locale);
});
} else {
setTimeout(() => {
this.i18n.changeLocale(locale);
}, 100);
}
}
/**
* 智能预加载:根据用户地理位置预测可能的语言
*/
async predictivePreload(): Promise<void> {
const currentLocale = this.i18n.currentLocale;
// 中文用户可能切换英文,反之亦然
const predictions: Record<string, string[]> = {
'zh-CN': ['en-US'],
'en-US': ['zh-CN', 'ja-JP'],
'ja-JP': ['en-US', 'zh-CN'],
};
const targets = predictions[currentLocale] ?? [];
for (const locale of targets) {
// 使用 link preload 提示浏览器预加载
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = `https://cdn.example.com/i18n/1.0.0/${locale}/common.json`;
document.head.appendChild(link);
}
}
}
3. SSR 集成
Next.js App Router 国际化
import { type ReactNode } from 'react';
import { I18nProvider } from '@/providers/I18nProvider';
interface LayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params;
// 服务端加载翻译资源
const messages = await loadServerMessages(locale);
return (
<html lang={locale} dir={isRTL(locale) ? 'rtl' : 'ltr'}>
<head>
{/* hreflang 标签,告诉搜索引擎多语言页面关系 */}
<link rel="alternate" hrefLang="zh-CN" href="https://example.com/zh-CN" />
<link rel="alternate" hrefLang="en-US" href="https://example.com/en-US" />
<link rel="alternate" hrefLang="ja-JP" href="https://example.com/ja-JP" />
<link rel="alternate" hrefLang="x-default" href="https://example.com/en-US" />
</head>
<body>
<I18nProvider locale={locale} messages={messages}>
{children}
</I18nProvider>
</body>
</html>
);
}
/** 服务端加载翻译文件 */
async function loadServerMessages(locale: string): Promise<Record<string, string>> {
try {
return (await import(`@/locales/${locale}/common.json`)).default;
} catch {
// 回退到默认语言
return (await import('@/locales/zh-CN/common.json')).default;
}
}
/** 生成静态路径 */
export function generateStaticParams() {
return [{ locale: 'zh-CN' }, { locale: 'en-US' }, { locale: 'ja-JP' }];
}
- 每种语言使用独立 URL(路径前缀或子域名)
- 添加
hreflang标签声明多语言页面关系 - 在
<html>标签上设置正确的lang和dir属性 - 使用
x-default指定默认回退语言 - SSR/SSG 确保搜索引擎能爬到翻译后的内容
扩展设计
1. 大规模翻译管理
当翻译条目超过 10 万行时,传统的表格编辑器会出现严重的性能问题。使用 Canvas 虚拟表格(如 VTable)渲染万行数据:
import { VTable } from '@visactor/vtable';
interface TranslationRow {
key: string;
namespace: string;
'zh-CN': string;
'en-US': string;
'ja-JP': string;
status: string;
}
function createTranslationTable(container: HTMLElement, data: TranslationRow[]): VTable {
const columns = [
{ field: 'key', title: 'Key', width: 250, fixed: 'left' },
{ field: 'namespace', title: '命名空间', width: 120 },
{ field: 'zh-CN', title: '简体中文', width: 300, editor: 'input' },
{ field: 'en-US', title: 'English', width: 300, editor: 'input' },
{ field: 'ja-JP', title: '日本語', width: 300, editor: 'input' },
{
field: 'status',
title: '状态',
width: 100,
cellType: 'tag',
style: {
color: (args: { value: string }) => {
const colorMap: Record<string, string> = {
published: '#52c41a',
approved: '#1890ff',
draft: '#faad14',
missing: '#ff4d4f',
};
return colorMap[args.value] ?? '#999';
},
},
},
];
return new VTable({
container,
records: data,
columns,
// Canvas 渲染,10 万行丝滑滚动
frozenColCount: 1,
enableLineBreak: true,
autoRowHeight: true,
});
}
2. AI 翻译集成
interface AITranslationOptions {
/** 源语言 */
sourceLang: string;
/** 目标语言 */
targetLang: string;
/** 上下文描述,帮助 AI 理解场景 */
context?: string;
/** 术语表,确保专业术语翻译一致 */
glossary?: Record<string, string>;
}
class AITranslationService {
/**
* 批量 AI 翻译
* 发送上下文和术语表,提高翻译质量
*/
async translateBatch(
entries: Array<{ key: string; value: string }>,
options: AITranslationOptions,
): Promise<Array<{ key: string; value: string; confidence: number }>> {
const prompt = this.buildPrompt(entries, options);
const response = await fetch('/api/ai-translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, model: 'gpt-4' }),
});
const result = await response.json();
return result.translations;
}
private buildPrompt(
entries: Array<{ key: string; value: string }>,
options: AITranslationOptions,
): string {
let prompt = `Translate the following UI text from ${options.sourceLang} to ${options.targetLang}.\n`;
if (options.context) {
prompt += `Context: ${options.context}\n`;
}
if (options.glossary && Object.keys(options.glossary).length > 0) {
prompt += `Glossary (must follow):\n`;
for (const [term, translation] of Object.entries(options.glossary)) {
prompt += ` "${term}" -> "${translation}"\n`;
}
}
prompt += `\nRules:\n`;
prompt += `- Keep placeholders like {name}, {count} unchanged\n`;
prompt += `- Preserve ICU MessageFormat syntax\n`;
prompt += `- Keep HTML tags unchanged\n`;
prompt += `- Be concise and natural for UI context\n\n`;
for (const entry of entries) {
prompt += `[${entry.key}]: ${entry.value}\n`;
}
return prompt;
}
}
3. 伪本地化测试
伪本地化(Pseudo-localization)是一种在不需要真实翻译的情况下测试 i18n 兼容性的技术。它将原文转换为看起来像外语的变体,用于发现以下问题:
- 硬编码字符串(未通过
t()翻译的文案) - 布局溢出(翻译后文案变长导致 UI 破裂)
- 字符编码问题(不支持非 ASCII 字符)
- RTL 布局缺陷
const PSEUDO_MAP: Record<string, string> = {
a: 'ä', b: 'ƀ', c: 'ç', d: 'ð', e: 'ë', f: 'ƒ',
g: 'ğ', h: 'ĥ', i: 'ï', j: 'ĵ', k: 'ĸ', l: 'ĺ',
m: 'ɱ', n: 'ñ', o: 'ö', p: 'þ', q: 'ǫ', r: 'ŗ',
s: 'š', t: 'ţ', u: 'ü', v: 'ṽ', w: 'ŵ', x: 'ẋ',
y: 'ÿ', z: 'ž',
};
/**
* 伪本地化转换
* "Hello, {name}!" -> "[!!Ĥëĺĺö, {name}!~~]"
*
* - [!! ... ~~] 标记翻译边界,发现未翻译文案
* - 字符替换模拟非 ASCII 字符
* - 添加 30% 长度填充模拟翻译膨胀(中→英通常膨胀 30-50%)
*/
function pseudoLocalize(text: string): string {
let result = '';
let inPlaceholder = false;
for (const char of text) {
if (char === '{') inPlaceholder = true;
if (char === '}') inPlaceholder = false;
if (inPlaceholder || char === '{' || char === '}') {
result += char; // 保留占位符
} else {
const lower = char.toLowerCase();
result += PSEUDO_MAP[lower] ?? char;
}
}
// 添加 30% 长度填充
const padding = '~'.repeat(Math.ceil(result.length * 0.3));
return `[!!${result}${padding}]`;
}
伪本地化应当只在开发/测试环境启用。可以通过 URL 参数(?locale=pseudo)或环境变量控制。
4. 图片与视频本地化
多媒体资源的本地化不能通过翻译函数处理,需要额外的资源映射机制:
/** 本地化资源映射 */
interface LocalizedAssetMap {
[assetKey: string]: {
[locale: string]: string; // locale -> CDN URL
fallback: string;
};
}
const assetMap: LocalizedAssetMap = {
'hero-banner': {
'zh-CN': 'https://cdn.example.com/images/hero-zh.webp',
'en-US': 'https://cdn.example.com/images/hero-en.webp',
'ja-JP': 'https://cdn.example.com/images/hero-ja.webp',
fallback: 'https://cdn.example.com/images/hero-en.webp',
},
'onboarding-video': {
'zh-CN': 'https://cdn.example.com/videos/onboarding-zh.mp4',
'en-US': 'https://cdn.example.com/videos/onboarding-en.mp4',
fallback: 'https://cdn.example.com/videos/onboarding-en.mp4',
},
};
function getLocalizedAsset(key: string, locale: string): string {
const asset = assetMap[key];
if (!asset) {
console.warn(`[i18n] Missing asset: ${key}`);
return '';
}
return asset[locale] ?? asset.fallback;
}
常见面试问题
Q1: i18n 语言包应该如何拆分和加载?有哪些优化策略?
答案:
语言包的拆分和加载是 i18n 性能优化的核心。主要从两个维度拆分:
按语言拆分(必做):每种语言独立打包,用户只加载当前语言:
// locales/
// zh-CN/
// common.json (公共文案 ~10KB)
// home.json (首页 ~5KB)
// dashboard.json (仪表盘 ~8KB)
// en-US/
// common.json
// home.json
// dashboard.json
按页面/命名空间拆分(推荐):配合路由懒加载,页面和对应翻译一起加载:
// 路由级别按需加载
const Dashboard = lazy(() =>
Promise.all([
import('./pages/Dashboard'),
i18n.loadNamespace('dashboard'),
]).then(([mod]) => mod),
);
加载优化策略:
| 策略 | 说明 |
|---|---|
| CDN 分发 | 语言包部署到 CDN,就近访问 |
| 版本化缓存 | URL 含版本号,配合强缓存(Cache-Control: max-age=31536000) |
| 本地持久化 | 加载后存入 localStorage/IndexedDB,二次访问免网络请求 |
| 请求去重 | 同一资源并发请求只发一次 fetch |
| 预加载 | hover 语言选择器时预加载、空闲时预测性加载 |
Q2: 前端如何实现运行时语言切换(不刷新页面)?
答案:
运行时语言切换的核心是响应式数据 + 观察者模式。当语言变更时,所有使用翻译的组件需要自动重渲染。
React 方案:利用 useSyncExternalStore 监听语言变化:
function useTranslation() {
const i18n = useContext(I18nContext);
// 语言变化时触发组件重渲染
const locale = useSyncExternalStore(
(cb) => i18n.onLocaleChange(cb), // subscribe
() => i18n.currentLocale, // getSnapshot
);
const t = useCallback(
(key: string) => i18n.t(key),
[i18n, locale], // locale 变化时重新创建 t
);
return { t, locale };
}
Vue 方案:利用 Vue 的响应式系统,将 locale 设为 ref:
// Vue Composition API
const locale = ref('zh-CN');
const messages = reactive({});
function t(key: string): string {
// locale 和 messages 都是响应式的
// 变更时依赖它们的组件自动更新
return getNestedValue(messages[locale.value], key);
}
切换语言的完整流程:
- 用户点击切换 -> 加载新语言的翻译资源
- 更新
locale状态 -> 触发所有组件重渲染 - 更新
<html lang="">和dir属性 - 将选择持久化到 Cookie/localStorage
- (可选)更新 URL 路径前缀
Q3: 如何处理复数、性别等复杂的翻译场景?
答案:
使用 ICU MessageFormat 标准。这是一种被 Unicode 组织定义的消息格式化语法,所有主流 i18n 库(react-intl、vue-i18n、i18next)都支持。
复数示例(不同语言复数规则不同):
// 英语:有 one 和 other 两种复数形式
{ "items": "{count, plural, =0 {No items} one {1 item} other {{count} items}}" }
// 阿拉伯语:有 zero, one, two, few, many, other 六种复数形式
{ "items": "{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {{count} عناصر} many {{count} عنصراً} other {{count} عنصر}}" }
// 中文:只有 other 一种
{ "items": "{count, plural, other {{count} 个项目}}" }
性别示例:
{ "liked": "{gender, select, male {他} female {她} other {Ta}} 点赞了你的照片" }
嵌套选择:
{
"notification": "{gender, select, male {{count, plural, one {他发来了 1 条消息} other {他发来了 {count} 条消息}}} female {{count, plural, one {她发来了 1 条消息} other {她发来了 {count} 条消息}}} other {{count, plural, one {Ta 发来了 1 条消息} other {Ta 发来了 {count} 条消息}}}}"
}
虽然嵌套语法看起来复杂,但翻译人员通常在 TMS 平台中通过可视化界面编辑,而非手写 ICU 语法。
Q4: i18n 方案的 SEO 如何处理?
答案:
多语言 SEO 需要关注以下几个关键点:
- 独立 URL:每种语言必须有独立、可爬取的 URL
https://example.com/zh-CN/about (中文)
https://example.com/en-US/about (英文)
- hreflang 标签:告诉搜索引擎页面的多语言版本关系
<link rel="alternate" hrefLang="zh-CN" href="https://example.com/zh-CN/about" />
<link rel="alternate" hrefLang="en-US" href="https://example.com/en-US/about" />
<link rel="alternate" hrefLang="x-default" href="https://example.com/en-US/about" />
- SSR/SSG:搜索引擎爬虫不执行 JS,所以翻译内容必须在服务端渲染
// Next.js generateStaticParams 为每种语言生成静态页面
export function generateStaticParams() {
return ['zh-CN', 'en-US', 'ja-JP'].map((locale) => ({ locale }));
}
- HTML lang 属性:
<html lang="zh-CN" dir="ltr">
- sitemap.xml 多语言声明:
<url>
<loc>https://example.com/en-US/about</loc>
<xhtml:link rel="alternate" hreflang="zh-CN" href="https://example.com/zh-CN/about"/>
<xhtml:link rel="alternate" hreflang="en-US" href="https://example.com/en-US/about"/>
</url>
Q5: 翻译管理平台(TMS)的核心功能有哪些?如何与开发流程集成?
答案:
TMS 的核心功能包括:
| 功能 | 说明 |
|---|---|
| 在线编辑 | 翻译人员在浏览器中直接编辑翻译,无需配置开发环境 |
| 翻译记忆库(TM) | 自动匹配相似的历史翻译,提高一致性和效率 |
| 术语库 | 确保专业术语翻译统一(如 "Dashboard" 始终译为 "仪表盘") |
| 审核工作流 | draft -> in_review -> approved -> published 状态流转 |
| 版本控制 | 每次发布生成快照,支持 Diff 对比和一键回滚 |
| 机器翻译 | 集成 Google Translate / DeepL / GPT-4 作为初稿 |
| 质量检查 | 检测缺失占位符、HTML 标签不匹配、长度超限等 |
与开发流程的集成方式:
方式 1(CI 回写)适合 SSR/SSG 项目,翻译文件跟随代码版本管理。方式 2(CDN 发布)适合 SPA 项目,翻译更新不需要重新部署应用。
Q6: 如何用 TypeScript 实现类型安全的翻译 key?
答案:
利用 TypeScript 的模板字面量类型和递归条件类型,从翻译 JSON 结构自动推断所有合法 key:
// 递归推断嵌套 key 路径
type NestedKeyOf<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${K}.${NestedKeyOf<T[K]>}`
: K;
}[keyof T & string]
: never;
// 从翻译文件推断
import zhCN from '@/locales/zh-CN/common.json';
type TranslationKeys = NestedKeyOf<typeof zhCN>;
// 结果: "button.submit" | "button.cancel" | "message.success" | ...
// 类型安全的翻译函数
function t(key: TranslationKeys, params?: Record<string, unknown>): string;
开发时效果:
- 输入
t('button.')时 IDE 自动补全submit、cancel等 - 输入不存在的 key
t('button.unknown')时 TypeScript 编译报错 - 删除翻译 key 后,所有引用处立即标红
这种方案的局限是只能检查静态字符串 key,动态拼接的 key(如 t(`status.${status}`))无法类型检查。
Q7: 如何测试 i18n 的正确性?伪本地化是什么?
答案:
i18n 测试分为三个层面:
- 单元测试:验证翻译函数、格式化函数的正确性
describe('I18n', () => {
it('should interpolate params', () => {
expect(t('greeting', { name: 'Alice' })).toBe('Hello, Alice!');
});
it('should handle plurals', () => {
expect(t('items', { count: 0 })).toBe('No items');
expect(t('items', { count: 1 })).toBe('1 item');
expect(t('items', { count: 5 })).toBe('5 items');
});
});
- 完整性检查:CI 中自动检测翻译缺失、多余 key
// scripts/check-translations.ts
function checkCompleteness(baseLang: string, targetLang: string): string[] {
const baseKeys = getAllKeys(loadJSON(`locales/${baseLang}/common.json`));
const targetKeys = getAllKeys(loadJSON(`locales/${targetLang}/common.json`));
return baseKeys.filter((key) => !targetKeys.includes(key)); // 缺失的 key
}
- 伪本地化(Pseudo-localization):
将原文转换为带有特殊标记的变体(如 "Hello" -> "[!!Ĥëĺĺö~~~]"),用于发现:
| 发现的问题 | 如何发现 |
|---|---|
| 硬编码字符串 | 没有 [!! ~~] 标记包裹的文案 |
| 布局溢出 | 膨胀 30% 后 UI 是否破裂 |
| 字符编码问题 | 非 ASCII 字符能否正常显示 |
| 截断问题 | 变长后文案是否被截断 |
伪本地化的优势在于不需要真实翻译就能发现大部分 i18n 问题,非常适合在开发早期集成。
Q8: 业界主流的 i18n 方案有哪些?如何选型?
答案:
| 方案 | 框架 | 特点 | 适用场景 |
|---|---|---|---|
| react-intl | React | ICU MessageFormat、FormatJS 生态 | 重格式化、国际化要求高 |
| react-i18next | React | 插件生态丰富、命名空间、按需加载 | 大型项目、多框架复用 |
| next-intl | Next.js | App Router 原生支持、类型安全 | Next.js 项目首选 |
| vue-i18n | Vue | 官方推荐、Composition API 支持 | Vue 项目 |
| Lingui | React/JS | 编译时优化、体积极小 | 对包体积敏感 |
| Paraglide | 全框架 | 编译时 i18n、Tree Shakable | 极致性能追求 |
选型建议:
- Next.js 项目:优先选 next-intl,与 App Router 深度集成
- 大型 React SPA:react-i18next,生态最完善
- Vue 项目:vue-i18n,官方推荐且社区成熟
- 极致性能:Lingui 或 Paraglide,编译时处理,零运行时开销
相关链接
- MDN: Intl API - 浏览器内置国际化 API
- ICU MessageFormat - Unicode 消息格式化标准
- FormatJS - JavaScript 国际化工具集
- next-intl - Next.js 国际化方案
- vue-i18n - Vue 国际化方案
- CSS 逻辑属性 - RTL 适配基础
- Google i18n 最佳实践 - SEO 国际化指南