Vue 基础体系 · 第 21/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 国际化工程:消息目录、Locale、日期数字、路由和回退

国际化(internationalization,通常写作 i18n)不是把中文字符串替换成英文字符串,而是让同一套应用根据用户的语言、地区、书写方向和格式约定,生成不同的界面文本与数据表示。

一个完整的 Vue 国际化系统至少包含以下对象:

  • 消息目录(message catalog):界面文案、错误消息、按钮文本等按键组织的数据。
  • Locale:描述语言、地区、脚本和相关格式规则的标识,例如 zh-CNen-USar-EG
  • 格式化器:日期、时间、数字、货币、百分比等按照 Locale 生成字符串。
  • 路由集成:决定 URL 是否包含 Locale,以及切换语言后如何保持当前页面。
  • 回退(fallback):当前 Locale 缺少消息、格式配置或路由时,如何选择备用值。
  • 加载与状态管理:语言包何时下载、如何避免重复请求、如何处理并发切换和加载失败。

Vue 本身提供响应式系统、组件生命周期和渲染能力,但不规定消息目录格式,也不内置完整的 i18n 运行时。Vue 3 项目通常使用 vue-i18n 这类兼容 Vue 3 的库;本文示例基于 vue-i18n v9+ 的 Composition API 模式、Vue Router 4、TypeScript 和 Vite。

1. 先区分语言、Locale 和消息目录

1.1 语言不等于 Locale

“中文”和“英语”只表达语言类别,而 Locale 还可以包含地区与脚本:

Locale 含义示例
zh-CN 中国大陆常用的简体中文
zh-TW 台湾地区常用的繁体中文
en-US 美国英语
en-GB 英国英语
ar-EG 埃及阿拉伯语
sr-Cyrl 使用西里尔字母的塞尔维亚语

地区会影响日期、数字和货币格式。例如同一个数值:

en-US: 1,234.56
de-DE: 1.234,56

因此,Locale 不能简单当作“当前语言名称”。它同时参与:

  1. 消息目录选择;
  2. Intl.DateTimeFormat 的格式选择;
  3. Intl.NumberFormat 的格式选择;
  4. 小数点和分组分隔符选择;
  5. 货币符号与位置选择;
  6. 某些语言的复数规则;
  7. 文本方向判断。

Locale 标识通常遵循 BCP 47 形式,但实际项目中不应只靠字符串拆分来实现全部规则。应用可以维护自己的受支持集合:

export const supportedLocales = ['zh-CN', 'en-US'] as const

export type AppLocale = (typeof supportedLocales)[number]

export function isSupportedLocale(value: unknown): value is AppLocale {
  return typeof value === 'string' &&
    (supportedLocales as readonly string[]).includes(value)
}

这里的 isSupportedLocale 是应用层的白名单判断,不是完整的 Locale 解析器。它的价值在于防止 URL、LocalStorage 或浏览器偏好直接注入一个应用没有准备语言包的值。

1.2 消息目录是稳定的键空间

消息目录不应只是一组散落的字符串,而应构成稳定的键空间:

// src/locales/zh-CN.ts
export default {
  app: {
    name: '订单中心',
    loading: '加载中……'
  },
  nav: {
    home: '首页',
    orders: '订单'
  },
  order: {
    summary: '订单摘要',
    itemCount: '没有商品 | {count} 件商品'
  },
  action: {
    save: '保存',
    cancel: '取消'
  },
  errors: {
    network: '网络请求失败,请稍后重试'
  }
}
// src/locales/en-US.ts
export default {
  app: {
    name: 'Order Center',
    loading: 'Loading…'
  },
  nav: {
    home: 'Home',
    orders: 'Orders'
  },
  order: {
    summary: 'Order summary',
    itemCount: 'No items | {count} items'
  },
  action: {
    save: 'Save',
    cancel: 'Cancel'
  },
  errors: {
    network: 'The network request failed. Please try again later.'
  }
}

组件依赖的是 nav.orderserrors.network 这样的键,而不是依赖某个语言的具体文本:

<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
</script>

<template>
  <nav aria-label="主导航">
    <a href="/orders">{{ t('nav.orders') }}</a>
  </nav>
</template>

这种设计有两个因果结果:

  • 文案修改不会要求修改组件逻辑;
  • 语言包可以由翻译人员、脚本或独立流程维护。

反例是把用户可见文本直接作为键:

t('订单')

它看似省事,但中文文案一旦修改,所有调用点都变成隐含的业务耦合;同时无法可靠判断某个 Locale 是否缺少同一条消息。

