CC 咖啡猫的工作空间 Coding Space

前端国际化实践(i18n)

国际化(Internationalization)是前端工程中不可回避的能力建设。本文从基础概念出发,覆盖 vue-i18n 和 react-i18next 两大生态的完整实践,并深入工程化、特殊场景和性能优化。


1. 国际化基础概念

1.1 i18n vs l10n

术语 全称 含义 谁负责
i18n Internationalization 让应用具备支持多语言的能力(框架、架构层面) 前端开发
l10n Localization 特定语言/地区提供翻译内容(文案、格式) 翻译团队 / 运营

关系:i18n 是基础设施,l10n 是内容填充。没有 i18n,l10n 无处安放;没有 l10n,i18n 毫无意义。

1.2 需要国际化的内容

内容类型 说明 处理方式
文本 按钮、标签、提示、错误信息 翻译 Key 映射
日期时间 创建时间、发布时间、倒计时 按 locale 格式化
数字 金额、百分比、数量 按 locale 格式化(千分位、小数点)
货币 价格、余额 locale + 货币单位转换
复数 "1 item" vs "2 items" ICU MessageFormat 复数规则
排序 不同语言的排序规则不同 Intl.Collator / localeCompare
图片/图标 含文字的图片、方向性图标 按语言切换资源
布局方向 阿拉伯语 RTL CSS 逻辑属性 + dir 属性切换

1.3 JavaScript 原生国际化 API

现代浏览器内置了 Intl 对象,很多场景无需额外库:

// 日期格式化
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(new Date())
// => "2026年6月29日星期一"

new Intl.DateTimeFormat('en-US', { dateStyle: 'full' }).format(new Date())
// => "Monday, June 29, 2026"

// 数字格式化
new Intl.NumberFormat('de-DE').format(1234567.89)
// => "1.234.567,89"

// 货币格式化
new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(1234.56)
// => "¥1,234.56"

new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.56)
// => "$1,234.56"

// 复数规则
const pluralRules = new Intl.PluralRules('en-US')
pluralRules.select(1)  // "one"
pluralRules.select(2)  // "other"

防御式提醒Intl API 在旧浏览器(如 Safari < 14)或某些 WebView 中可能缺失部分功能,生产环境建议使用 polyfill:@formatjs/intl-* 系列包。


2. 国际化方案对比

2.1 主流方案

特性 vue-i18n react-i18next react-intl (FormatJS)
生态 Vue 官方 React 生态最流行 FormatJS 官方
最新版本 v10 v24 v7
按需加载 ✅ 动态 loadLocaleMessages ✅ 基于 i18next 的 lazy load @formatjs/intl 按需 polyfill
ICU MessageFormat ✅ 内置 ✅ 需 i18next-icu 插件 ✅ 原生支持
TypeScript 类型支持 ⚠️ 需额外配置 react-i18next TS 类型完善 @formatjs/ts-transformer
Composition API useI18n() useTranslation() useIntl()
Trans 组件(HTML 插值) i18n-t / i18n-n Trans 组件 FormattedMessage + 标签
SSR 兼容 ✅ Nuxt 官方 ✅ Next.js + next-i18next ✅ Next.js + @formatjs/intl
语言检测 需自行集成 i18next-browser-languageDetector 需自行集成
社区插件 较少 丰富(detector, ICU, backend, chained backend) 较少
bundle 体积 ~8KB (gzip) ~12KB + i18next ~5KB ~10KB

2.2 方案选型建议

场景 推荐方案
Vue 3 项目 vue-i18n v10
Nuxt 项目 @nuxtjs/i18n(基于 vue-i18n)
React 项目 react-i18next
Next.js 项目 next-intlnext-i18next
大型多语言应用 react-i18next + i18next-http-backend(动态加载)
SSR 首屏要求高 vue-i18n (lazy-load) / next-intl

3. vue-i18n 实践

3.1 安装与配置

npm install vue-i18n@10
// src/i18n/index.ts
import { createI18n, type I18nOptions } from 'vue-i18n'

// 语言包(小型项目可直接引入)
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'

// 定义支持的语言类型
export type LocaleType = 'zh-CN' | 'en-US' | 'ja-JP'

