跳到主要内容

设计多语言管理系统

需求分析

功能需求

  1. 语言切换:用户可在页面内无刷新切换语言,切换后所有文案实时更新
  2. 文案管理:支持按模块/页面组织翻译文案,支持嵌套 key 和命名空间
  3. 翻译工作流:翻译人员在线编辑、审核、发布,支持版本控制和回滚
  4. 动态加载:语言包按需加载,避免首屏加载全量翻译文案
  5. 格式化能力
    • 复数1 item vs 2 items(不同语言复数规则不同)
    • 日期/时间2026-02-27 vs Feb 27, 2026 vs 27/02/2026
    • 货币¥100.00 vs $100.00 vs €100,00
    • 性别He liked your photo vs She liked your photo

非功能需求

需求说明
按需加载单个语言包体积 < 50KB,首屏只加载当前语言
SEO 友好每种语言有独立 URL,支持 hreflang 标签
开发体验类型安全的翻译 key、IDE 自动补全、缺失 key 提示
大规模管理支持 10 万+ 翻译条目、50+ 语言的高效管理
可扩展性易于接入机器翻译、AI 翻译、翻译记忆库
面试要点

面试中讨论 i18n 系统设计时,面试官最关注三个方面:运行时性能(语言包加载策略)、开发体验(类型安全、工作流)、可扩展性(大规模翻译管理)。


整体架构

架构说明

整体架构分为三层:前端 SDK 负责运行时翻译和格式化;翻译管理平台(TMS) 提供翻译人员的工作界面和工作流;CI/CD 集成 打通代码仓库和翻译平台的双向同步。


核心模块设计

1. 前端 i18n SDK

核心数据结构

types/i18n.ts
/** 支持的语言代码 */
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 是国际化文案的行业标准,支持复数、性别、选择等复杂场景:

locales/en-US/common.json
{
"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>"
}
locales/zh-CN/common.json
{
"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/aboutSEO 最优、易于实现URL 较长
子域名zh.example.com/aboutURL 简洁、CDN 灵活DNS 配置复杂、Cookie 共享问题
Cookie / Headerexample.com/aboutURL 不变、体验好SEO 不友好、不可分享特定语言
推荐方案

URL 路径前缀是目前最主流的方案,Next.js、Nuxt 等框架均原生支持。既保证 SEO 友好,又便于用户分享特定语言的链接。

Middleware 语言检测链

检测链按优先级依次执行,命中后停止:

middleware/i18n.ts
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)

平台架构

翻译工作流状态机

核心数据模型

types/tms.ts
/** 翻译条目 */
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 动态加载 + 缓存最快缓存命中即时
core/resource-loader.ts
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 提供了日期、数字、货币等本地化格式化能力:

core/formatter.ts
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-CNen-USja-JP
formatDate(date, 'long')2026年2月27日February 27, 20262026年2月27日
formatCurrency(1234.5, 'USD')US$1,234.50$1,234.50$1,234.50
formatRelativeTime(-3天)3天前3 days ago3日前
formatList(['A','B','C'])A、B和CA, B, and CA、B、C

RTL 布局支持

阿拉伯语、希伯来语等从右到左(RTL)书写的语言需要特殊处理:

utils/rtl.ts
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);
}
styles/rtl.css
/* 使用逻辑属性替代物理属性,自动适配 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/endblock-start/end)替代物理属性(left/right/top/bottom),这样布局会自动跟随文字方向翻转。


关键技术实现

I18n 核心类

core/i18n.ts
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

hooks/useTranslation.ts
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 };
}

使用示例

components/UserProfile.tsx
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 的编译时检查和自动补全:

types/i18n-keys.ts
// 从 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 未使用的语言包

build/i18n-plugin.ts
/**
* 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;
}

按路由拆分语言包

routes/index.tsx
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. 切换性能优化

core/locale-switch.ts
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 国际化

app/[locale]/layout.tsx
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' }];
}
SEO 最佳实践
  • 每种语言使用独立 URL(路径前缀或子域名)
  • 添加 hreflang 标签声明多语言页面关系
  • <html> 标签上设置正确的 langdir 属性
  • 使用 x-default 指定默认回退语言
  • SSR/SSG 确保搜索引擎能爬到翻译后的内容

扩展设计

1. 大规模翻译管理

当翻译条目超过 10 万行时,传统的表格编辑器会出现严重的性能问题。使用 Canvas 虚拟表格(如 VTable)渲染万行数据:

components/TranslationEditor.tsx
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 翻译集成

services/ai-translation.ts
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 布局缺陷
utils/pseudo-locale.ts
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. 图片与视频本地化

多媒体资源的本地化不能通过翻译函数处理,需要额外的资源映射机制:

utils/localized-asset.ts
/** 本地化资源映射 */
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);
}

切换语言的完整流程:

  1. 用户点击切换 -> 加载新语言的翻译资源
  2. 更新 locale 状态 -> 触发所有组件重渲染
  3. 更新 <html lang="">dir 属性
  4. 将选择持久化到 Cookie/localStorage
  5. (可选)更新 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 需要关注以下几个关键点:

  1. 独立 URL:每种语言必须有独立、可爬取的 URL
https://example.com/zh-CN/about  (中文)
https://example.com/en-US/about (英文)
  1. 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" />
  1. SSR/SSG:搜索引擎爬虫不执行 JS,所以翻译内容必须在服务端渲染
// Next.js generateStaticParams 为每种语言生成静态页面
export function generateStaticParams() {
return ['zh-CN', 'en-US', 'ja-JP'].map((locale) => ({ locale }));
}
  1. HTML lang 属性
<html lang="zh-CN" dir="ltr">
  1. 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 自动补全 submitcancel
  • 输入不存在的 key t('button.unknown') 时 TypeScript 编译报错
  • 删除翻译 key 后,所有引用处立即标红

这种方案的局限是只能检查静态字符串 key,动态拼接的 key(如 t(`status.${status}`))无法类型检查。

Q7: 如何测试 i18n 的正确性?伪本地化是什么?

答案

i18n 测试分为三个层面:

  1. 单元测试:验证翻译函数、格式化函数的正确性
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');
});
});
  1. 完整性检查: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
}
  1. 伪本地化(Pseudo-localization)

将原文转换为带有特殊标记的变体(如 "Hello" -> "[!!Ĥëĺĺö~~~]"),用于发现:

发现的问题如何发现
硬编码字符串没有 [!! ~~] 标记包裹的文案
布局溢出膨胀 30% 后 UI 是否破裂
字符编码问题非 ASCII 字符能否正常显示
截断问题变长后文案是否被截断

伪本地化的优势在于不需要真实翻译就能发现大部分 i18n 问题,非常适合在开发早期集成。

Q8: 业界主流的 i18n 方案有哪些?如何选型?

答案

方案框架特点适用场景
react-intlReactICU MessageFormat、FormatJS 生态重格式化、国际化要求高
react-i18nextReact插件生态丰富、命名空间、按需加载大型项目、多框架复用
next-intlNext.jsApp Router 原生支持、类型安全Next.js 项目首选
vue-i18nVue官方推荐、Composition API 支持Vue 项目
LinguiReact/JS编译时优化、体积极小对包体积敏感
Paraglide全框架编译时 i18n、Tree Shakable极致性能追求

选型建议

  • Next.js 项目:优先选 next-intl,与 App Router 深度集成
  • 大型 React SPA:react-i18next,生态最完善
  • Vue 项目:vue-i18n,官方推荐且社区成熟
  • 极致性能:Lingui 或 Paraglide,编译时处理,零运行时开销

相关链接