国际化(i18n)多语言支持

国际化不只是"把文字翻译成另一种语言"。它涉及路由策略、SEO 优化、语言包加载性能、日期/货币/数字的本地化格式、RTL 布局适配等一系列工程决策。@nuxtjs/i18n 是 Nuxt 生态中最成熟的 i18n 方案,本章从路由策略的底层取舍讲起,覆盖 SEO、性能优化到中英双语的完整落地。

1. 路由策略:i18n 的第一个关键决策

1.1 四种路由策略

@nuxtjs/i18n 提供四种路由策略,每种都有不同的 URL 表现:

策略默认语言 URL其他语言 URL适用场景
prefix_except_default/about/en/about最常见,默认语言无前缀
prefix/zh/about/en/about所有语言等价对待
prefix_and_default/zh/about/about/en/about兼容旧 URL
no_prefix/about/about单 URL,Cookie/Header 切换

1.2 如何选择

prefix_except_default(推荐):最符合用户习惯。中文用户访问 /about 看到中文,英文用户访问 /en/about 看到英文。默认语言没有前缀,URL 更简洁。

prefix:所有语言都有前缀。优势是 URL 结构完全一致,劣势是默认语言也需要前缀(/zh/about),已有的不带前缀的链接会 404。

no_prefix:URL 不变,通过 Cookie 或 Accept-Language Header 判断语言。优势是 URL 简洁,劣势是 SEO 极差——搜索引擎无法区分同一 URL 的不同语言版本。除非你的应用不需要 SEO(如后台管理系统),否则不要用。

1.3 配置

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'zh', language: 'zh-CN', name: '中文', file: 'zh.json' },
      { code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
    ],
    defaultLocale: 'zh',
    strategy: 'prefix_except_default',
    lazy: true,
    langDir: 'locales/',
  },
})

2. 翻译管理

2.1 语言文件组织

小项目可以用单个 JSON 文件:

locales/
├── zh.json
└── en.json

中大型项目按模块拆分:

locales/
├── zh/
│   ├── common.json      ← 全局通用(导航、按钮、错误消息)
│   ├── video.json       ← 视频模块
│   ├── auth.json        ← 认证模块
│   └── admin.json       ← 后台模块
├── en/
│   ├── common.json
│   ├── video.json
│   ├── auth.json
│   └── admin.json

2.2 翻译键的命名规范

好的键命名让翻译文件可维护:

{
  "video": {
    "title": "视频标题",
    "upload": {
      "button": "上传视频",
      "progress": "上传中... {percent}%",
      "success": "上传成功",
      "error": "上传失败,请重试"
    },
    "player": {
      "play": "播放",
      "pause": "暂停",
      "quality": {
        "auto": "自动",
        "1080p": "1080p 高清",
        "720p": "720p 标清"
      }
    }
  }
}

命名原则:

  • 模块.功能.具体项:层级不超过 3-4 级
  • 名词优先video.upload.button 而非 video.clickToUpload
  • 带参数的翻译:用 {param} 占位符,如 "共 {count} 个视频"

2.3 在组件中使用

<template>
  <div>
    <h1>{{ $t('video.title') }}</h1>
    <p>{{ $t('video.upload.progress', { percent: 75 }) }}</p>
    <button>{{ $t('video.upload.button') }}</button>
  </div>
</template>
 
<script setup>
const { t, locale, setLocale } = useI18n()
 
// 编程式访问
console.log(t('video.title'))
 
// 切换语言
await setLocale('en')
</script>

$t() 是模板中的翻译函数,useI18n()t() 是 Composition API 版本。

3. SEO 优化

3.1 hreflang 标签

hreflang 告诉搜索引擎"这个页面还有其他语言版本",是多语言 SEO 的核心:

<!-- /about 页面的 <head> -->
<link rel="alternate" hreflang="zh-CN" href="https://example.com/about" />
<link rel="alternate" hreflang="en-US" href="https://example.com/en/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />

@nuxtjs/i18n 自动生成 hreflang 标签,无需手动配置。x-default 指向默认语言版本,供搜索引擎在没有匹配语言时使用。