const i18n = createI18n<I18nOptions, LocaleType>({
  legacy: false,            // 使用 Composition API 模式
  locale: 'zh-CN',          // 默认语言
  fallbackLocale: 'en-US',  // 回退语言
  messages: {
    'zh-CN': zhCN,
    'en-US': enUS,
  },
  // 缺失翻译时的处理
  missing: (locale, key) => {
    console.warn(`[i18n] 缺失翻译: ${locale} - ${key}`)
    // 生产环境可上报监控
    // reportMissingKey(locale, key)
  },
  // 数字格式自定义
  numberFormats: {
    'zh-CN': {
      currency: { style: 'currency', currency: 'CNY', notation: 'standard' },
      decimal: { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 },
      percent: { style: 'percent' },
    },
    'en-US': {
      currency: { style: 'currency', currency: 'USD', notation: 'standard' },
      decimal: { style: 'decimal', minimumFractionDigits: 2 },
      percent: { style: 'percent' },
    },
  },
  // 日期时间格式自定义
  datetimeFormats: {
    'zh-CN': {
      short: { year: 'numeric', month: '2-digit', day: '2-digit' },
      long: { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' },
    },
    'en-US': {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' },
    },
  },
})

export default i18n
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import i18n from './i18n'

const app = createApp(App)
app.use(i18n)
app.mount('#app')

3.2 语言包文件组织

// src/i18n/locales/zh-CN.json
{
  "common": {
    "submit": "提交",
    "cancel": "取消",
    "confirm": "确认",
    "loading": "加载中...",
    "noData": "暂无数据",
    "retry": "重试",
    "search": "搜索",
    "reset": "重置"
  },
  "user": {
    "title": "用户管理",
    "login": "登录",
    "logout": "退出登录",
    "welcome": "你好,{name}",
    "welcomeWithTitle": "你好,{name}!您是{title}",
    "onlineCount": "当前在线:{count} 人",
    "onlineCount_plural": "当前在线:{count} 人",
    "profile": {
      "avatar": "头像",
      "nickname": "昵称",
      "email": "邮箱",
      "phone": "手机号"
    }
  },
  "error": {
    "network": "网络异常,请稍后重试",
    "timeout": "请求超时",
    "serverError": "服务器错误({code})"
  },
  "validation": {
    "required": "{field}不能为空",
    "email": "请输入有效的邮箱地址",
    "minLength": "{field}至少需要{min}个字符",
    "maxLength": "{field}不能超过{max}个字符"
  }
}
// src/i18n/locales/en-US.json
{
  "common": {
    "submit": "Submit",
    "cancel": "Cancel",
    "confirm": "Confirm",
    "loading": "Loading...",
    "noData": "No Data",
    "retry": "Retry",
    "search": "Search",
    "reset": "Reset"
  },
  "user": {
    "title": "User Management",
    "login": "Login",
    "logout": "Logout",
    "welcome": "Hello, {name}",
    "welcomeWithTitle": "Hello, {name}! You are {title}",
    "onlineCount": "{count} online | {count} online",
    "profile": {
      "avatar": "Avatar",
      "nickname": "Nickname",
      "email": "Email",
      "phone": "Phone"
    }
  },
  "error": {
    "network": "Network error, please try again later",
    "timeout": "Request timeout",
    "serverError": "Server error ({code})"
  },
  "validation": {
    "required": "{field} is required",
    "email": "Please enter a valid email address",
    "minLength": "{field} must be at least {min} characters",
    "maxLength": "{field} must not exceed {max} characters"
  }
}

3.3 语言包文件组织方式对比

组织方式 优点 缺点 适用场景
单 JSON 文件 简单直观,IDE 搜索方便 项目变大后文件膨胀,不易拆分 小型项目(< 200 条文案)
按模块拆分 JSON 按需加载,职责清晰 需管理多文件导入 中大型项目
YAML 可读写性好,支持注释 无类型检查,需额外 loader 翻译团队主导流程
TypeScript 类型安全,编译期检查 翻译人员可能不熟悉 TS 纯开发团队维护
按命名空间拆分(推荐) 按页面/模块懒加载,性能好 需 i18n 配置支持 lazy load 大型应用

3.4 在模板中使用

<!-- 使用 $t 函数 -->
<template>
  <div class="user-profile">
    <!-- 普通文本 -->
    <h1>{{ $t('user.title') }}</h1>

    <!-- 参数插值 -->
    <p>{{ $t('user.welcome', { name: userStore.currentUser?.nickname ?? '--' }) }}</p>

    <!-- 复数 -->
    <p>{{ $t('user.onlineCount', { count: onlineUsers }) }}</p>

    <!-- 数字格式化 -->
    <p>{{ $n(price, 'currency') }}</p>

    <!-- 日期格式化 -->
    <p>{{ $d(new Date(), 'short') }}</p>

    <!-- v-t 指令(性能优于 $t,适合纯文本节点) -->
    <span v-t="'common.submit'" class="btn-primary" />

    <!-- 表单校验场景 -->
    <ElFormItem
      :label="$t('user.profile.email')"
      :rules="[
        { required: true, message: $t('validation.required', { field: $t('user.profile.email') }) },
        { type: 'email', message: $t('validation.email') },
      ]"
    >
      <ElInput v-model="email" />
    </ElFormItem>
  </div>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'

const { t, n, d } = useI18n()
const userStore = useUserStore()

const onlineUsers = ref(128)
const price = ref(1999.99)

// $t 在 Composition API 中等价于 t()
const pageTitle = computed(() => t('user.title'))
useHead({ title: pageTitle })
</script>

3.5 v-t 指令 vs $t 函数

对比维度 $t() 函数 v-t 指令
使用方式 模板/脚本中调用 指令绑定
性能 每次渲染重新计算 惰性更新,语言切换时自动更新
支持参数 ⚠️ 仅限于指令值传递
灵活度 可与表达式、计算属性组合 仅支持静态 key
推荐场景 动态参数、组合逻辑 纯文本节点、高频更新场景

3.6 语言切换与动态加载

// src/composables/useLocale.ts
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'

