name: i18n description: 国际化与本地化模式,用于多语言应用程序。在实现翻译系统、区域特定格式化、RTL布局或管理语言切换时使用。关键词:i18n、国际化、l10n、本地化、翻译、翻译、区域、语言、多语言、多语言、RTL、从右到左、LTR、双向、复数化、复数形式、日期格式、时间格式、数字格式、货币格式、时区、i18next、react-intl、FormatJS、gettext、ICU MessageFormat、消息格式、语言检测、语言切换、Accept-Language、区域回退、翻译键、翻译文件、JSON翻译、PO文件、YAML翻译、react i18n、React本地化、格式化日期、格式化数字、格式化货币、格式化相对时间、Intl API、NumberFormat、DateTimeFormat、RTL CSS、逻辑属性、方向感知、语言代码、区域代码、区域标识符、BCP47、ISO 639、翻译提取、伪本地化、命名空间、翻译命名空间。
国际化 (i18n)
概述
国际化(i18n)是设计软件以适应各种语言和区域而无需工程更改的过程。本地化(l10n)是针对特定区域的实际适配。此技能涵盖架构模式、翻译格式、区域特定格式化和流行库。
快速参考
常见用例:
- 多语言网络应用程序(React、Vue、Angular)
- 区域特定的日期、数字和货币格式化
- RTL(从右到左)布局支持,适用于阿拉伯语、希伯来语、波斯语、乌尔都语
- 不同语言的复数化规则
- 翻译管理和提取工作流程
- 无需页面重新加载的动态语言切换
- 从头部/ cookies 检测服务器端区域
流行库:
- React:i18next、react-intl(FormatJS)、react-i18next
- Vue:vue-i18n
- Node.js:i18next、node-polyglot、format-message
- Python:gettext、Babel
- Ruby:i18n gem、Rails I18n
关键概念
国际化架构
核心原则:
- 将可翻译内容与代码分离
- 使用区域标识符(例如,
en-US、fr-FR、zh-Hans) - 支持动态区域切换
- 处理回退链(例如,
de-AT->de->en)
架构模式:
src/
locales/
en/
common.json
products.json
errors.json
fr/
common.json
products.json
errors.json
i18n/
config.ts
index.ts
区域配置:
interface LocaleConfig {
code: string; // 例如,'en-US'
language: string; // 例如,'en'
region?: string; // 例如,'US'
direction: "ltr" | "rtl";
dateFormat: string;
numberFormat: Intl.NumberFormatOptions;
currency: string;
}
const locales: Record<string, LocaleConfig> = {
"en-US": {
code: "en-US",
language: "en",
region: "US",
direction: "ltr",
dateFormat: "MM/dd/yyyy",
numberFormat: { style: "decimal", minimumFractionDigits: 2 },
currency: "USD",
},
"de-DE": {
code: "de-DE",
language: "de",
region: "DE",
direction: "ltr",
dateFormat: "dd.MM.yyyy",
numberFormat: { style: "decimal", minimumFractionDigits: 2 },
currency: "EUR",
},
"ar-SA": {
code: "ar-SA",
language: "ar",
region: "SA",
direction: "rtl",
dateFormat: "dd/MM/yyyy",
numberFormat: { style: "decimal", minimumFractionDigits: 2 },
currency: "SAR",
},
};
翻译文件格式
JSON 格式(i18next、react-intl):
{
"common": {
"welcome": "欢迎, {{name}}!",
"items_count": "{{count}} 个项目",
"items_count_plural": "{{count}} 个项目"
},
"products": {
"title": "产品",
"addToCart": "添加到购物车",
"price": "价格: {{price, currency}}"
},
"errors": {
"required": "此字段为必填项",
"minLength": "必须至少 {{min}} 个字符"
}
}
带命名空间的嵌套 JSON:
{
"nav": {
"home": "首页",
"products": "产品",
"about": "关于我们"
},
"footer": {
"copyright": "版权 2024",
"links": {
"privacy": "隐私政策",
"terms": "服务条款"
}
}
}
YAML 格式:
common:
welcome: "欢迎, {name}!"
items_count:
one: "{count} 个项目"
other: "{count} 个项目"
products:
title: 产品
addToCart: 添加到购物车
outOfStock: 缺货
errors:
required: 此字段为必填项
email: 请输入有效的电子邮件
PO/POT 格式(gettext):
# 英文翻译
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8
"
"Language: en
"
"Plural-Forms: nplurals=2; plural=(n != 1);
"
#: src/components/Header.js:15
msgid "Welcome"
msgstr "欢迎"
#: src/components/Cart.js:42
msgid "item"
msgid_plural "items"
msgstr[0] "项目"
msgstr[1] "项目"
#: src/components/Product.js:28
#, python-format
msgid "Price: %(price)s"
msgstr "价格: %(price)s"
复数化规则
ICU MessageFormat:
const messages = {
// 英文
en: {
items: "{count, plural, =0 {没有项目} one {# 个项目} other {# 个项目}}",
cartItems: `{count, plural,
=0 {您的购物车为空}
one {您有 # 个项目在购物车中}
other {您有 # 个项目在购物车中}
}`,
},
// 俄语(3 个复数形式)
ru: {
items: `{count, plural,
one {# 个商品}
few {# 个商品}
many {# 个商品}
other {# 个商品}
}`,
},
// 阿拉伯语(6 个复数形式)
ar: {
items: `{count, plural,
zero {没有项目}
one {一个项目}
two {两个项目}
few {# 个项目}
many {# 个项目}
other {# 个项目}
}`,
},
};
Select 和 SelectOrdinal:
const messages = {
gender: `{gender, select,
male {他}
female {她}
other {他们}
} 喜欢了您的帖子。`,
ordinal: `{position, selectordinal,
one {#st}
two {#nd}
few {#rd}
other {#th}
} 名`,
};
日期/时间/数字格式化
使用 Intl API:
class LocaleFormatter {
private locale: string;
constructor(locale: string) {
this.locale = locale;
}
formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
return new Intl.NumberFormat(this.locale, options).format(value);
}
formatCurrency(value: number, currency: string): string {
return new Intl.NumberFormat(this.locale, {
style: "currency",
currency,
}).format(value);
}
formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string {
return new Intl.DateTimeFormat(this.locale, options).format(date);
}
formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit): string {
return new Intl.RelativeTimeFormat(this.locale, {
numeric: "auto",
}).format(value, unit);
}
formatList(
items: string[],
type: "conjunction" | "disjunction" = "conjunction",
): string {
return new Intl.ListFormat(this.locale, { type }).format(items);
}
}
// 用法
const formatter = new LocaleFormatter("de-DE");
formatter.formatNumber(1234567.89); // "1.234.567,89"
formatter.formatCurrency(99.99, "EUR"); // "99,99 €"
formatter.formatDate(new Date()); // "19.12.2024"
formatter.formatRelativeTime(-1, "day"); // "昨天"
formatter.formatList(["A", "B", "C"]); // "A, B 和 C"
按区域的日期格式模式:
const datePatterns: Record<string, Intl.DateTimeFormatOptions> = {
short: { dateStyle: "short" },
medium: { dateStyle: "medium" },
long: { dateStyle: "long" },
full: { dateStyle: "full" },
custom: {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
},
};
// 结果因区域而异:
// en-US: "Thursday, December 19, 2024"
// de-DE: "Donnerstag, 19. Dezember 2024"
// ja-JP: "2024年12月19日木曜日"
RTL(从右到左)支持
CSS 逻辑属性:
/* 代替物理属性 */
.card {
/* 使用逻辑属性以自动支持 RTL */
margin-inline-start: 1rem; /* 在 LTR 中是 margin-left,在 RTL 中是 margin-right */
margin-inline-end: 2rem;
padding-inline: 1rem;
padding-block: 0.5rem;
border-inline-start: 3px solid blue;
text-align: start; /* 在 LTR 中是 left,在 RTL 中是 right */
}
/* Flexbox 和 Grid 自动适配 */
.container {
display: flex;
flex-direction: row; /* 适配文档方向 */
gap: 1rem;
}
方向感知样式:
/* 在根元素设置方向 */
html[dir="rtl"] {
direction: rtl;
}
/* 使用 :dir() 伪类 */
.icon:dir(rtl) {
transform: scaleX(-1); /* 翻转图标 */
}
/* 双向文本处理 */
.mixed-content {
unicode-bidi: isolate;
}
React RTL 实现:
import { createContext, useContext, ReactNode } from "react";
interface DirectionContextType {
direction: "ltr" | "rtl";
isRTL: boolean;
}
const DirectionContext = createContext<DirectionContextType>({
direction: "ltr",
isRTL: false,
});
export function DirectionProvider({
locale,
children,
}: {
locale: string;
children: ReactNode;
}) {
const rtlLocales = ["ar", "he", "fa", "ur"];
const language = locale.split("-")[0];
const isRTL = rtlLocales.includes(language);
const direction = isRTL ? "rtl" : "ltr";
return (
<DirectionContext.Provider value={{ direction, isRTL }}>
<div dir={direction}>{children}</div>
</DirectionContext.Provider>
);
}
export const useDirection = () => useContext(DirectionContext);
内容本地化策略
动态内容加载:
async function loadTranslations(locale: string, namespace: string) {
try {
const translations = await import(`../locales/${locale}/${namespace}.json`);
return translations.default;
} catch {
// 回退到默认区域
const fallback = await import(`../locales/en/${namespace}.json`);
return fallback.default;
}
}
服务器端区域检测:
function detectLocale(request: Request): string {
// 1. 检查 URL 参数
const url = new URL(request.url);
const urlLocale = url.searchParams.get("locale");
if (urlLocale && isValidLocale(urlLocale)) return urlLocale;
// 2. 检查 cookie
const cookieLocale = getCookie(request, "locale");
if (cookieLocale && isValidLocale(cookieLocale)) return cookieLocale;
// 3. 检查 Accept-Language 头部
const acceptLanguage = request.headers.get("Accept-Language");
if (acceptLanguage) {
const preferred = parseAcceptLanguage(acceptLanguage);
const matched = preferred.find((l) => isValidLocale(l));
if (matched) return matched;
}
// 4. 默认区域
return "en-US";
}
function parseAcceptLanguage(header: string): string[] {
return header
.split(",")
.map((lang) => {
const [code, q] = lang.trim().split(";q=");
return { code: code.trim(), q: parseFloat(q) || 1 };
})
.sort((a, b) => b.q - a.q)
.map(({ code }) => code);
}
库:i18next、react-intl、gettext
i18next 设置:
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: ["en", "de", "fr", "es", "ar"],
ns: ["common", "products", "errors"],
defaultNS: "common",
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
interpolation: {
escapeValue: false,
format: (value, format, lng) => {
if (format === "currency") {
return new Intl.NumberFormat(lng, {
style: "currency",
currency: "USD",
}).format(value);
}
if (value instanceof Date) {
return new Intl.DateTimeFormat(lng).format(value);
}
return value;
},
},
react: {
useSuspense: true,
},
});
export default i18n;
i18next 在 React 中的用法:
import { useTranslation, Trans } from "react-i18next";
function ProductCard({ product }) {
const { t, i18n } = useTranslation(["products", "common"]);
return (
<div>
<h2>{product.name}</h2>
<p>{t("products:price", { price: product.price })}</p>
<p>{t("common:items_count", { count: product.stock })}</p>
<Trans i18nKey="products:description" values={{ name: product.name }}>
立即查看 <strong>{{ name: product.name }}</strong>!
</Trans>
<button onClick={() => i18n.changeLanguage("de")}>
{t("common:switchLanguage")}
</button>
</div>
);
}
react-intl 设置:
import { IntlProvider, FormattedMessage, useIntl } from "react-intl";
const messages = {
en: {
"app.greeting": "你好, {name}!",
"app.items": "{count, plural, =0 {没有项目} one {# 个项目} other {# 个项目}}",
},
de: {
"app.greeting": "Hallo, {name}!",
"app.items":
"{count, plural, =0 {Keine Artikel} one {# Artikel} other {# Artikel}}",
},
};
function App() {
const [locale, setLocale] = useState("en");
return (
<IntlProvider locale={locale} messages={messages[locale]}>
<Content />
</IntlProvider>
);
}
function Content() {
const intl = useIntl();
return (
<div>
<FormattedMessage id="app.greeting" values={{ name: "世界" }} />
<p>{intl.formatMessage({ id: "app.items" }, { count: 5 })}</p>
<p>
{intl.formatNumber(1234.56, { style: "currency", currency: "EUR" })}
</p>
<p>{intl.formatDate(new Date(), { dateStyle: "long" })}</p>
</div>
);
}
Python gettext:
import gettext
from pathlib import Path
# 设置
localedir = Path(__file__).parent / 'locales'
translation = gettext.translation(
'messages',
localedir=localedir,
languages=['de'],
fallback=True
)
_ = translation.gettext
ngettext = translation.ngettext
# 用法
print(_("你好,世界!"))
print(_("欢迎, %(name)s!") % {'name': '用户'})
count = 5
print(ngettext(
"%(count)d 个项目",
"%(count)d 个项目",
count
) % {'count': count})
最佳实践
架构
- 将所有面向用户的字符串提取到翻译文件中
- 使用命名空间按功能/页面组织翻译
- 实现区域回退链
- 延迟加载翻译以提高性能
翻译键
- 使用描述性、分层的键(例如,
products.card.addToCart) - 当含义模糊时,在键中包含上下文
- 切勿使用原始文本作为键(易碎、难以追踪)
- 记录占位符及其预期值
格式化
- 始终使用区域感知格式化处理日期、数字、货币
- 使用 ICU MessageFormat 处理复杂复数化
- 尽可能在服务器端处理时区转换
- 考虑文化差异(例如,姓名顺序、地址格式)
RTL 支持
- 专门使用 CSS 逻辑属性
- 使用实际 RTL 内容测试,不仅仅是镜像 LTR
- 正确处理双向文本
- 确保图标和图像方向适当
测试
- 使用伪本地化测试以捕获硬编码字符串
- 使用长翻译(如德语)测试 UI 溢出
- 使用 RTL 语言测试布局问题
- 自动化未翻译字符串的提取
示例
完整的 React i18n 设置
// i18n/config.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
export const supportedLocales = [
{ code: "en-US", name: "English", dir: "ltr" },
{ code: "de-DE", name: "Deutsch", dir: "ltr" },
{ code: "ar-SA", name: "العربية", dir: "rtl" },
] as const;
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en-US",
supportedLngs: supportedLocales.map((l) => l.code),
load: "currentOnly",
ns: ["common", "products"],
defaultNS: "common",
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
detection: {
order: ["querystring", "cookie", "navigator"],
caches: ["cookie"],
},
});
export default i18n;
// hooks/useLocale.ts
import { useTranslation } from "react-i18next";
import { supportedLocales } from "../i18n/config";
export function useLocale() {
const { i18n } = useTranslation();
const currentLocale =
supportedLocales.find((l) => l.code === i18n.language) ||
supportedLocales[0];
const changeLocale = async (code: string) => {
await i18n.changeLanguage(code);
document.documentElement.dir =
supportedLocales.find((l) => l.code === code)?.dir || "ltr";
document.documentElement.lang = code;
};
return {
locale: currentLocale,
locales: supportedLocales,
changeLocale,
isRTL: currentLocale.dir === "rtl",
};
}
// components/LocaleSwitcher.tsx
import { useLocale } from "../hooks/useLocale";
export function LocaleSwitcher() {
const { locale, locales, changeLocale } = useLocale();
return (
<select
value={locale.code}
onChange={(e) => changeLocale(e.target.value)}
aria-label="选择语言"
>
{locales.map((l) => (
<option key={l.code} value={l.code}>
{l.name}
</option>
))}
</select>
);
}
翻译提取脚本
// scripts/extract-translations.js
const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const sourceDir = "./src";
const outputFile = "./locales/extracted.json";
function extractTranslationKeys(code) {
const keys = new Set();
const ast = parser.parse(code, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
traverse(ast, {
CallExpression(path) {
if (
path.node.callee.name === "t" ||
path.node.callee.property?.name === "t"
) {
const arg = path.node.arguments[0];
if (arg?.type === "StringLiteral") {
keys.add(arg.value);
}
}
},
});
return keys;
}
function walkDir(dir) {
const allKeys = new Set();
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walkDir(filePath).forEach((k) => allKeys.add(k));
} else if (/\.(tsx?|jsx?)$/.test(file)) {
const code = fs.readFileSync(filePath, "utf8");
extractTranslationKeys(code).forEach((k) => allKeys.add(k));
}
}
return allKeys;
}
const keys = walkDir(sourceDir);
const result = {};
keys.forEach((key) => {
result[key] = "";
});
fs.writeFileSync(outputFile, JSON.stringify(result, null, 2));
console.log(`提取了 ${keys.size} 个翻译键`);