3.2 Sitemap 多语言

配合 @nuxtjs/sitemap,可以为每个页面生成多语言 sitemap:

<url>
  <loc>https://example.com/about</loc>
  <xhtml:link rel="alternate" hreflang="zh-CN" href="https://example.com/about"/>
  <xhtml:link rel="alternate" hreflang="en-US" href="https://example.com/en/about"/>
</url>

3.3 SEO 元数据的翻译

页面的 titledescription 也需要翻译:

<script setup>
const { t } = useI18n()
 
useHead({
  title: t('video.seo.title'),
  meta: [
    { name: 'description', content: t('video.seo.description') },
  ],
})
</script>

3.4 canonical URL

多语言页面的 canonical URL 应该指向当前语言版本的 URL,而非默认语言。@nuxtjs/i18n 正确处理了这一点——/en/about 的 canonical 是 https://example.com/en/about,而非 https://example.com/about

4. 性能优化:懒加载语言包

4.1 问题

如果所有语言的翻译文件都打包在初始 bundle 中,每增加一种语言,bundle 就增大一份。10 种语言 × 每种 50KB = 500KB 的翻译文件——用户只看一种语言,却下载了所有语言。

4.2 懒加载方案

lazy: true 开启后,只有当前语言的翻译文件会被加载。切换语言时动态加载目标语言的文件:

i18n: {
  lazy: true,            // 开启懒加载
  langDir: 'locales/',   // 语言文件目录
  locales: [
    { code: 'zh', file: 'zh.json' },
    { code: 'en', file: 'en.json' },
  ],
}

加载时序:

  1. 用户访问 /about(默认中文)→ 只加载 zh.json
  2. 用户点击"English" → 动态 import('locales/en.json') → 切换完成

4.3 模块化语言文件的懒加载

如果语言文件按模块拆分,可以用目录格式:

locales: [
  {
    code: 'zh',
    files: ['zh/common.json', 'zh/video.json', 'zh/auth.json'],
  },
],

或者进一步优化——只加载当前页面需要的语言包。这需要在页面组件中按需加载:

<script setup>
const { mergeLocaleMessage } = useI18n()
 
// 只在视频页面加载视频相关翻译
const videoMessages = await import(`~/locales/${locale.value}/video.json`)
mergeLocaleMessage(locale.value, { video: videoMessages.default })
</script>

4.4 SSR 与语言包预加载

SSR 阶段,服务端需要正确的语言包来渲染页面。@nuxtjs/i18n 会在 SSR 时同步加载当前语言的翻译文件,确保首屏 HTML 包含正确的翻译内容。客户端 Hydration 时复用服务端的翻译数据,不会重复请求。

5. 本地化格式

5.1 日期和时间

不同地区的日期格式差异巨大:

地区格式示例
中国YYYY年MM月DD日2025年1月15日
美国MM/DD/YYYY01/15/2025
英国DD/MM/YYYY15/01/2025
日本YYYY/MM/DD2025/01/15

使用 Intl.DateTimeFormat(浏览器原生 API)或 Vue I18n 的日期格式化:

// i18n 配置中定义日期格式
datetimeFormats: {
  zh: {
    short: { year: 'numeric', month: 'short', day: 'numeric' },
    long: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' },
  },
  en: {
    short: { year: 'numeric', month: 'short', day: 'numeric' },
    long: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' },
  },
}
<template>
  <time>{{ $d(new Date(), 'long') }}</time>
  <!-- 中文: 2025年1月15日 14:30 -->
  <!-- 英文: January 15, 2025, 2:30 PM -->
</template>

5.2 数字和货币

numberFormats: {
  zh: {
    currency: { style: 'currency', currency: 'CNY' },
    decimal: { style: 'decimal', minimumFractionDigits: 2 },
  },
  en: {
    currency: { style: 'currency', currency: 'USD' },
    decimal: { style: 'decimal', minimumFractionDigits: 2 },
  },
}
<template>
  <span>{{ $n(9999.5, 'currency') }}</span>
  <!-- 中文: ¥9,999.50 -->
  <!-- 英文: $9,999.50 -->