export function useLocale() {
  const { locale, availableLocales, setLocaleMessage, mergeLocaleMessage } = useI18n()
  const router = useRouter()

  // 支持的语言列表
  const locales = [
    { code: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
    { code: 'en-US', label: 'English', flag: '🇺🇸' },
    { code: 'ja-JP', label: '日本語', flag: '🇯🇵' },
  ]

  // 切换语言
  const switchLocale = async (lang: string) => {
    // 防御:检查语言是否已注册
    if (!locales.some((l) => l.code === lang)) {
      console.error(`[i18n] 不支持的语言: ${lang}`)
      return
    }

    try {
      // 动态加载语言包(按需加载,减少首屏体积)
      const messages = await import(`../i18n/locales/${lang}.json`)
      setLocaleMessage(lang, messages.default)

      locale.value = lang
      document.documentElement.lang = lang

      // 持久化到 localStorage
      localStorage.setItem('app-locale', lang)

      // Element Plus 语言包切换
      // import('element-plus/dist/locale/zh-cn.mjs') etc.
      // locale.value === 'zh-CN'
      //   ? (ElMessage.i18n = zhCn)
      //   : (ElMessage.i18n = en)

      // 更新路由 query(可选)
      router.replace({ query: { ...router.currentRoute.value.query, lang } })
    } catch (err) {
      console.error(`[i18n] 加载语言包失败: ${lang}`, err)
      ElMessage.error('语言包加载失败')
    }
  }

  // 初始化:从 localStorage / 浏览器偏好读取
  const initLocale = () => {
    const saved = localStorage.getItem('app-locale') as string | null
    if (saved && locales.some((l) => l.code === saved)) {
      locale.value = saved
      return
    }

    // 浏览器语言检测(取前两位匹配)
    const browserLang = navigator.language
    const matched = locales.find((l) => l.code.startsWith(browserLang.slice(0, 2)))
    if (matched) {
      locale.value = matched.code
    }
  }

  return {
    locale,
    locales,
    switchLocale,
    initLocale,
  }
}
<!-- 语言切换组件 -->
<template>
  <ElDropdown trigger="click" @command="switchLocale">
    <span class="locale-switcher">
      <span class="locale-flag">{{ currentLocale?.flag }}</span>
      {{ currentLocale?.label }}
      <ElIcon><ArrowDown /></ElIcon>
    </span>
    <template #dropdown>
      <ElDropdownMenu>
        <ElDropdownItem
          v-for="loc in locales"
          :key="loc.code"
          :command="loc.code"
          :disabled="loc.code === currentLocale?.code"
        >
          {{ loc.flag }} {{ loc.label }}
        </ElDropdownItem>
      </ElDropdownMenu>
    </template>
  </ElDropdown>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useLocale } from '@/composables/useLocale'

const { locale, locales, switchLocale } = useLocale()

const currentLocale = computed(() =>
  locales.find((l) => l.code === locale.value),
)
</script>

3.7 消息格式进阶

参数插值

{
  "user": {
    "welcome": "你好,{name}!您是{title}",
    "score": "您的积分为:{score}",
    "orderCount": "您有{count}个订单"
  }
}
<template>
  <p>{{ $t('user.welcome', { name: '张三', title: '管理员' }) }}</p>
  <!-- 输出:你好,张三!您是管理员 -->

  <!-- 数字格式化参数 $n() 支持链式 -->
  <p>{{ $t('user.score', { score: $n(12800.5, 'decimal') }) }}</p>
  <!-- 中文输出:您的积分为:12,800.50 -->
</template>

复数处理

vue-i18n 复数语法基于 ICU 标准,通过 管道符 | 分隔不同复数形式:

{
  "items": "没有商品 | {count} 个商品",
  "itemsWithZero": "没有商品 | {count} 个商品 | {count} 个商品"
}
<template>
  <p>{{ $t('items', { count: 0 }) }}</p>
  <!-- 中文输出:没有商品 -->
  <p>{{ $t('items', { count: 1 }) }}</p>
  <!-- 中文输出:1 个商品 -->
  <p>{{ $t('items', { count: 5 }) }}</p>
  <!-- 中文输出:5 个商品 -->

  <p>{{ $t('itemsWithZero', { count: 0 }) }}</p>
  <!-- 英文 itemsWithZero: "no items | {count} item | {count} items" -->
  <!-- 英文输出:no items -->
</template>

复数规则因语言而异——中文没有复数形态变化,英文有单数/复数,阿拉伯语有 6 种复数形式。vue-i18n 使用 Intl.PluralRules 自动匹配语言的复数规则:

{
  "key": "zero | one | two | few | many | other"
}

日期时间格式化

<template>
  <!-- 使用预设格式名 -->
  <p>{{ $d(new Date(), 'short') }}</p>
  <p>{{ $d(new Date(), 'long') }}</p>

  <!-- 使用完整选项 -->
  <p>{{ $d(new Date(), { dateStyle: 'full', timeStyle: 'medium' }) }}</p>
</template>

相对时间

// 相对时间需额外注册(vue-i18n v10 需要搭配 @vueuse/core 或自行实现)
import { useTimeAgo } from '@vueuse/core'

const createdAt = new Date('2026-06-28T10:00:00Z')
const timeAgo = useTimeAgo(createdAt)
// 输出:"1 day ago"(根据当前语言环境)

3.8 组件插值(在翻译文本中嵌入链接/按钮)

<template>
  <!-- i18n-t 组件:在翻译文本中嵌入 HTML 元素 -->
  <i18n-t keypath="terms.agreement" tag="p">
    <template #link>
      <ElLink type="primary" href="/terms" target="_blank">{{ $t('terms.userAgreement') }}</ElLink>
    </template>
    <template #privacy>
      <ElLink type="primary" href="/privacy" target="_blank">{{ $t('terms.privacyPolicy') }}</ElLink>
    </template>
  </i18n-t>

  <!-- i18n-n 组件:数字格式化插值 -->
  <i18n-n :value="1234567" format="currency" tag="span" />
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
{
  "terms": {
    "agreement": "登录即表示您同意{link}和{privacy}",
    "userAgreement": "《用户协议》",
    "privacyPolicy": "《隐私政策》"
  }
}

注意事项:HTML 插值存在 XSS 风险。永远不要插值用户输入的内容。i18n-t 使用具名插槽而非 v-html,在安全性上更优。

3.9 Composition API 完整示例

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'

const { t, locale, availableLocales, n, d } = useI18n()
const userStore = useUserStore()

// 列表页国际化
const columns = computed(() => [
  { key: 'name', label: t('user.profile.nickname') },
  { key: 'email', label: t('user.profile.email') },
  { key: 'phone', label: t('user.profile.phone') },
  { key: 'createdAt', label: t('common.createdAt') },
  { key: 'actions', label: t('common.actions') },
])