1.3 键名的稳定性比目录层级更重要

嵌套目录主要用于组织和阅读,真正的契约是键名。推荐按领域或界面职责划分:

app.*
nav.*
auth.*
order.*
validation.*
errors.*

不要让目录结构反映组件文件路径,例如:

components.OrderList.header.title

因为组件重构不应该导致翻译键全部变化。消息键应该表达用户概念或业务概念,而不是当前的组件树。

对于有变量的文本,应使用参数插值:

t('welcome', { name: 'Ada' })
welcome: '欢迎,{name}'

不要先拼接文本:

`${t('welcomePrefix')} ${name}`

不同语言的词序可能不同,拼接会把源语言的语法假设泄漏到其他语言。

2. 创建 Vue I18n 实例

安装依赖:

npm install vue-i18n vue-router

目录可以组织为:

src/
├─ i18n/
│  ├─ index.ts
│  └─ loaders.ts
├─ locales/
│  ├─ en-US.ts
│  └─ zh-CN.ts
├─ router/
│  └─ index.ts
├─ App.vue
└─ main.ts

2.1 最小可运行配置

// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import enUS from '../locales/en-US'

export const supportedLocales = ['zh-CN', 'en-US'] as const
export type AppLocale = (typeof supportedLocales)[number]

export const defaultLocale: AppLocale = 'zh-CN'

export function isSupportedLocale(value: unknown): value is AppLocale {
  return typeof value === 'string' &&
    (supportedLocales as readonly string[]).includes(value)
}

export const i18n = createI18n({
  legacy: false,
  locale: defaultLocale,
  fallbackLocale: {
    default: 'en-US',
    'zh-CN': ['en-US']
  },
  messages: {
    'en-US': enUS
  }
})

几个配置的含义必须分开:

  • legacy: false:使用 Composition API 风格。组件中可以通过 useI18n() 获得 tdn 等函数。
  • locale:当前 Locale。
  • messages:已经加载到内存中的语言包。
  • fallbackLocale:当前消息找不到时使用的备用 Locale。
  • 'zh-CN': ['en-US']:当当前 Locale 是 zh-CN 时,优先回退到 en-US
  • default: 'en-US':没有专门规则的 Locale 使用 en-US

main.ts 中安装:

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

createApp(App)
  .use(i18n)
  .use(router)
  .mount('#app')

use(i18n) 必须发生在应用挂载之前。否则组件在初始化时无法获得由插件提供的国际化上下文。

2.2 组件中的 Composition API

<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
</script>

<template>
  <section>
    <h1>{{ t('app.name') }}</h1>
    <button type="button">
      {{ t('action.save') }}
    </button>
  </section>
</template>

useI18n() 必须在 setup() 执行期间调用。<script setup> 本身就是 setup() 的编译形式,因此可以直接使用。

如果组件使用全局语言包,默认可以写:

const { t, d, n, locale } = useI18n({ useScope: 'global' })

如果组件有局部消息:

<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { t } = useI18n({
  messages: {
    'zh-CN': {
      title: '局部标题'
    },
    'en-US': {
      title: 'Local title'
    }
  }
})
</script>

<template>
  <h2>{{ t('title') }}</h2>
</template>

局部消息适合组件私有文案,但同一个键在全局和局部同时存在时,查找范围会影响结果。工程中应明确哪些键属于组件私有,避免因为作用域不清而出现“改了全局语言包但界面没变化”的问题。

3. Locale 状态、切换和响应式更新

当前 Locale 是应用状态。切换过程至少包括:

  1. 判断目标 Locale 是否受支持;
  2. 加载目标语言包;
  3. 将语言包注册到 i18n 实例;
  4. 更新当前 Locale;
  5. 更新文档语言和书写方向;
  6. 持久化选择;
  7. 让组件重新渲染。

可以封装加载器,避免组件直接操作底层实例。

// src/i18n/loaders.ts
import type { AppLocale } from './index'

type LocaleMessages = Record<string, unknown>

const loaders: Record<AppLocale, () => Promise<{ default: LocaleMessages }>> = {
  'zh-CN': () => import('../locales/zh-CN'),
  'en-US': () => import('../locales/en-US')
}

const loaded = new Set<AppLocale>(['en-US'])
const loading = new Map<AppLocale, Promise<void>>()

export async function loadLocaleMessages(
  locale: AppLocale,
  setMessages: (locale: AppLocale, messages: LocaleMessages) => void
): Promise<void> {
  if (loaded.has(locale)) {
    return
  }

  const existing = loading.get(locale)
  if (existing) {
    return existing
  }

  const promise = loaders[locale]()
    .then(module => {
      setMessages(locale, module.default)
      loaded.add(locale)
    })
    .finally(() => {
      loading.delete(locale)
    })

  loading.set(locale, promise)
  return promise
}

这里有两层缓存:

  • loaded 防止已经完成的语言包再次导入;
  • loading 防止两个组件或两个导航同时请求同一个语言包。

如果没有 loading,用户快速点击语言切换按钮时可能同时产生多次相同请求。Vite 对动态 import() 的具体分包结果属于构建工具行为,但显式列出 loader 通常比任意拼接文件路径更容易让打包器分析,也更容易限制可加载文件范围。

切换函数:

// src/i18n/index.ts
import { nextTick } from 'vue'
import { loadLocaleMessages } from './loaders'

// 在同一文件中追加
export async function setLocale(locale: AppLocale): Promise<void> {
  await loadLocaleMessages(locale, (targetLocale, messages) => {
    i18n.global.setLocaleMessage(targetLocale, messages)
  })

  i18n.global.locale.value = locale

  document.documentElement.lang = locale
  document.documentElement.dir = locale.startsWith('ar') ? 'rtl' : 'ltr'

  localStorage.setItem('locale', locale)

  // 让依赖当前 Locale 的 DOM 更新完成后再返回
  await nextTick()
}

i18n.global.locale.valuelegacy: false 时的响应式 Locale。不能写成:

i18n.global.locale = locale

这会把 Ref 当成普通属性处理,无法得到预期结果。

语言选择组件:

<script setup lang="ts">
import { ref } from 'vue'
import { setLocale, type AppLocale } from '../i18n'

const changing = ref(false)
const locale = ref<AppLocale>('zh-CN')

async function changeLocale(event: Event) {
  const value = (event.target as HTMLSelectElement).value as AppLocale

  if (value === locale.value) {
    return
  }

  changing.value = true

  try {
    await setLocale(value)
    locale.value = value
  } finally {
    changing.value = false
  }
}
</script>

<template>
  <label>
    <span>Language</span>
    <select
      :value="locale"
      :disabled="changing"
      @change="changeLocale"
    >
      <option value="zh-CN">简体中文</option>
      <option value="en-US">English</option>
    </select>
  </label>
</template>

生产代码应避免在异步加载完成后无条件写入全局 Locale。存在以下并发场景:

用户点击 en-US
  └─ 请求 en-US,尚未完成

用户马上点击 zh-CN
  └─ 请求 zh-CN,先完成并设置 zh-CN

en-US 请求随后完成
  └─ 如果无保护,可能把界面改回 en-US

一个简单的切换序号可以丢弃过期结果:

let localeChangeId = 0

export async function setLocaleSafely(locale: AppLocale): Promise<void> {
  const currentId = ++localeChangeId

  await loadLocaleMessages(locale, (targetLocale, messages) => {
    i18n.global.setLocaleMessage(targetLocale, messages)
  })

  if (currentId !== localeChangeId) {
    return
  }

  i18n.global.locale.value = locale
  document.documentElement.lang = locale
  localStorage.setItem('locale', locale)
}

这里的语言包注册仍然允许发生,因为它是可复用缓存;但只有最后一次请求可以改变当前 Locale。这是“缓存状态”和“当前选择状态”必须分别处理的原因。

4. 消息插值、复数和富文本

4.1 参数插值

t('order.itemCount', { count: 3 })

语言包:

{
  order: {
    itemCount: '没有商品 | {count} 件商品'
  }
}

模板:

<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
const count = computed(() => 3)
</script>

<template>
  <p>{{ t('order.itemCount', count) }}</p>
</template>

vue-i18n 会根据数值选择复数分支,但语言的复数规则不都等同于“0、1、其他”三种简单情况。应让 i18n 库依据 Locale 处理规则,不要在业务代码中手写:

count === 1 ? t('oneItem') : t('manyItems')

手写分支会把英语的复数假设硬编码到应用中。

对于更复杂的语言,应使用库提供的复数参数和规则,并通过目标语言测试实际输出。不要仅凭中文或英文测试推断所有语言都遵循同样的分支数量。

4.2 富文本消息

如果一段文案中包含链接、粗体或其他组件,不要默认使用 v-html

<!-- 有 XSS 风险,不应把翻译文本直接作为 HTML 注入 -->
<p v-html="t('terms')"></p>

原因是语言包可能来自远程后台、翻译平台或构建产物之外的来源;一旦消息包含未清理的 HTML,翻译文本就变成了注入入口。

组件化文本更安全:

<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
</script>

<template>
  <i18n-t keypath="terms" tag="p">
    <template #terms>
      <a href="/terms">{{ t('termsLink') }}</a>
    </template>
  </i18n-t>
</template>

语言包:

{
  terms: '请阅读我们的 {terms}。',
  termsLink: '服务条款'
}

i18n-t 让翻译消息决定词序,让 Vue 负责插入实际组件。链接地址、事件处理器和权限逻辑仍应由组件控制,而不是放进翻译字符串。

5. 日期和数字:Locale 不是字符串替换

5.1 为什么不能手写日期格式

以下代码把日期格式错误地固定为某一地区习惯:

`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`

它还存在月份从 0 开始、时区和补零等问题。更重要的是,它没有表达用户所处 Locale 的格式约定。

国际化格式依赖 ECMAScript Internationalization API(Intl)。vue-i18nd()n() 对它进行了组件化封装。

// src/i18n/index.ts
export const i18n = createI18n({
  legacy: false,
  locale: defaultLocale,
  fallbackLocale: {
    default: 'en-US',
    'zh-CN': ['en-US']
  },
  messages: {
    'en-US': enUS
  },
  datetimeFormats: {
    'zh-CN': {
      short: {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit'
      },
      long: {
        dateStyle: 'long'
      }
    },
    'en-US': {
      short: {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit'
      },
      long: {
        dateStyle: 'long'
      }
    }
  },
  numberFormats: {
    'zh-CN': {
      decimal: {
        style: 'decimal',
        maximumFractionDigits: 2
      },
      currency: {
        style: 'currency',
        currency: 'CNY'
      },
      percent: {
        style: 'percent',
        maximumFractionDigits: 1
      }
    },
    'en-US': {
      decimal: {
        style: 'decimal',
        maximumFractionDigits: 2
      },
      currency: {
        style: 'currency',
        currency: 'USD'
      },
      percent: {
        style: 'percent',
        maximumFractionDigits: 1
      }
    }
  }
})

组件中使用:

<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { d, n } = useI18n({ useScope: 'global' })

const createdAt = new Date('2025-03-08T12:30:00Z')
const amount = 1234567.89
const ratio = 0.875
</script>

<template>
  <dl>
    <dt>创建日期</dt>
    <dd>{{ d(createdAt, 'long') }}</dd>

    <dt>金额</dt>
    <dd>{{ n(amount, 'currency') }}</dd>

    <dt>完成率</dt>
    <dd>{{ n(ratio, 'percent') }}</dd>
  </dl>
</template>

Locale 改变后,依赖 dn 的模板会重新计算。输出的货币、分隔符和日期顺序由 Locale 与格式配置共同决定。

5.2 时间戳、时区和日期语义

new Date('2025-03-08T12:30:00Z') 表示一个带 UTC 语义的时间点。显示时,如果不明确时区,浏览器通常会使用运行环境的本地时区。这会导致:

  • 服务端渲染使用 UTC;
  • 浏览器使用用户本地时区;
  • 首次渲染文本不一致,产生 hydration mismatch;
  • 同一个订单在不同用户设备上显示不同日期。

如果业务含义是“某个瞬间”,后端应传输明确时区的 ISO 8601 字符串或 Unix 时间戳,并明确显示时区规则:

const dateOptions = {
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
  timeZone: 'Asia/Shanghai'
} as const

如果业务含义是“当地营业日”而不是瞬间,则不能随意把它转换成 Date。例如 2025-03-08 可能只是一个日期,不应因为客户端时区转换而变成前一天。

5.3 数字精度与格式化边界

Intl.NumberFormat 只负责显示格式,不负责修复浮点数精度:

0.1 + 0.2 // 不是严格等于 0.3

金额计算应在服务端或专门的金额模型中使用最小货币单位整数或十进制定点方案;前端 n() 只负责把最终数值展示为本地格式。不能把格式化后的字符串重新解析为金额:

const display = n(amount, 'currency')
// display 可能是 "$1,234.56" 或 "1.234,56 €"
// 不能再用 parseFloat(display) 参与计算

6. 回退机制:消息回退、格式回退和路由回退不是一回事

“回退”经常被混为一个概念,实际上至少有三条独立链路。

6.1 消息回退

当当前语言包中找不到 order.summary 时,消息解析大致经历:

当前 Locale 的精确键
  ↓ 找不到
当前 Locale 的隐式父 Locale(若适用)
  ↓ 仍找不到
fallbackLocale 配置
  ↓ 仍找不到
返回键名或触发 missing 处理

例如当前 Locale 为 zh-CN

fallbackLocale: {
  'zh-CN': ['en-US'],
  default: ['en-US']
}

如果 zh-CN 缺少 order.summaryen-US 有该键,则显示英文。这样可以允许中文语言包增量翻译,而不必在发布前保证所有消息都已翻译。

但回退不是“把另一种语言整包合并到当前语言”。它通常按键查找。假设:

zh-CN = {
  nav: {
    home: '首页'
  }
}

en-US = {
  nav: {
    home: 'Home',
    orders: 'Orders'
  }
}

调用:

t('nav.orders')

当前目录没有 nav.orders,才会从回退目录查找 Orders;不会因为 nav 对象存在就停止整个查找。

6.2 默认文案参数不等于 Locale 回退

可以给 t 传默认消息:

t('temporary.message', '暂时无法加载')

它解决的是某一处调用的默认显示,不等同于配置 fallbackLocale。前者是调用点级别的备用内容,后者是全局消息解析策略。

6.3 路由回退是 URL 策略

如果用户访问:

/xx/orders

xx 不是受支持的 Locale,这不是消息缺失,而是路由参数无效。应用可以:

  • 重定向到默认 Locale,例如 /zh-CN/orders
  • 返回 404;
  • 根据浏览器偏好选择一个 Locale;
  • 显示“语言不受支持”的错误页。

这必须单独设计。不能因为消息有 en-US 回退,就认为 /xx/orders 自动成为 /en-US/orders

6.4 缺少翻译时要可观测

开发环境应保留缺失警告:

export const i18n = createI18n({
  legacy: false,
  locale: defaultLocale,
  fallbackLocale: 'en-US',
  missingWarn: import.meta.env.DEV,
  fallbackWarn: import.meta.env.DEV,
  messages: {
    'en-US': enUS
  }
})

生产环境可以降低日志噪声,但不应静默吞掉关键缺失。可以通过 missing 回调上报:

export const i18n = createI18n({
  legacy: false,
  locale: defaultLocale,
  fallbackLocale: 'en-US',
  messages: {
    'en-US': enUS
  },
  missing(locale, key) {
    if (import.meta.env.PROD) {
      console.warn(`[i18n] missing key: ${locale}.${key}`)
    }
  }
})

上报时应注意不要记录用户输入、完整请求参数或敏感数据。缺失键本身通常足够定位问题。

7. 路由中的 Locale 设计

7.1 两种常见 URL 方案

Locale 可以放在:

/orders

并由 Cookie、LocalStorage 或浏览器偏好决定;也可以放在 URL 中:

/zh-CN/orders
/en-US/orders

URL 包含 Locale 的好处是:

  • 页面可直接分享;
  • 同一 URL 在不同设备上语义一致;
  • 搜索引擎和缓存可以区分语言版本;
  • 前进、后退能够保留语言状态。

代价是每条页面 URL 都需要处理 Locale 参数,且切换语言时要改变 URL。

对于需要公开访问、分享或 SEO 的站点,Locale 前缀通常更明确;对于内部管理后台,使用用户偏好也可能更简单。两者都不是 Vue Router 的强制要求。

7.2 使用父级 Locale 参数组织路由

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import AppLayout from '../layouts/AppLayout.vue'
import HomeView from '../views/HomeView.vue'
import { i18n, isSupportedLocale, setLocale, type AppLocale } from '../i18n'

let navigationId = 0

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      redirect: '/zh-CN'
    },
    {
      path: '/:locale',
      component: AppLayout,
      children: [
        {
          path: '',
          name: 'home',
          component: HomeView
        },
        {
          path: 'orders',
          name: 'orders',
          component: () => import('../views/OrdersView.vue')
        }
      ]
    },
    {
      path: '/:pathMatch(.*)*',
      name: 'not-found',
      component: () => import('../views/NotFoundView.vue')
    }
  ]
})

router.beforeEach(async to => {
  const id = ++navigationId
  const rawLocale = to.params.locale
  const locale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale

  if (!locale) {
    return '/zh-CN'
  }

  if (!isSupportedLocale(locale)) {
    return { name: 'not-found' }
  }

  await setLocale(locale as AppLocale)

  // 如果等待语言包期间已经发起了更新的导航,
  // 当前导航不应再修改最终状态。
  if (id !== navigationId) {
    return false
  }

  document.title = String(to.meta.title ?? i18n.global.t('app.name'))
  return true
})

export default router

这个守卫的执行顺序是:

sequenceDiagram
    participant U as 用户
    participant R as Vue Router
    participant G as beforeEach
    participant L as 语言包加载器
    participant I as i18n
    participant V as 页面组件

    U->>R: 访问 /en-US/orders
    R->>G: 解析 to.params.locale
    G->>G: 检查 en-US 是否受支持
    G->>L: 加载 en-US 语言包
    L-->>G: 返回消息目录
    G->>I: setLocaleMessage(en-US)
    G->>I: locale.value = en-US
    G-->>R: 放行导航
    R->>V: 创建/复用 OrdersView
    V->>I: t、d、n
    I-->>V: 返回英文消息和格式

语言包必须在导航放行前加载,原因是页面首次渲染时才能读取目标语言。若先进入页面、再异步切换语言,用户会先看到默认语言,随后整个页面跳变,标题、按钮和无障碍名称还可能在不同时间更新。

7.3 路由守卫中的错误路径

动态导入可能失败:

  • 网络断开;
  • CDN 返回 404;
  • 部署时 HTML 和 JS 版本不一致;
  • 语言包构建产物缺失。

守卫中可以捕获并转到错误页:

router.beforeEach(async to => {
  const rawLocale = to.params.locale
  const locale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale

  if (!isSupportedLocale(locale)) {
    return { name: 'not-found' }
  }

  try {
    await setLocale(locale)
  } catch (error) {
    console.error('Failed to load locale messages', error)
    return {
      name: 'locale-error',
      query: { locale }
    }
  }

  return true
})

错误页自身不能依赖尚未加载成功的目标语言包来显示唯一错误信息。它至少应有一个已预载语言包,或使用极少量的静态兜底文本。

7.4 切换语言时保持当前路由

如果只改变前缀,推荐使用当前路由的命名路由和参数:

import { useRoute, useRouter } from 'vue-router'
import type { AppLocale } from '../i18n'

const route = useRoute()
const router = useRouter()

async function switchLocale(locale: AppLocale) {
  await router.push({
    name: route.name!,
    params: {
      ...route.params,
      locale
    },
    query: route.query,
    hash: route.hash
  })
}

这里让路由守卫负责加载语言包和设置 Locale,语言选择组件只负责导航。这样可以避免出现:

组件直接设置 i18n.locale
路由参数仍是旧 Locale
刷新页面后语言又变回旧值

路由是 URL 状态,i18n 是运行时状态。两者应有单一同步入口,而不是在多个组件中各自修改。

7.5 翻译路由路径时使用稳定的路由名

如果页面路径本身也翻译:

/zh-CN/orders
/en-US/orders

或者:

/zh-CN/ding-dan
/en-US/orders

不要把语言切换实现成“替换字符串”。应维护 Locale 到路径的映射,并通过稳定的路由名导航:

const localizedPaths = {
  'zh-CN': {
    orders: 'orders'
  },
  'en-US': {
    orders: 'orders'
  }
} as const

如果不同语言的路径不同,路由表可以定义多个别名或多个记录,但页面逻辑使用同一个 name 或同一个页面组件。路由名是程序契约,路径是面向用户和搜索引擎的表现形式。

8. 页面标题、文档语言和书写方向

国际化不仅影响可见文本。浏览器和辅助技术还需要知道页面语言:

document.documentElement.lang = 'en-US'

对于从右向左书写的语言,应同步设置:

document.documentElement.dir = 'rtl'

不要只在按钮中显示语言名称,却保持:

<html lang="zh-CN" dir="ltr">

这会影响屏幕阅读器的发音、标点处理、文本选择和布局方向。

页面标题也应通过消息目录生成:

router.afterEach(to => {
  const key = typeof to.meta.titleKey === 'string'
    ? to.meta.titleKey
    : 'app.name'

  document.title = i18n.global.t(key)
})

对应的路由元信息:

{
  path: 'orders',
  name: 'orders',
  component: () => import('../views/OrdersView.vue'),
  meta: {
    titleKey: 'nav.orders'
  }
}

这里使用 titleKey 而不是直接把 title: '订单' 写入路由配置,因为路由配置本身也需要随 Locale 更新。

这也与可访问性相关:页面标题、表单标签、按钮名称、错误提示都属于用户界面文本,不应因为“视觉上只是图标”而绕过国际化。图标按钮需要可翻译的 aria-label

<button
  type="button"
  :aria-label="t('action.close')"
>
  ×
</button>

9. 语言包懒加载与首屏策略

语言包懒加载的目标是避免把所有语言的消息一次性发送到浏览器。基本策略通常是:

默认语言包:首屏预载
其他语言包:用户切换或路由进入时动态加载

但回退链会改变这个策略。假设当前是 zh-CN,回退是 en-US

zh-CN 缺少某键
  ↓
需要查找 en-US
  ↓
如果 en-US 尚未加载,回退只能得到缺失结果

因此至少要满足以下条件之一:

  1. 所有可能的回退语言包在首屏加载;
  2. 回退语言包也能被同步或提前异步加载;
  3. 发布流程保证当前语言包不缺少关键消息;
  4. 缺失时允许显示键名或静态错误文案。

如果目标是保证首屏可用,常见选择是把默认语言和主回退语言一起打入初始包;如果目标是尽可能减少首屏体积,则要接受回退语言包首次请求失败的错误路径,并为它提供可观测性。

动态语言包的发布还涉及缓存一致性。HTML 可能来自旧版本,而语言包 JS 来自新版本,或反过来。语言包文件应使用带内容哈希的构建文件名,并让 HTML 与静态资源遵循一致的发布策略。不能依赖“语言包文件名永远不变”来获得长期缓存,否则用户可能持续拿到旧翻译。

10. 浏览器偏好、持久化与 URL 优先级

应用通常同时面对三种 Locale 来源:

  1. URL,例如 /en-US/orders
  2. 用户显式选择并存入 LocalStorage 或 Cookie;
  3. 浏览器的 navigator.languages

必须定义优先级。一个可解释的规则是:

有效 URL Locale
  > 用户已保存的 Locale
  > 浏览器偏好
  > 应用默认 Locale

浏览器偏好可能是:

navigator.languages
// ['zh-CN', 'zh', 'en-US']

但不能直接取第一个值作为应用 Locale,因为 zh 可能不在受支持集合中。应逐个匹配:

import { isSupportedLocale, type AppLocale } from './i18n'

export function detectLocale(): AppLocale {
  const saved = localStorage.getItem('locale')

  if (isSupportedLocale(saved)) {
    return saved
  }

  for (const candidate of navigator.languages) {
    if (isSupportedLocale(candidate)) {
      return candidate
    }

    const languageOnly = candidate.split('-')[0]
    const matched = ['zh-CN', 'en-US'].find(locale =>
      locale.startsWith(`${languageOnly}-`)
    )

    if (matched && isSupportedLocale(matched)) {
      return matched
    }
  }

  return 'zh-CN'
}

localStorage 可能被禁用、清空或抛出异常;它只能作为偏好来源,不能作为唯一状态存储。对公开页面而言,URL 通常比 LocalStorage 更有决定性,因为 URL 能被服务器、搜索引擎和其他用户观察到。

11. SSR 和 hydration 的一致性边界

如果使用 SSR,服务端和客户端必须在首次渲染时得到相同的:

  • Locale;
  • 消息目录;
  • 日期时区;
  • 数字格式配置;
  • 路由。

否则服务端输出:

订单中心

而客户端首次计算得到:

Order Center

就可能出现 hydration mismatch,或者用户看到一闪而过的错误语言。

SSR 请求应根据请求 URL、Cookie 或其他确定性来源选择 Locale,并在服务端渲染前加载对应语言包。客户端接管时要复用同一 Locale 和消息数据,而不是重新根据浏览器偏好推断一次。

日期尤其容易产生差异。例如服务端运行在 UTC,客户端运行在 UTC+8;即便 Locale 相同,未经指定时区的日期也可能跨日。需要显示固定业务时区时,应在格式选项中显式指定 timeZone,或者在服务端和客户端统一运行环境。

12. 常见失败表现与诊断方法

12.1 页面显示键名

表现:

nav.orders

通常表示:

  1. 当前和回退语言包都没有这个键;
  2. 语言包尚未注册;
  3. useI18n 使用了错误的作用域;
  4. 动态导入失败;
  5. 键名拼写或大小写不一致。

诊断顺序应是:

console.log(i18n.global.availableLocales)
console.log(i18n.global.getLocaleMessage('zh-CN'))
console.log(i18n.global.locale.value)

再检查浏览器 Network 面板中的语言包请求是否返回 200,以及 setLocaleMessage 是否在设置 locale 前执行。

12.2 切换后部分组件没有变化

常见原因是:

  • 某组件缓存了 t('key') 的结果,而不是在模板或计算属性中调用;
  • 使用了非响应式的普通变量保存 Locale;
  • 组件使用了局部作用域,却以为它使用全局消息;
  • 页面中的文本来自后端,未经过格式化层;
  • 路由标题只在首次进入时设置,没有在 Locale 变化后更新。

错误示例:

const title = t('nav.orders')
// title 是当时计算出的字符串,不会自动随 Locale 更新

应使用计算属性:

import { computed } from 'vue'

const title = computed(() => t('nav.orders'))

或者直接在模板中调用 t()

12.3 日期在本地和线上不一致

先区分三个变量:

输入值代表什么?
格式 Locale 是什么?
显示时区是什么?

如果输入值没有时区,格式化器无法凭空推断业务语义。检查网络响应中是否使用了:

2025-03-08T12:30:00Z

而不是:

2025-03-08 12:30:00

后者缺少时区信息,在不同 JavaScript 运行环境中的解释可能不一致。

12.4 路由 Locale 与界面 Locale 不一致

表现:

URL: /en-US/orders
界面: 中文

或者刷新后语言改变。通常是因为某个组件直接设置了 i18n.global.locale.value,却没有通过路由更新 :locale 参数。

修复原则是让路由成为 URL Locale 的单一来源:语言切换调用 router.push(),路由守卫加载并设置 i18n Locale,组件只读取状态。

12.5 语言切换后返回旧页面

如果使用浏览器后退,用户可能回到带有旧 Locale 的 URL。这是 URL 状态的正常结果,不应在 afterEach 中强行把所有路由改成当前语言,否则会破坏浏览器历史记录。

正确做法是:

  • 进入哪个 Locale 的 URL,就使用哪个 Locale;
  • 只有用户主动切换语言时才创建新导航;
  • 如果产品要求切换语言不增加历史记录,可以使用 router.replace(),但这是明确的交互取舍。

13. 测试和交付检查

消息目录可以做结构检查,而不应只在浏览器中人工点击。至少检查:

  • 受支持 Locale 的集合是否与语言包 loader 一致;
  • 默认 Locale 是否有完整的首屏关键键;
  • 回退 Locale 是否在目标环境可加载;
  • 关键键在各语言包中的类型和结构是否一致;
  • 插值变量名称是否一致,例如 {count} 不能在另一语言变成 {number} 后仍沿用旧调用;
  • 日期和数字格式是否覆盖所有实际使用的格式名;
  • URL 中的 Locale 是否能刷新、分享和直接访问;
  • 无效 Locale 是否进入预期的重定向或 404;
  • 语言包加载失败时是否有可操作的错误界面。

一个最小的运行时验证可以这样写:

const requiredKeys = [
  'app.name',
  'nav.home',
  'nav.orders',
  'errors.network'
] as const

for (const locale of ['zh-CN', 'en-US'] as const) {
  for (const key of requiredKeys) {
    const value = i18n.global.te(key, locale)

    if (!value) {
      throw new Error(`Missing i18n key: ${locale}.${key}`)
    }
  }
}

te 的具体调用能力属于 vue-i18n API,而不是 Vue 核心 API。若项目使用的库版本对该方法签名不同,应以所安装版本的类型定义为准。更可靠的长期方案是构建阶段比较语言包结构,并把结果作为 CI 失败条件。

14. 一套可验证的端到端数据流

把整个系统串起来,用户访问 /en-US/orders 时应发生以下状态变化:

1. Router 解析 locale = "en-US"
2. 守卫验证 "en-US" 在支持列表中
3. loader 检查 en-US 是否已加载
4. 未加载则执行动态 import
5. setLocaleMessage("en-US", messages)
6. i18n.global.locale.value = "en-US"
7. 设置 <html lang="en-US">
8. 设置页面标题为 t("nav.orders")
9. Router 放行
10. OrdersView 创建
11. 模板读取 t、d、n
12. Vue 响应式系统完成渲染

如果第 4 步失败,不能继续假设第 6 步一定会成功;如果第 6 步成功但第 7 步未执行,视觉文本可能正确但辅助技术获得的语言仍错误;如果第 8 步缺少标题键,页面主体可能正常而浏览器标签页显示键名。

因此,国际化工程的核心不是“在模板里调用 t()”,而是建立一条一致的数据流:

Locale 来源
  → Locale 校验
  → 语言包加载
  → 消息注册
  → 当前 Locale 更新
  → 文本与格式响应式计算
  → 路由、文档元信息和可访问性同步
  → 缺失与加载错误可观测

当消息目录、Locale、日期数字格式、路由和回退分别拥有清晰边界,并通过一个受控入口连接起来时,国际化才不会退化成组件中的字符串替换,而会成为可加载、可测试、可诊断的 Vue 应用基础设施。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。