</template>

5.3 复数形式

不同语言的复数规则差异很大。英文有单复数(1 item / 2 items),中文没有复数,阿拉伯语有 6 种复数形式。Vue I18n 用管道符 | 处理:

{
  "video": {
    "count": "没有视频 | {count} 个视频 | {count} 个视频"
  }
}
{
  "video": {
    "count": "no videos | {count} video | {count} videos"
  }
}
<template>
  {{ $t('video.count', { count: videoCount }, videoCount) }}
</template>

6. 语言切换

6.1 语言切换组件

<template>
  <select :value="locale" @change="setLocale($event.target.value)">
    <option v-for="loc in locales" :key="loc.code" :value="loc.code">
      {{ loc.name }}
    </option>
  </select>
</template>
 
<script setup>
const { locale, locales, setLocale } = useI18n()
</script>

6.2 语言检测

@nuxtjs/i18n 支持自动检测用户语言:

i18n: {
  detectBrowserLanguage: {
    useCookie: true,           // 用 Cookie 记住选择
    cookieKey: 'i18n_lang',
    fallbackLocale: 'zh',
    redirectOn: 'root',        // 只在首页重定向
  },
}

检测优先级:Cookie(用户选择)→ Navigator Language(浏览器语言)→ defaultLocale(兜底)。

redirectOn: 'root' 很重要:只在访问根路径 / 时自动重定向到检测到的语言。如果设为 'all',每个页面都会检测并重定向,这会破坏分享链接——你分享 /en/about 给中文用户,他会被重定向到 /about

6.3 客户端持久化

用户手动切换语言后,通过 Cookie 持久化选择。下次访问时优先使用 Cookie 中的语言,而非浏览器语言检测。

7. 翻译工作流

7.1 开发者 vs 翻译者

最佳实践是开发者写默认语言(中文),翻译者处理其他语言

  1. 开发者在代码中使用 $t('video.upload.button')
  2. 开发者在 zh.json 中添加中文翻译
  3. 翻译者在 en.json 中添加英文翻译
  4. CI 检查翻译完整性(所有键都有对应翻译)

7.2 翻译完整性检查

可以在 CI 中检查所有语言文件的键是否一致:

// scripts/check-i18n.ts
import zh from '../locales/zh.json'
import en from '../locales/en.json'
 
function getKeys(obj: object, prefix = ''): string[] {
  return Object.entries(obj).flatMap(([key, value]) =>
    typeof value === 'object'
      ? getKeys(value, `${prefix}${key}.`)
      : [`${prefix}${key}`]
  )
}
 
const zhKeys = new Set(getKeys(zh))
const enKeys = new Set(getKeys(en))
 
const missing = [...zhKeys].filter(k => !enKeys.has(k))
if (missing.length > 0) {
  console.error('Missing English translations:', missing)
  process.exit(1)
}

7.3 翻译平台集成

大型项目可以用专业翻译平台管理翻译:

平台特点适用场景
Crowdin开源项目免费,社区翻译开源项目
Lokalise开发者友好,CLI 同步商业项目
Phrase企业级,工作流审批大型团队
Tolgee开源自托管,页内翻译编辑注重隐私

这些平台可以监听 Git 仓库中 locales/ 目录的变化,自动同步翻译键到平台,翻译完成后自动提交 PR。

本章小结

  • 路由策略prefix_except_default 最常见,no_prefix SEO 极差只适合后台系统
  • 翻译管理:按模块拆分语言文件,键名遵循 模块.功能.具体项 三级结构
  • SEO:hreflang 自动生成,Sitemap 多语言支持,页面元数据也要翻译
  • 懒加载lazy: true 只加载当前语言,切换时动态加载,SSR 阶段同步加载保证首屏
  • 本地化格式:日期 $d()、数字 $n()、复数用管道符,依赖 Intl API
  • 语言检测:Cookie 优先 → 浏览器语言 → 默认值,redirectOn: 'root' 避免破坏分享链接
  • 翻译工作流:开发者写默认语言,翻译者处理其他语言,CI 检查翻译完整性