// 动态表单校验
const formRules = computed(() => ({
  email: [
    { required: true, message: t('validation.required', { field: t('user.profile.email') }), trigger: 'blur' },
    { type: 'email', message: t('validation.email'), trigger: 'blur' },
  ],
  nickname: [
    { required: true, message: t('validation.required', { field: t('user.profile.nickname') }), trigger: 'blur' },
    { min: 2, max: 20, message: t('validation.minLength', { field: t('user.profile.nickname'), min: 2 }) },
  ],
}))

// 监听语言变化,更新 Element Plus 组件语言
watch(locale, (newLocale) => {
  // 动态切换 UI 库语言包
  import(`element-plus/dist/locale/${newLocale.slice(0, 2)}.mjs`).then((mod) => {
    // app.config.globalProperties.$ELEMENT.locale = mod.default
  })

  // Pinia 中存储当前语言
  userStore.setLanguage(newLocale)

  // 上报埋点
  // trackEvent('locale_switch', { locale: newLocale })
})
</script>

4. react-i18next 实践

4.1 安装与配置

npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend
// src/i18n/index.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import Backend from 'i18next-http-backend'

i18n
  .use(Backend)                              // 动态加载语言包
  .use(LanguageDetector)                     // 浏览器语言检测
  .use(initReactI18next)                     // 绑定 React
  .init({
    fallbackLng: 'en-US',
    debug: import.meta.env.DEV,              // 开发环境下开启调试

    // 命名空间配置
    ns: ['common', 'user', 'error', 'validation'],
    defaultNS: 'common',

    // 语言检测优先级
    detection: {
      order: ['localStorage', 'navigator', 'path', 'subdomain'],
      caches: ['localStorage'],
      lookupLocalStorage: 'app-locale',
    },

    // 缺失 key 处理
    saveMissing: import.meta.env.DEV,
    missingKeyHandler: (lngs: string[], ns: string, key: string) => {
      console.warn(`[i18n] 缺失翻译 [${lngs.join(', ')}]: ${ns}:${key}`)
    },

    interpolation: {
      escapeValue: false,  // React 默认已做 XSS 的 escape
    },

    returnObjects: true,   // 允许翻译返回对象(如嵌套的 validation 消息)

    react: {
      useSuspense: false,  // 关闭 Suspense,避免白屏
    },
  })

export default i18n
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './i18n'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

4.2 语言包配置

// public/locales/zh-CN/common.json
{
  "submit": "提交",
  "cancel": "取消",
  "loading": "加载中...",
  "noData": "暂无数据",
  "retry": "重试",
  "modalTitle": "确认操作",
  "modalContent": "确定要{action}吗?此操作不可撤销"
}

// public/locales/zh-CN/user.json
{
  "title": "用户管理",
  "welcome": "你好,{{name}}",
  "onlineCount_one": "当前 {{count}} 人在线",
  "onlineCount_other": "当前 {{count}} 人在线"
}
// public/locales/en-US/common.json
{
  "submit": "Submit",
  "cancel": "Cancel",
  "loading": "Loading...",
  "noData": "No Data",
  "retry": "Retry",
  "modalTitle": "Confirm Action",
  "modalContent": "Are you sure you want to {{action}}? This action cannot be undone."
}

// public/locales/en-US/user.json
{
  "title": "User Management",
  "welcome": "Welcome, {{name}}",
  "onlineCount_one": "{{count}} user online",
  "onlineCount_other": "{{count}} users online"
}

4.3 useTranslation Hook

import { useTranslation } from 'react-i18next'

function UserList() {
  const { t, i18n } = useTranslation('user')

  // 不传参数时使用默认 ns(common)
  const { t: tCommon } = useTranslation()

  // 使用 t 函数
  const title = t('title')                      // "用户管理"
  const welcome = t('welcome', { name: user?.nickname ?? '--' })

  // 使用其他命名空间
  const submitLabel = tCommon('common:submit')

  // 复数
  const onlineText = t('onlineCount', { count: users.length })

  // 嵌套 key 使用点号访问
  const avatarLabel = t('profile.avatar')

  return (
    <div>
      <h1>{title}</h1>
      <p>{welcome}</p>
      <p>{onlineText}</p>
    </div>
  )
}

4.4 Trans 组件(嵌入 HTML 元素)

import { Trans, useTranslation } from 'react-i18next'

function TermsBlock() {
  const { t } = useTranslation()

  return (
    <p>
      <Trans
        i18nKey="terms.agreement"
        components={{
          link: <a href="/terms" target="_blank" rel="noopener noreferrer" />,
          privacy: <a href="/privacy" target="_blank" rel="noopener noreferrer" />,
        }}
      />
    </p>
  )
}
// common.json
{
  "terms": {
    "agreement": "By logging in, you agree to <link>Terms of Service</link> and <privacy>Privacy Policy</privacy>"
  }
}

Trans 组件解析 <link>...</link> 标签并映射到 components 中定义的 React 元素。这避免了 dangerouslySetInnerHTML,是安全的插值方式。

4.5 命名空间与按需加载

import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'

// 页面级加载对应命名空间
function UserProfilePage() {
  const { t, i18n, ready } = useTranslation(['user', 'validation'], { useSuspense: false })

  // 手动加载额外命名空间
  const handleViewErrorDetail = async () => {
    // 第一次查看错误详情时才加载 error 命名空间
    if (!i18n.hasResourceBundle(i18n.language, 'error')) {
      await i18n.loadNamespaces('error')
    }
  }

  if (!ready) {
    return <div>Loading translations...</div>
  }

  return (
    <div>
      <h1>{t('user:title')}</h1>
      <p>{t('validation:required', { field: t('user:profile.email') })}</p>
    </div>
  )
}

