国际化与本地化技能Skill i18n

国际化(i18n)与本地化(l10n)技能用于设计和实现多语言应用程序,包括翻译管理、区域特定格式化、RTL布局支持、复数化规则等,覆盖从架构模式到流行库的应用。关键词:国际化、本地化、翻译、多语言、区域格式化、RTL支持、复数化、日期格式、数字格式、货币格式、i18next、react-intl、gettext、ICU MessageFormat、Intl API、CSS逻辑属性、语言检测、SEO优化。

前端开发 0 次安装 0 次浏览 更新于 3/24/2026

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-USfr-FRzh-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} 个翻译键`);