国际化i18n

What — 是什么

国际化(i18n,Internationalization 的缩写)是让应用支持多语言和区域习惯的工程实践,本地化(l10n)是具体语言的适配。

核心概念:

  • 翻译管理:JSON 资源文件、键值对提取与替换
  • ICU MessageFormat:处理复数、性别、变量插值的标准格式
  • 区域格式:日期、数字、货币、排序规则
  • RTL 布局:阿拉伯语、希伯来语等从右到左的语言
  • 动态切换:运行时切换语言,无需刷新页面

关键特性:

  • i18n 是架构层面的准备,l10n 是具体语言的翻译
  • 翻译 key 提取而非硬编码是第一步
  • 日期/数字格式化使用 Intl API,不要手动拼接

Why — 为什么

适用场景:

  • 面向多地区用户的 SaaS 产品
  • 企业级出海项目
  • 多语言内容平台

对比方案:

维度i18nextreact-intlvue-i18nnext-intl
框架框架无关ReactVueNext.js
ICU 格式插件支持原生支持支持
动态加载支持支持支持支持
TypeScript插件支持支持支持
生态最大成熟Vue 官方Next.js 首选

优缺点:

  • ✅ 优点:
    • 扩大用户覆盖范围
    • 翻译与代码分离,非技术人员可维护
    • Intl API 自动处理区域格式
  • ❌ 缺点:
    • 增加包体积(翻译文件)
    • key 管理需要规范
    • RTL 布局需要额外适配

How — 怎么用

React + i18next

配置:

// i18n/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

import en from './locales/en.json';
import zh from './locales/zh.json';

i18n.use(LanguageDetector)
    .use(initReactI18next)
    .init({
        resources: { en: { translation: en }, zh: { translation: zh } },
        fallbackLng: 'en',
        interpolation: { escapeValue: false },
        detection: {
            order: ['localStorage', 'navigator'],
            caches: ['localStorage'],
        },
    });

export default i18n;

翻译文件:

// locales/zh.json
{
    "common": {
        "confirm": "确认",
        "cancel": "取消",
        "delete": "删除"
    },
    "user": {
        "greeting": "你好,{{name}}!",
        "unread": "你有 {{count}} 条未读消息",
        "unread_plural": "你有 {{count}} 条未读消息"
    },
    "order": {
        "status": "订单状态:{{status}}",
        "total": "总计:{{amount}}",
        "items": "{{count}} 件商品"
    }
}

// locales/en.json
{
    "common": {
        "confirm": "Confirm",
        "cancel": "Cancel",
        "delete": "Delete"
    },
    "user": {
        "greeting": "Hello, {{name}}!",
        "unread": "You have {{count}} unread message",
        "unread_plural": "You have {{count}} unread messages"
    },
    "order": {
        "status": "Order status: {{status}}",
        "total": "Total: {{amount}}",
        "items": "{{count}} item(s)"
    }
}

组件中使用:

import { useTranslation } from 'react-i18next';

function UserPanel({ user }: { user: User }) {
    const { t, i18n } = useTranslation();

    return (
        <div>
            <p>{t('user.greeting', { name: user.name })}</p>
            <p>{t('user.unread', { count: user.unreadCount })}</p>

            <select
                value={i18n.language}
                onChange={(e) => i18n.changeLanguage(e.target.value)}
            >
                <option value="zh">中文</option>
                <option value="en">English</option>
            </select>
        </div>
    );
}

Vue + vue-i18n

// i18n/index.ts
import { createI18n } from 'vue-i18n';
import zh from './locales/zh.json';
import en from './locales/en.json';

const i18n = createI18n({
    legacy: false, // Composition API 模式
    locale: localStorage.getItem('locale') || navigator.language.startsWith('zh') ? 'zh' : 'en',
    fallbackLocale: 'en',
    messages: { zh, en },
});

export default i18n;
<template>
    <p>{{ t('user.greeting', { name: user.name }) }}</p>
    <p>{{ t('user.unread', { count: user.unreadCount }) }}</p>
    <select v-model="locale">
        <option value="zh">中文</option>
        <option value="en">English</option>
    </select>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();

// 切换语言时持久化
watch(locale, (val) => localStorage.setItem('locale', val));
</script>

区域格式化

// 数字格式化
const numberFmt = new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
});
numberFmt.format(1234.5); // "¥1,234.50"

new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5);
// "$1,234.50"