4.6 语言切换

import { useTranslation } from 'react-i18next'
import { Select } from 'antd'

const locales = [
  { code: 'zh-CN', label: '简体中文' },
  { code: 'en-US', label: 'English' },
  { code: 'ja-JP', label: '日本語' },
]

function LocaleSwitcher() {
  const { i18n } = useTranslation()

  const handleChange = async (lang: string) => {
    try {
      await i18n.changeLanguage(lang)
      document.documentElement.lang = lang
      localStorage.setItem('app-locale', lang)

      // Ant Design 语言包切换
      // import('antd/locale/zh_CN').then(mod => setAntdLocale(mod.default))
    } catch (err) {
      console.error('语言切换失败:', err)
    }
  }

  // 语言检测初始化时自动完成
  return (
    <Select
      value={i18n.language}
      onChange={handleChange}
      options={locales.map((l) => ({ value: l.code, label: l.label }))}
    />
  )
}

4.7 完整示例:用户管理页

import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Table, Button, Modal, message, type ColumnsType } from 'antd'
import { useUserStore } from '@/stores/user'

interface User {
  id: number
  name: string
  email: string
  createdAt: string
}

function UserManagementPage() {
  const { t, i18n } = useTranslation(['user', 'common', 'validation'])
  const userStore = useUserStore()

  const [users, setUsers] = useState<User[]>([])
  const [loading, setLoading] = useState(false)

  const columns: ColumnsType<User> = [
    { title: t('user:profile.nickname'), dataIndex: 'name', key: 'name' },
    { title: t('user:profile.email'), dataIndex: 'email', key: 'email' },
    {
      title: t('common:createdAt', { defaultValue: 'Created At' }),
      dataIndex: 'createdAt',
      key: 'createdAt',
      render: (val: string) => new Intl.DateTimeFormat(i18n.language).format(new Date(val)),
    },
    {
      title: t('common:actions'),
      key: 'actions',
      render: (_, record) => (
        <Button
          danger
          onClick={() => handleDelete(record)}
        >
          {t('common:delete')}
        </Button>
      ),
    },
  ]

  const handleDelete = (user: User) => {
    Modal.confirm({
      title: t('common:modalTitle'),
      content: t('common:modalContent', { action: t('common:delete') }),
      okText: t('common:confirm'),
      cancelText: t('common:cancel'),
      onOk: async () => {
        try {
          // 删除逻辑
          message.success(t('common:operationSuccess'))
        } catch {
          message.error(t('common:operationFailed'))
        }
      },
    })
  }

  const fetchUsers = async () => {
    setLoading(true)
    try {
      const res = await fetch('/api/users')
      const data = await res.json()
      setUsers(data)
    } catch {
      message.error(t('error:network'))
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    fetchUsers()
  }, [])

  return (
    <div>
      <h1>{t('user:title')}</h1>
      <Table
        loading={loading}
        columns={columns}
        dataSource={users}
        rowKey="id"
        locale={{
          emptyText: t('common:noData'),
        }}
      />
    </div>
  )
}

4.8 缺失翻译检测(开发环境)

// src/i18n/missingReporter.ts
import i18n from './index'

if (import.meta.env.DEV) {
  i18n.on('missingKey', (lngs: string[], ns: string, key: string) => {
    console.warn(
      `[i18n] 缺失翻译: 语言=${lngs.join(',')} 命名空间=${ns} key=${key}`,
    )
  })
}

5. 国际化工程化

5.1 文案提取与翻译流程

开发阶段                    翻译阶段                    发布阶段
─────────                  ─────────                  ─────────
开发写中文文案                 翻译平台                     CI 检查
    │                          │                          │
    ▼                          ▼                          ▼
自动提取中文                 人工/机器翻译              检查翻译完整性
key + 默认文案              生成目标语言包              校验缺失翻译
    │                          │                          │
    ▼                          ▼                          ▼
提交代码                     PR 包含翻译               不通过则阻断发布

5.2 翻译平台集成

平台 特点 集成方式 定价
Crowdin 生态完善,支持 400+ 语言,Git 集成 GitHub/GitLab 同步 + CLI 免费 1 个项目
Lokalise 开发者友好,CLI 强大,截图上下文 CLI 上传/下载 + API 付费 40 美元/月起
POEditor 简单易用,支持术语管理 API + Webhook 免费小团队
Phrase (ex-PhraseApp) 企业级,CI/CD 集成 CLI + GitHub 付费
SIL International 开源本地化平台 Self-hosted 免费

CRUD 集成命令行示例

# 安装 Crowdin CLI
npm install -g @crowdin/cli

# 配置文件 crowdin.yml
cat > crowdin.yml << 'EOF'
project_id: "你的项目ID"
api_token: "${CROWDIN_API_TOKEN}"
base_path: "."

files:
  - source: /src/i18n/locales/zh-CN.json
    translation: /src/i18n/locales/%two_letters_code%.json
    update_option: update_as_unapproved
EOF

# 上传源语言包
crowdin upload sources

# 下载翻译
crowdin download

5.3 自动提取中文文案脚本

// scripts/extract-i18n-keys.ts
// 从源码中提取所有 $t() / t() 调用中的 key,生成待翻译清单

import * as fs from 'node:fs'
import * as path from 'node:path'
import { parse, type File } from '@babel/parser'
import traverse from '@babel/traverse'
import glob from 'fast-glob'

interface I18nKey {
  key: string
  file: string
  line: number
  defaultMessage?: string
}

