国际化 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 几乎是同一套思路:
messages对象 ≈ resource bundle$t('hello')≈messageSource.getMessageuseI18n().locale≈LocaleContextHolder
区别:前端可以运行时切换,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 模式:
- Legacy 模式(
legacy: true)—— 用this.$t(),和 Options API 配套,Vue 2 写法 - Composition 模式(
legacy: false)—— 用const { t } = useI18n(),推荐
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('key')—— 取翻译- 带参数:
t('key', { name: '...' }) locale是响应式 ref,直接赋值即可切换
老写法 $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。原因:
- 笔记是面向中文 Java 开发者的,没有海外用户需求
- 笔记内容本身是 Markdown,每篇笔记都要翻一遍不划算
但产品的 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> 插槽 |
小练习
- 装 vue-i18n,给 in4vue 的顶栏按钮(登录、设置、退出)加上
t() - 加一个语言切换下拉,切换 zh-CN / en-US,刷新页面记得
- 和 Element Plus 的
el-config-provider联动 - 用
t()写一个带参数的欢迎语:welcome = "你好, {name}" - 加类型补全,在
t('xxx')里故意写错 key 看 IDE 报红
延伸阅读
- Vue I18n 官方文档
- MDN - Intl 对象
- Element Plus - 国际化
- @intlify/unplugin-vue-i18n(Vite 插件,带类型推导和 JSON 优化)
- Format.JS ICU MessageFormat(更强的复数、性别表达)