前端国际化实践(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"
防御式提醒:
IntlAPI 在旧浏览器(如 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-intl 或 next-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。