const I18N_PATTERNS = [
  /t\(['"](.+?)['"]/g,        // t('xxx') / t("xxx")
  /\$t\(['"](.+?)['"]/g,      // $t('xxx')
  /keypath=['"](.+?)['"]/g,   // <i18n-t keypath="xxx">
  /i18nKey="(.+?)"/g,         // Trans i18nKey="xxx"
]

async function extractI18nKeys(sourceDir: string, extension: string): Promise<I18nKey[]> {
  const files = await glob(`**/*.${extension}`, {
    cwd: sourceDir,
    ignore: ['node_modules/**', 'dist/**', '**/*.test.*', '**/*.spec.*'],
  })

  const keys: I18nKey[] = []

  for (const file of files) {
    const content = fs.readFileSync(path.join(sourceDir, file), 'utf-8')
    const lines = content.split('\n')
    const matchedKeys = new Set<string>()

    // 正则匹配
    for (let i = 0; i < lines.length; i++) {
      for (const pattern of I18N_PATTERNS) {
        pattern.lastIndex = 0
        const match = pattern.exec(lines[i])
        if (match && !matchedKeys.has(match[1])) {
          matchedKeys.add(match[1])
          keys.push({
            key: match[1],
            file: file,
            line: i + 1,
          })
        }
      }
    }
  }

  return keys
}

// 导出为 CSV 供翻译团队使用
function exportToCSV(keys: I18nKey[], outputPath: string) {
  const header = 'key,file,line,defaultMessage\n'
  const rows = keys.map((k) => `${k.key},${k.file},${k.line},${k.defaultMessage ?? ''}`)
  fs.writeFileSync(outputPath, header + rows.join('\n'), 'utf-8')
  console.log(`导出 ${keys.length} 条文案到 ${outputPath}`)
}

// 使用
// const keys = await extractI18nKeys('./src', '(ts|tsx|vue)$')
// exportToCSV(keys, './i18n-keys.csv')

5.4 缺失翻译检测(CI 集成)

// scripts/check-i18n-completeness.ts
// CI 中检查:所有语言包是否与源语言包 key 一致

import * as fs from 'node:fs'
import * as path from 'node:path'

interface TranslationReport {
  missingKeys: string[]
  extraKeys: string[]
  totalSource: number
  totalTarget: number
}

function getAllKeys(obj: Record<string, unknown>, prefix = ''): string[] {
  const keys: string[] = []
  for (const [key, value] of Object.entries(obj)) {
    const fullKey = prefix ? `${prefix}.${key}` : key
    if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
      keys.push(...getAllKeys(value as Record<string, unknown>, fullKey))
    } else {
      keys.push(fullKey)
    }
  }
  return keys
}

function checkTranslation(sourcePath: string, targetPath: string): TranslationReport {
  const source = JSON.parse(fs.readFileSync(sourcePath, 'utf-8'))
  const target = JSON.parse(fs.readFileSync(targetPath, 'utf-8'))

  const sourceKeys = new Set(getAllKeys(source))
  const targetKeys = new Set(getAllKeys(target))

  return {
    missingKeys: [...sourceKeys].filter((k) => !targetKeys.has(k)),
    extraKeys: [...targetKeys].filter((k) => !sourceKeys.has(k)),
    totalSource: sourceKeys.size,
    totalTarget: targetKeys.size,
  }
}

// 检查所有语言 vs 中文
const localesDir = path.resolve(__dirname, '../src/i18n/locales')
const zhPath = path.join(localesDir, 'zh-CN.json')
const files = fs.readdirSync(localesDir)

let hasError = false
for (const file of files) {
  if (file === 'zh-CN.json') continue
  const report = checkTranslation(zhPath, path.join(localesDir, file))
  if (report.missingKeys.length > 0) {
    console.error(`[❌] ${file} 缺失 ${report.missingKeys.length} 个翻译:`)
    report.missingKeys.forEach((k) => console.error(`  - ${k}`))
    hasError = true
  }
  if (report.extraKeys.length > 0) {
    console.warn(`[⚠️] ${file} 有 ${report.extraKeys.length} 个多余 key(可能已废弃)`)
  }
}

if (hasError) {
  console.error('\n翻译完整性检查不通过!请补全缺失翻译后再提交。')
  process.exit(1)
} else {
  console.log('✅ 所有语言包翻译完整')
}
# .github/workflows/i18n-check.yml
name: i18n Check
on: [pull_request]

jobs:
  check-translations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npx tsx scripts/check-i18n-completeness.ts

5.5 语言包体积控制

// vite.config.ts(Vite 项目)
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          // 按语言拆分包
          if (id.includes('i18n/locales/')) {
            const match = id.match(/locales\/(.+)\.json$/)
            if (match) {
              return `lang-${match[1]}`
            }
          }
        },
      },
    },
  },
})
// next.config.js(Next.js 项目)
const nextConfig = {
  webpack(config) {
    // 按语言拆分 locale chunk
    config.plugins.push(
      new webpack.ContextReplacementPlugin(
        /i18n\/locales/,
        /zh-CN|en-US|ja-JP/,
      ),
    )
    return config
  },
}

6. 特殊场景处理

6.1 RTL(从右到左)布局适配

阿拉伯语(ar-SA)、希伯来语(he-IL)等 RTL 语言需要镜像整个 UI 布局。

/* 方案一:CSS 逻辑属性(推荐) */
.card {
  /* ✅ 好的实践:使用逻辑属性 */
  margin-inline-start: 16px;
  padding-inline: 24px;
  border-inline-start: 3px solid var(--primary);
  text-align: start;

  /* ❌ 不好的实践:硬编码物理属性 */
  /* margin-left: 16px; */
  /* padding-left: 24px; */
  /* text-align: left; */
}

/* 方案二:dir 选择器 */
[dir="rtl"] .arrow {
  transform: scaleX(-1);
}