new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5);
// "1.234,50 €"

// 日期格式化
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'long' }).format(new Date());
// "2026年5月11日"

new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(new Date());
// "May 11, 2026"

// 相对时间
new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' }).format(-1, 'day');
// "昨天"

new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' }).format(-1, 'day');
// "yesterday"

// 列表格式化
new Intl.ListFormat('zh-CN', { style: 'long' }).format(['张三', '李四', '王五']);
// "张三、李四和王五"

new Intl.ListFormat('en-US', { style: 'long' }).format(['Alice', 'Bob', 'Carol']);
// "Alice, Bob, and Carol"

RTL 布局

/* 使用逻辑属性替代物理方向 */
.card {
    /* ❌ 物理方向 */
    /* margin-left: 16px; */
    /* text-align: left; */

    /* ✅ 逻辑方向:自动适配 RTL */
    margin-inline-start: 16px;
    text-align: start;
    padding-inline: 16px;
    border-inline-end: 1px solid var(--color-border);
}

/* RTL 专属覆盖 */
[dir="rtl"] .icon-arrow {
    transform: scaleX(-1); /* 箭头翻转 */
}
// 切换 RTL
function setDirection(lang: string) {
    const rtlLanguages = ['ar', 'he', 'fa', 'ur'];
    const dir = rtlLanguages.includes(lang) ? 'rtl' : 'ltr';
    document.documentElement.setAttribute('dir', dir);
    document.documentElement.setAttribute('lang', lang);
}

Next.js i18n(App Router)

// next.config.ts
const nextConfig = {
    i18n: {
        locales: ['zh', 'en', 'ja'],
        defaultLocale: 'zh',
    },
};
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';

export default async function LocaleLayout({
    children, params: { locale },
}: {
    children: React.ReactNode;
    params: { locale: string };
}) {
    const messages = await getMessages();

    return (
        <html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
            <body>
                <NextIntlClientProvider messages={messages}>
                    {children}
                </NextIntlClientProvider>
            </body>
        </html>
    );
}

常见问题与踩坑

问题原因解决方案
翻译 key 丢失新增 key 未同步到所有语言文件CI 中用脚本检查 key 完整性
日期格式硬编码2026-01-15 直接拼接Intl.DateTimeFormat
译文溢出 UI德语等比中文长 30%+UI 预留弹性空间,测试长文本
切换语言闪烁全量加载所有翻译按需加载语言包 i18next-http-backend
复数形式错误中文无复数,英文有用 ICU MessageFormat 处理复数

最佳实践

  • 翻译 key 用点分隔命名空间:user.greetingorder.status
  • 日期/数字/货币用 Intl API,不要手动格式化
  • CSS 用逻辑属性(margin-inline-start)适配 RTL
  • CI 检查翻译 key 完整性,防止遗漏
  • UI 测试长文本(德语/法语文本比中文长 30%+)

面试题

Q1: i18n 和 l10n 的区别是什么?

i18n(Internationalization)是架构层面的准备——让代码具备多语言能力(提取 key、抽象格式化、支持 RTL);l10n(Localization)是具体语言的适配——翻译文案、调整日期数字格式、适配区域习惯。i18n 是前提,l10n 是落地。

Q2: Intl API 有哪些用途?为什么推荐用 Intl 而非手动格式化?

Intl API 处理区域敏感的格式化:Intl.NumberFormat(数字/货币)、Intl.DateTimeFormat(日期)、Intl.RelativeTimeFormat(相对时间)、Intl.ListFormat(列表连接)。推荐使用是因为手动格式化无法覆盖所有区域规则(如德语数字用点作千分位),Intl 由浏览器内置,零依赖且规则完整。

Q3: RTL 布局如何适配?

使用 CSS 逻辑属性替代物理方向属性(margin-inline-start 替代 margin-lefttext-align: start 替代 text-left);根元素设置 dir="rtl" 自动翻转布局方向;图标箭头等方向性元素用 transform: scaleX(-1) 翻转;避免在 CSS 中硬编码左右方向。

Q4: 翻译 key 的管理有哪些最佳实践?

使用点分隔命名空间组织 key(user.greetingorder.status),避免扁平化命名导致冲突;CI 中用脚本检查所有语言文件的 key 完整性;key 名体现语义而非翻译内容(用 user.greeting 而非 user.hello);按模块拆分翻译文件避免单文件过大。


相关链接: