国际化 i18n:vue-i18n 完整方案

1. 从 MessageSource 到 vue-i18n

Java 里搞国际化是 MessageSource

@Autowired MessageSource messageSource;

String greeting = messageSource.getMessage("hello", null, LocaleContextHolder.getLocale());

配合 messages_zh_CN.properties / messages_en_US.properties

# messages_zh_CN.properties
hello=你好, {0}

# messages_en_US.properties
hello=Hello, {0}

前端 vue-i18n 几乎是同一套思路:

区别:前端可以运行时切换,Spring 一般按请求的 Accept-Language 确定。


2. 安装与最小配置

pnpm add vue-i18n
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'

export const i18n = createI18n({
  legacy: false,              // 使用 Composition API 模式
  locale: 'zh-CN',            // 默认语言
  fallbackLocale: 'en-US',    // 找不到键时回退
  messages: {
    'zh-CN': zhCN,
    'en-US': enUS,
  },
})
// main.ts
import { i18n } from './i18n'
app.use(i18n)

两种 API 模式

in4vue 是 Composition API 项目,用 Composition 模式


3. 消息文件:扁平还是嵌套?

src/i18n/locales/zh-CN.json

{
  "common": {
    "confirm": "确认",
    "cancel": "取消",
    "save": "保存",
    "delete": "删除"
  },
  "auth": {
    "login": "登录",
    "logout": "退出登录",
    "welcome": "欢迎回来, {name}",
    "passwordMinLength": "密码至少 {min} 位"
  },
  "notes": {
    "title": "笔记列表",
    "createNew": "新建笔记",
    "deleteConfirm": "确认删除「{title}」?"
  }
}

en-US.json

{
  "common": {
    "confirm": "Confirm",
    "cancel": "Cancel",
    "save": "Save",
    "delete": "Delete"
  },
  "auth": {
    "login": "Sign in",
    "logout": "Sign out",
    "welcome": "Welcome back, {name}",
    "passwordMinLength": "Password must be at least {min} characters"
  },
  "notes": {
    "title": "Notes",
    "createNew": "New note",
    "deleteConfirm": "Delete \"{title}\"?"
  }
}

按"功能模块"嵌套common / auth / notes... 避免键名太长(loginPagePasswordInputPlaceholder)。


4. 组件里用:useI18n

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

const { t, locale } = useI18n()

const user = { name: '应剑锋' }
</script>

<template>
  <h1>{{ t('auth.welcome', { name: user.name }) }}</h1>
  <el-button>{{ t('common.save') }}</el-button>

  <p>当前语言: {{ locale }}</p>
  <el-button @click="locale = 'en-US'">English</el-button>
  <el-button @click="locale = 'zh-CN'">中文</el-button>
</template>

要点

老写法 $t:模板里也能用 {{ $t('key') }} ——前提是开启了 legacy(或 globalInjection: true)。新项目推荐 useI18n() 保持类型友好。


5. 类型安全:让 t('key') 有自动补全

vue-i18n 9+ 支持用 TypeScript 推导消息结构,写错 key 立刻报错:

// src/types/i18n.d.ts
import type zhCN from '@/i18n/locales/zh-CN.json'

declare module 'vue-i18n' {
  export interface DefineLocaleMessage extends typeof zhCN {}
}

现在 t('auth.welcome') 有补全,t('auth.welcomex') 会红波浪。

Java 对照:类似 Spring Boot 配置类的 @ConfigurationProperties(prefix = "app"),key 拼错不会编译通过。前端这么做后可靠性飙升。


6. 复数:pluralization

中文不怎么区分单复数,英文要 1 item / 2 items。vue-i18n 内置支持:

// en-US.json
{
  "item": "no items | one item | {count} items"
}

// zh-CN.json
{
  "item": "没有条目 | 一条条目 | {count} 条条目"
}
<p>{{ t('item', 0) }}</p>    <!-- no items / 没有条目 -->
<p>{{ t('item', 1) }}</p>    <!-- one item / 一条条目 -->
<p>{{ t('item', 5) }}</p>    <!-- 5 items / 5 条条目 -->

管道分隔的三段对应 0 / 1 / N。


7. 语言切换:持久化 + 多处同步

简单粗暴在任意组件里改 locale.value = 'en-US' 能用,但刷新就没了。配合 VueUse 的 useStorage

// src/composables/useLocale.ts
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStorage } from '@vueuse/core'

export type SupportedLocale = 'zh-CN' | 'en-US'

const storedLocale = useStorage<SupportedLocale>('app-locale', 'zh-CN')

export function useLocale() {
  const { locale } = useI18n()

  const current = computed({
    get: () => storedLocale.value,
    set: (val: SupportedLocale) => {
      storedLocale.value = val
      locale.value = val
      // 同步 html 的 lang 属性(对 SEO / 无障碍有帮助)
      document.documentElement.lang = val
    },
  })

  return { current, options: [
    { label: '简体中文', value: 'zh-CN' as const },
    { label: 'English', value: 'en-US' as const },
  ] }
}

初始化时读 storage

// src/i18n/index.ts
const storedLocale = localStorage.getItem('app-locale') || 'zh-CN'

export const i18n = createI18n({
  legacy: false,
  locale: storedLocale,
  fallbackLocale: 'en-US',
  messages: { /* ... */ },
})

8. 和 Element Plus 联动

Element Plus 内部文案(分页"共 X 条"、日期选择器月份名...)也需要切换:

<!-- App.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'

const { locale } = useI18n()

const elementLocale = computed(() => (locale.value === 'en-US' ? en : zhCn))
</script>

<template>
  <el-config-provider :locale="elementLocale">
    <router-view />
  </el-config-provider>
</template>

locale 变 → elementLocale 重新计算 → Element Plus 文案跟着切。


9. 日期与数字格式化

9.1 vue-i18n 内置的 $d / $n

export const i18n = createI18n({
  // ...
  datetimeFormats: {
    'zh-CN': {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' },
    },
    'en-US': {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' },
    },
  },
  numberFormats: {
    'zh-CN': {
      currency: { style: 'currency', currency: 'CNY' },
      percent: { style: 'percent', maximumFractionDigits: 2 },
    },
    'en-US': {
      currency: { style: 'currency', currency: 'USD' },
      percent: { style: 'percent', maximumFractionDigits: 2 },
    },
  },
})
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { d, n } = useI18n()
</script>

<template>
  <p>{{ d(new Date(), 'long') }}</p>
  <!-- zh-CN: 2026年5月12日 14:30 -->
  <!-- en-US: May 12, 2026, 2:30 PM -->

  <p>{{ n(1234.56, 'currency') }}</p>
  <!-- zh-CN: ¥1,234.56 -->
  <!-- en-US: $1,234.56 -->
</template>

9.2 直接用原生 Intl

其实 d / n 背后就是 Intl API,浏览器自带,不用 moment/dayjs 都能做:

new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(new Date())
// "2026年5月12日星期二"

new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(1234.56)
// "¥1,234.56"

new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' }).format(-3, 'day')
// "3天前"

10. 按需加载:不把所有语言打进初始包

项目支持 10 种语言但用户只用 1 种,全打包浪费:

// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.json' // 默认语言,预加载

export const i18n = createI18n({
  legacy: false,
  locale: 'zh-CN',
  fallbackLocale: 'zh-CN',
  messages: { 'zh-CN': zhCN },
})

// 动态加载其他语言
const loadedLocales = new Set(['zh-CN'])

export async function loadLocale(locale: string) {
  if (loadedLocales.has(locale)) {
    i18n.global.locale.value = locale as any
    return
  }
  const messages = await import(`./locales/${locale}.json`)
  i18n.global.setLocaleMessage(locale, messages.default)
  loadedLocales.add(locale)
  i18n.global.locale.value = locale as any
}

切换时调 loadLocale('en-US'),Vite 会自动给 en-US.json 拆成独立 chunk,按需下载。


11. 后端返回的文案怎么处理

后端返回业务错误文案(如"用户名已存在"),两种做法:

方案 A:后端返错误码,前端翻译

// 后端返
{ "code": "USER_EXISTS" }
// zh-CN.json
{
  "errors": {
    "USER_EXISTS": "用户名已存在",
    "EMAIL_INVALID": "邮箱格式错误"
  }
}
ElMessage.error(t(`errors.${err.code}`))

优点:完全前端控制,能做好所有语言。 缺点:新增错误要前后端同步。

方案 B:后端根据 Accept-Language 直接返文案

前端请求带 Accept-Language: en-US,后端查自己的 MessageSource 返英文。Axios 拦截器统一设:

service.interceptors.request.use((config) => {
  config.headers['Accept-Language'] = i18n.global.locale.value
  return config
})

优点:前端不用维护错误文案。 缺点:后端也要做 i18n 工作量。

国内项目常见做法A + B 混合——业务错误后端返(B),UI 文案(按钮、表单提示)前端自己(A)。


12. 表单校验中的 i18n

Element Plus 规则里的 message 支持函数:

import { useI18n } from 'vue-i18n'

const { t } = useI18n()

const rules = computed(() => ({
  username: [
    { required: true, message: () => t('auth.usernameRequired'), trigger: 'blur' },
    { min: 3, max: 20, message: () => t('auth.usernameLength', { min: 3, max: 20 }), trigger: 'blur' },
  ],
}))

必须用 computed:切换语言时 t('key') 返回的值变,rules 要重算。直接写 message: t('...') 会锁死在第一次调用的值。


13. 反模式

13.1 在模板里硬编码

<!-- ❌ 一旦要做 i18n 就满屏要改 -->
<el-button>保存</el-button>

<!-- ✅ 即便暂时只有中文,也先走 t() -->
<el-button>{{ t('common.save') }}</el-button>

前瞻一点:即便产品当前只做中文,用 t() 的成本几乎为零,未来支持英文不用大改。

13.2 把 HTML 塞到翻译值里

{
  "agreement": "我同意 <a href='/terms'>服务条款</a>"
}

然后 v-html="t('agreement')" —— XSS 风险 + 翻译人员不懂 HTML 容易破坏标签。

正确做法:用组件插值(<i18n-t>):

<i18n-t keypath="agreement" tag="p">
  <template #link>
    <a href="/terms">{{ t('termsLink') }}</a>
  </template>
</i18n-t>
{
  "agreement": "I agree to the {link}",
  "termsLink": "Terms of Service"
}

13.3 动态 key 不加回退

t(`errors.${err.code}`) // 如果 code 未收录,会返回 key 本身,用户看到 "errors.XXX"

加兜底:

const msg = te(`errors.${err.code}`) ? t(`errors.${err.code}`) : t('errors.unknown')

te(key) 检测 key 是否存在。


14. in4vue 的 i18n 策略

在 in4vue 里,先不做 i18n。原因:

产品的 UI 壳子部分(按钮、菜单)按 t() 写能"免费"获得未来扩展的能力。当某天接了英文博主写英文笔记时,补一份 en-US.json 即可。


15. 常见坑点

现象 原因 解法
t 在组件外用不了 useI18n() 只在 setup 里可用 i18n.global.t(...)
切换语言后表单校验消息没变 rules 不是 computed computed(() => ({ ... }))
日期格式不对 没配 datetimeFormats 或 key 写错 检查 key 和 locale 对应
翻译 key 满天飞找不到 键名混乱 按模块嵌套 + 类型补全
XSS 翻译值里含 HTML + v-html <i18n-t> 插槽

小练习

  1. 装 vue-i18n,给 in4vue 的顶栏按钮(登录、设置、退出)加上 t()
  2. 加一个语言切换下拉,切换 zh-CN / en-US,刷新页面记得
  3. 和 Element Plus 的 el-config-provider 联动
  4. t() 写一个带参数的欢迎语:welcome = "你好, {name}"
  5. 加类型补全,在 t('xxx') 里故意写错 key 看 IDE 报红

延伸阅读