[dir="rtl"] .breadcrumb-separator {
  transform: rotate(180deg);
}
// 语言切换时设置 dir
const switchLocale = async (lang: string) => {
  const rtlLanguages = ['ar', 'he', 'fa', 'ur']

  document.documentElement.dir = rtlLanguages.some((prefix) =>
    lang.startsWith(prefix),
  )
    ? 'rtl'
    : 'ltr'

  // antd / element-plus 也需切换
  // Ant Design 自动根据 locale 切换 dir
}
CSS 物理属性 CSS 逻辑属性 说明
left / right inset-inline-start / inset-inline-end 定位
margin-left / margin-right margin-inline-start / margin-inline-end 外边距
padding-left / padding-right padding-inline-start / padding-inline-end 内边距
border-left / border-right border-inline-start / border-inline-end 边框
text-align: left / right text-align: start / end 文本对齐
transform: translateX 需配合 dir 检测 动画

防御式提示:CSS 逻辑属性在旧浏览器(如 IE11)中不支持。如果仍需兼容,可使用 PostCSS 插件 postcss-logical 进行编译期转换,或通过 [dir] 选择器覆写。

6.2 日期时间本地化

// 使用 dayjs locale(Vue 项目)
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/en'
import relativeTime from 'dayjs/plugin/relativeTime'

dayjs.extend(relativeTime)

// 监听语言切换,更新 dayjs locale
watch(locale, (newLocale) => {
  const dayjsLocaleMap: Record<string, string> = {
    'zh-CN': 'zh-cn',
    'en-US': 'en',
    'ja-JP': 'ja',
  }
  dayjs.locale(dayjsLocaleMap[newLocale] ?? 'en')
})

// 使用
dayjs().format('LL')           // "2026年6月29日" (zh-cn)
dayjs().fromNow()              // "3天前" (zh-cn)
// 使用 date-fns locale(React 项目)
import { format, formatDistanceToNow } from 'date-fns'
import { zhCN, enUS } from 'date-fns/locale'
import { useTranslation } from 'react-i18next'

const localeMap: Record<string, Locale> = {
  'zh-CN': zhCN,
  'en-US': enUS,
}

function FormattedDate({ date }: { date: Date | string }) {
  const { i18n } = useTranslation()
  const locale = localeMap[i18n.language] ?? enUS

  return (
    <time dateTime={typeof date === 'string' ? date : date.toISOString()}>
      {format(new Date(date), 'PPP', { locale })}
    </time>
  )
}

function TimeAgo({ date }: { date: Date }) {
  const { i18n } = useTranslation()
  const locale = localeMap[i18n.language] ?? enUS

  return (
    <span title={format(date, 'PPPpp', { locale })}>
      {formatDistanceToNow(date, { addSuffix: true, locale })}
    </span>
  )
}

6.3 动态内容的国际化(后端返回的多语言字段)

后端返回的数据中可能包含需要国际化的字段。常见的方案有三种:

interface ApiProduct {
  id: number
  name: string        // 中文名称
  nameEn?: string     // 英文名称
  nameJa?: string     // 日文名称
  description: string
  // ...
}

// 方案一:后端返回多语言字段
interface ApiProduct_i18n {
  id: number
  name_i18n: {
    'zh-CN': string
    'en-US': string
    'ja-JP': string
  }
  description_i18n: Record<string, string>
}
// 方案二:前端根据当前语言选择字段(配合 Pinia)
// stores/locale.ts
import { defineStore } from 'pinia'
import { useI18n } from 'vue-i18n'

export const useLocaleStore = defineStore('locale', () => {
  const { locale } = useI18n()

  // 获取国际化字段
  function getLocalizedField<T extends Record<string, string>>(
    i18nFields: T,
    defaultValue = '--',
  ): string {
    // 防御:空值兜底
    if (!i18nFields) return defaultValue

    // 精确匹配优先
    if (i18nFields[locale.value]) return i18nFields[locale.value]

    // fallback 到英文
    if (i18nFields['en-US']) return i18nFields['en-US']

    // fallback 到第一个可用值
    const values = Object.values(i18nFields)
    return values.length > 0 ? values[0] : defaultValue
  }

  return { getLocalizedField }
})
<script setup lang="ts">
import { useLocaleStore } from '@/stores/locale'

const localeStore = useLocaleStore()

const product = ref<ApiProduct_i18n>()

// 在模板中使用
const productName = computed(() =>
  localeStore.getLocalizedField(product.value?.name_i18n ?? {}),
)

const productDesc = computed(() =>
  localeStore.getLocalizedField(product.value?.description_i18n ?? {}),
)
</script>

<template>
  <div class="product-card">
    <h3>{{ productName }}</h3>
    <p>{{ productDesc }}</p>
  </div>
</template>
// React 版本:自定义 Hook
function useLocalizedField() {
  const { i18n } = useTranslation()

  return useCallback(
    <T extends Record<string, string>>(
      i18nFields: T | null | undefined,
      defaultValue = '--',
    ): string => {
      if (!i18nFields) return defaultValue
      if (i18nFields[i18n.language]) return i18nFields[i18n.language]
      if (i18nFields['en-US']) return i18nFields['en-US']

      const values = Object.values(i18nFields)
      return values.length > 0 ? values[0] : defaultValue
    },
    [i18n.language],
  )
}

// 使用
function ProductCard({ product }: { product: ApiProduct_i18n }) {
  const getField = useLocalizedField()

  return (
    <div>
      <h3>{getField(product?.name_i18n)}</h3>
      <p>{getField(product?.description_i18n)}</p>
    </div>
  )
}

6.4 浏览器语言检测

// 浏览器语言检测的完整方案
function detectBrowserLanguage(): string {
  // navigator.language vs navigator.languages
  // navigator.language 只返回一个
  // navigator.languages 返回用户偏好列表(Chrome 76+ / Firefox 32+ / Safari 12.1+)

  const languages = navigator.languages ?? [navigator.language]

  const supported = ['zh-CN', 'en-US', 'ja-JP', 'ar-SA']

  for (const lang of languages) {
    // 精确匹配
    if (supported.includes(lang)) return lang

    // 前缀匹配(如 navigator.language = 'zh' 匹配 'zh-CN')
    const prefix = lang.slice(0, 2)
    const matched = supported.find((s) => s.startsWith(prefix))
    if (matched) return matched
  }

  return 'en-US' // 默认回退
}

7. 注意事项与性能优化

7.1 语言包体积管理

优化手段 说明 效果
按需加载 仅加载当前语言包 首屏减少 ~80KB
按模块拆分 按页面/功能拆分命名空间 懒加载减少 ~60%
压缩 JSON 去除空格、短 key 减少 20-30%
Tree Shaking 删除未使用的 key 视项目而定
CDN 托管 语言包放 CDN,利用缓存 减少服务器带宽

7.2 运行时性能

// ❌ 不好的实践:每次渲染都调用 t()
// 组件每次重渲染都会重新计算翻译
function BadComponent() {
  return <div>{t('nav.' + currentTab)}</div>
}

// ✅ 好的实践:使用 useMemo(React)或 computed(Vue)
// 只在 key 或参数变化时重新计算
function GoodComponent() {
  const navLabel = useMemo(
    () => t('nav.' + currentTab),
    [currentTab],
  )
  return <div>{navLabel}</div>
}
<!-- Vue 中的优化 -->
<script setup lang="ts">
// ✅ 好的实践:使用 computed 缓存翻译结果
const pageTitle = computed(() => t('user.title'))
const welcomeMsg = computed(() => t('user.welcome', { name: user.value?.name ?? '--' }))

// ❌ 不好的实践:直接在模板中调用 $t()
// 每一次重渲染都会重新翻译
</script>

7.3 SSR 兼容

// Nuxt 3 国际化配置
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'zh-CN', name: '简体中文', iso: 'zh-CN', file: 'zh-CN.json' },
      { code: 'en-US', name: 'English', iso: 'en-US', file: 'en-US.json' },
    ],
    defaultLocale: 'zh-CN',
    lazy: true,                      // 懒加载语言包
    langDir: 'i18n/locales/',        // 语言包目录
    strategy: 'prefix_except_default', // URL 前缀策略
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root',            // 根路径重定向
    },
  },
})
// Next.js + next-intl
// middleware.ts
import createMiddleware from 'next-intl/middleware'

export default createMiddleware({
  locales: ['zh-CN', 'en-US', 'ja-JP'],
  defaultLocale: 'zh-CN',
  localeDetection: true,
})

export const config = {
  matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
}

7.4 常见踩坑点

1. 在 setup 外部使用 $t()
   ❌ const title = t('nav.home')
   ✅ const { t } = useI18n()  // 必须在 setup/组件上下文中

2. 语言包 key 冲突
   ❌ user.title 和 page.title 同名不同义
   ✅ 使用命名空间隔离:user:title vs page:title

3. 忘记转义动态内容
   ❌ $t('welcome', { name: userInput })  // XSS 风险
   ✅ react-i18next 默认 escapeValue=true
   ✅ vue-i18n 默认安全,但自定义组件插值需注意

4. 复数形式遗漏
   ❌ 只写了一种复数形式
   ✅ 检查目标语言的复数规则(阿拉伯语有 6 种)

5. RTL 遗漏
   ❌ 只测试 LTR 语言
   ✅ 使用 Cypress / Playwright 添加 RTL 视觉回归测试

6. 语言包加载时序
   ❌ 语言未加载完成就渲染
   ✅ 使用 Suspense(React)或 v-if="i18n.global.ready"(Vue)

7. URL 参数中拼接多语言字段
   ❌ /product/123?lang=zh-CN  // 不利于 SEO
   ✅ /zh-CN/product/123 或 /product/123  (基于 cookie/header)

8. 数字/货币格式假设
   ❌ 硬编码 ¥1000.00
   ✅ 使用 $n() / Intl.NumberFormat

7.5 检查清单

项目国际化准备清单:
□ 所有用户可见文本已提取为 i18n key
□ 日期时间使用 Intl.DateTimeFormat 或 dayjs locale
□ 数字/货币使用 Intl.NumberFormat
□ CSS 使用逻辑属性(inline-start/end 而非 left/right)
□ RTL 布局已验证
□ SEO 标签支持多语言(<link rel="alternate" hreflang>)
□ 语言包按需加载配置完成
□ CI 中包含翻译完整性检查
□ UI 组件库已配置对应语言包
□ 表单校验错误信息已国际化
□ 后端返回的动态内容已适配多语言字段
□ E2E 测试覆盖了至少 2 种语言的页面渲染

总结

前端国际化不是简单的"把中文替换成 key",而是一整套从架构到工程化的系统工程:

  • 基础层:理解 i18n vs l10n,掌握 Intl 原生 API
  • 方案选型:Vue 项目用 vue-i18n,React 项目用 react-i18next
  • 工程化:自动化文案提取 + 翻译平台集成 + CI 翻译检查
  • 特殊场景:RTL 布局、日期数字本地化、动态多语言字段
  • 性能:按需加载、语言包拆分、缓存翻译结果

核心原则:永远不要信任后端返回的文案格式,在前端做最后一层兜底和格式化;永远不要在代码中硬编码面向用户的文本,所有文案走 i18n key。