Vue 3 国际化实战:Vue I18n 多语言支持完整解决方案
Orion K Lv6

随着全球化的发展,多语言支持已成为现代 Web 应用的必备功能。Vue 3 结合 Vue I18n 提供了强大的国际化解决方案,本文将深入探讨如何在 Vue 3 项目中实现完整的多语言支持,包括文本翻译、日期格式化、数字格式化、复数处理等高级特性。

Vue I18n 基础配置

1. 项目初始化

安装依赖

1
2
3
4
5
6
# 安装 Vue I18n
npm install vue-i18n@9

# 安装相关工具
npm install @intlify/unplugin-vue-i18n
npm install @intlify/vue-i18n-loader

Vite 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'url'

export default defineConfig({
plugins: [
vue(),
VueI18nPlugin({
// 语言文件路径
include: resolve(dirname(fileURLToPath(import.meta.url)), './src/locales/**'),
// 启用组合式 API
compositionOnly: true,
// 运行时编译
runtimeOnly: false,
// 全局注入
globalInjection: true,
// 严格模式
strictMessage: false,
// 缺失键处理
missingWarn: false,
fallbackWarn: false
})
],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})

2. I18n 配置文件

主配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import type { I18nOptions } from 'vue-i18n'

// 导入语言文件
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
import jaJP from './locales/ja-JP.json'
import koKR from './locales/ko-KR.json'
import frFR from './locales/fr-FR.json'
import deDE from './locales/de-DE.json'
import esES from './locales/es-ES.json'
import ruRU from './locales/ru-RU.json'

/**
* 支持的语言列表
*/
export const SUPPORT_LOCALES = [
{
code: 'zh-CN',
name: '简体中文',
flag: '🇨🇳',
dir: 'ltr'
},
{
code: 'en-US',
name: 'English',
flag: '🇺🇸',
dir: 'ltr'
},
{
code: 'ja-JP',
name: '日本語',
flag: '🇯🇵',
dir: 'ltr'
},
{
code: 'ko-KR',
name: '한국어',
flag: '🇰🇷',
dir: 'ltr'
},
{
code: 'fr-FR',
name: 'Français',
flag: '🇫🇷',
dir: 'ltr'
},
{
code: 'de-DE',
name: 'Deutsch',
flag: '🇩🇪',
dir: 'ltr'
},
{
code: 'es-ES',
name: 'Español',
flag: '🇪🇸',
dir: 'ltr'
},
{
code: 'ru-RU',
name: 'Русский',
flag: '🇷🇺',
dir: 'ltr'
}
] as const

/**
* 默认语言
*/
export const DEFAULT_LOCALE = 'zh-CN'

/**
* 回退语言
*/
export const FALLBACK_LOCALE = 'en-US'

/**
* 语言消息
*/
const messages = {
'zh-CN': zhCN,
'en-US': enUS,
'ja-JP': jaJP,
'ko-KR': koKR,
'fr-FR': frFR,
'de-DE': deDE,
'es-ES': esES,
'ru-RU': ruRU
}

/**
* 获取浏览器语言
*/
export function getBrowserLocale(): string {
const navigatorLocale = navigator.language || (navigator as any).userLanguage

// 精确匹配
if (SUPPORT_LOCALES.some(locale => locale.code === navigatorLocale)) {
return navigatorLocale
}

// 语言代码匹配(如 zh 匹配 zh-CN)
const languageCode = navigatorLocale.split('-')[0]
const matchedLocale = SUPPORT_LOCALES.find(locale =>
locale.code.startsWith(languageCode)
)

return matchedLocale?.code || DEFAULT_LOCALE
}

/**
* 获取存储的语言
*/
export function getStoredLocale(): string {
try {
const stored = localStorage.getItem('app-locale')
if (stored && SUPPORT_LOCALES.some(locale => locale.code === stored)) {
return stored
}
} catch (error) {
console.warn('Failed to get stored locale:', error)
}

return getBrowserLocale()
}

/**
* 存储语言设置
*/
export function setStoredLocale(locale: string): void {
try {
localStorage.setItem('app-locale', locale)
} catch (error) {
console.warn('Failed to store locale:', error)
}
}

/**
* I18n 配置选项
*/
const i18nOptions: I18nOptions = {
legacy: false, // 使用组合式 API
locale: getStoredLocale(),
fallbackLocale: FALLBACK_LOCALE,
messages,

// 数字格式化
numberFormats: {
'zh-CN': {
currency: {
style: 'currency',
currency: 'CNY',
notation: 'standard'
},
decimal: {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2
},
percent: {
style: 'percent',
useGrouping: false
}
},
'en-US': {
currency: {
style: 'currency',
currency: 'USD',
notation: 'standard'
},
decimal: {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2
},
percent: {
style: 'percent',
useGrouping: false
}
},
'ja-JP': {
currency: {
style: 'currency',
currency: 'JPY',
notation: 'standard'
},
decimal: {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0
},
percent: {
style: 'percent',
useGrouping: false
}
}
},

// 日期时间格式化
datetimeFormats: {
'zh-CN': {
short: {
year: 'numeric',
month: 'short',
day: 'numeric'
},
long: {
year: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: 'numeric'
},
time: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
}
},
'en-US': {
short: {
year: 'numeric',
month: 'short',
day: 'numeric'
},
long: {
year: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: 'numeric'
},
time: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
}
}
},

// 缺失键处理
missingWarn: process.env.NODE_ENV !== 'production',
fallbackWarn: process.env.NODE_ENV !== 'production',

// 全局属性
globalInjection: true,

// 模式
mode: 'composition'
}

/**
* 创建 I18n 实例
*/
export const i18n = createI18n(i18nOptions)

/**
* 获取当前语言信息
*/
export function getCurrentLocaleInfo() {
const currentLocale = i18n.global.locale.value
return SUPPORT_LOCALES.find(locale => locale.code === currentLocale)
}

/**
* 切换语言
*/
export async function setLocale(locale: string): Promise<void> {
if (!SUPPORT_LOCALES.some(l => l.code === locale)) {
console.warn(`Unsupported locale: ${locale}`)
return
}

// 动态加载语言包(如果需要)
if (!i18n.global.availableLocales.includes(locale)) {
try {
const messages = await import(`./locales/${locale}.json`)
i18n.global.setLocaleMessage(locale, messages.default)
} catch (error) {
console.error(`Failed to load locale ${locale}:`, error)
return
}
}

// 设置语言
i18n.global.locale.value = locale

// 存储到本地
setStoredLocale(locale)

// 设置 HTML lang 属性
document.documentElement.lang = locale

// 设置文档方向
const localeInfo = SUPPORT_LOCALES.find(l => l.code === locale)
if (localeInfo) {
document.documentElement.dir = localeInfo.dir
}
}

export default i18n

3. 语言文件结构

中文语言文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// src/i18n/locales/zh-CN.json
{
"common": {
"ok": "确定",
"cancel": "取消",
"confirm": "确认",
"delete": "删除",
"edit": "编辑",
"save": "保存",
"loading": "加载中...",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"search": "搜索",
"reset": "重置",
"submit": "提交",
"back": "返回",
"next": "下一步",
"previous": "上一步",
"close": "关闭",
"refresh": "刷新"
},
"navigation": {
"home": "首页",
"about": "关于",
"contact": "联系我们",
"products": "产品",
"services": "服务",
"blog": "博客",
"news": "新闻",
"help": "帮助",
"profile": "个人资料",
"settings": "设置",
"logout": "退出登录"
},
"form": {
"validation": {
"required": "此字段为必填项",
"email": "请输入有效的邮箱地址",
"phone": "请输入有效的手机号码",
"password": "密码长度至少为 {min} 位",
"confirm_password": "两次输入的密码不一致",
"min_length": "最少输入 {min} 个字符",
"max_length": "最多输入 {max} 个字符",
"numeric": "请输入数字",
"url": "请输入有效的网址"
},
"placeholder": {
"email": "请输入邮箱地址",
"password": "请输入密码",
"name": "请输入姓名",
"phone": "请输入手机号码",
"search": "请输入搜索关键词",
"comment": "请输入评论内容"
}
},
"message": {
"welcome": "欢迎使用我们的应用!",
"login_success": "登录成功",
"login_failed": "登录失败,请检查用户名和密码",
"logout_success": "退出登录成功",
"save_success": "保存成功",
"save_failed": "保存失败",
"delete_success": "删除成功",
"delete_failed": "删除失败",
"delete_confirm": "确定要删除这个项目吗?",
"network_error": "网络连接错误,请稍后重试",
"permission_denied": "权限不足",
"not_found": "页面未找到",
"server_error": "服务器错误"
},
"date": {
"today": "今天",
"yesterday": "昨天",
"tomorrow": "明天",
"this_week": "本周",
"last_week": "上周",
"next_week": "下周",
"this_month": "本月",
"last_month": "上月",
"next_month": "下月",
"this_year": "今年",
"last_year": "去年",
"next_year": "明年"
},
"pluralization": {
"item": "没有项目 | {count} 个项目 | {count} 个项目",
"user": "没有用户 | {count} 个用户 | {count} 个用户",
"comment": "没有评论 | {count} 条评论 | {count} 条评论",
"like": "没有点赞 | {count} 个点赞 | {count} 个点赞",
"view": "没有浏览 | {count} 次浏览 | {count} 次浏览"
},
"time": {
"just_now": "刚刚",
"minutes_ago": "{count} 分钟前",
"hours_ago": "{count} 小时前",
"days_ago": "{count} 天前",
"weeks_ago": "{count} 周前",
"months_ago": "{count} 个月前",
"years_ago": "{count} 年前"
},
"error": {
"400": "请求错误",
"401": "未授权访问",
"403": "禁止访问",
"404": "页面不存在",
"500": "服务器内部错误",
"502": "网关错误",
"503": "服务不可用",
"504": "网关超时"
}
}

英文语言文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// src/i18n/locales/en-US.json
{
"common": {
"ok": "OK",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"save": "Save",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"search": "Search",
"reset": "Reset",
"submit": "Submit",
"back": "Back",
"next": "Next",
"previous": "Previous",
"close": "Close",
"refresh": "Refresh"
},
"navigation": {
"home": "Home",
"about": "About",
"contact": "Contact",
"products": "Products",
"services": "Services",
"blog": "Blog",
"news": "News",
"help": "Help",
"profile": "Profile",
"settings": "Settings",
"logout": "Logout"
},
"form": {
"validation": {
"required": "This field is required",
"email": "Please enter a valid email address",
"phone": "Please enter a valid phone number",
"password": "Password must be at least {min} characters",
"confirm_password": "Passwords do not match",
"min_length": "Minimum {min} characters required",
"max_length": "Maximum {max} characters allowed",
"numeric": "Please enter a number",
"url": "Please enter a valid URL"
},
"placeholder": {
"email": "Enter email address",
"password": "Enter password",
"name": "Enter name",
"phone": "Enter phone number",
"search": "Enter search keywords",
"comment": "Enter comment"
}
},
"message": {
"welcome": "Welcome to our application!",
"login_success": "Login successful",
"login_failed": "Login failed, please check username and password",
"logout_success": "Logout successful",
"save_success": "Save successful",
"save_failed": "Save failed",
"delete_success": "Delete successful",
"delete_failed": "Delete failed",
"delete_confirm": "Are you sure you want to delete this item?",
"network_error": "Network error, please try again later",
"permission_denied": "Permission denied",
"not_found": "Page not found",
"server_error": "Server error"
},
"date": {
"today": "Today",
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"this_week": "This week",
"last_week": "Last week",
"next_week": "Next week",
"this_month": "This month",
"last_month": "Last month",
"next_month": "Next month",
"this_year": "This year",
"last_year": "Last year",
"next_year": "Next year"
},
"pluralization": {
"item": "no items | {count} item | {count} items",
"user": "no users | {count} user | {count} users",
"comment": "no comments | {count} comment | {count} comments",
"like": "no likes | {count} like | {count} likes",
"view": "no views | {count} view | {count} views"
},
"time": {
"just_now": "Just now",
"minutes_ago": "{count} minutes ago",
"hours_ago": "{count} hours ago",
"days_ago": "{count} days ago",
"weeks_ago": "{count} weeks ago",
"months_ago": "{count} months ago",
"years_ago": "{count} years ago"
},
"error": {
"400": "Bad Request",
"401": "Unauthorized",
"403": "Forbidden",
"404": "Not Found",
"500": "Internal Server Error",
"502": "Bad Gateway",
"503": "Service Unavailable",
"504": "Gateway Timeout"
}
}

高级国际化功能

1. 组合式 API 使用

国际化 Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
// composables/useI18n.ts
import { computed } from 'vue'
import { useI18n as useVueI18n } from 'vue-i18n'
import type { Composer } from 'vue-i18n'

/**
* 扩展的国际化 Hook
*/
export function useI18n() {
const i18n = useVueI18n()

/**
* 翻译函数(支持 HTML)
*/
const th = (key: string, values?: Record<string, any>): string => {
return i18n.t(key, values)
}

/**
* 复数翻译
*/
const tp = (key: string, count: number, values?: Record<string, any>): string => {
return i18n.t(key, { count, ...values }, count)
}

/**
* 日期格式化
*/
const td = (date: Date | string | number, format = 'short'): string => {
const dateObj = typeof date === 'string' || typeof date === 'number'
? new Date(date)
: date
return i18n.d(dateObj, format)
}

/**
* 数字格式化
*/
const tn = (number: number, format = 'decimal'): string => {
return i18n.n(number, format)
}

/**
* 货币格式化
*/
const tc = (amount: number): string => {
return i18n.n(amount, 'currency')
}

/**
* 百分比格式化
*/
const tpct = (value: number): string => {
return i18n.n(value / 100, 'percent')
}

/**
* 相对时间格式化
*/
const tr = (date: Date | string | number): string => {
const dateObj = typeof date === 'string' || typeof date === 'number'
? new Date(date)
: date
const now = new Date()
const diff = now.getTime() - dateObj.getTime()

const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const weeks = Math.floor(days / 7)
const months = Math.floor(days / 30)
const years = Math.floor(days / 365)

if (seconds < 60) {
return i18n.t('time.just_now')
} else if (minutes < 60) {
return i18n.t('time.minutes_ago', { count: minutes })
} else if (hours < 24) {
return i18n.t('time.hours_ago', { count: hours })
} else if (days < 7) {
return i18n.t('time.days_ago', { count: days })
} else if (weeks < 4) {
return i18n.t('time.weeks_ago', { count: weeks })
} else if (months < 12) {
return i18n.t('time.months_ago', { count: months })
} else {
return i18n.t('time.years_ago', { count: years })
}
}

/**
* 检查翻译键是否存在
*/
const te = (key: string): boolean => {
return i18n.te(key)
}

/**
* 获取当前语言
*/
const locale = computed(() => i18n.locale.value)

/**
* 获取可用语言列表
*/
const availableLocales = computed(() => i18n.availableLocales)

/**
* 是否为 RTL 语言
*/
const isRTL = computed(() => {
const rtlLocales = ['ar', 'he', 'fa', 'ur']
return rtlLocales.some(rtl => locale.value.startsWith(rtl))
})

return {
// 原始 i18n 实例
...i18n,

// 扩展方法
th,
tp,
td,
tn,
tc,
tpct,
tr,
te,

// 计算属性
locale,
availableLocales,
isRTL
}
}

/**
* 语言切换 Hook
*/
export function useLocale() {
const { locale } = useI18n()

/**
* 切换语言
*/
const setLocale = async (newLocale: string): Promise<void> => {
const { setLocale: setI18nLocale } = await import('@/i18n')
await setI18nLocale(newLocale)
}

/**
* 获取语言信息
*/
const getLocaleInfo = (localeCode?: string) => {
const { SUPPORT_LOCALES } = require('@/i18n')
const code = localeCode || locale.value
return SUPPORT_LOCALES.find((l: any) => l.code === code)
}

/**
* 获取所有支持的语言
*/
const getSupportedLocales = () => {
const { SUPPORT_LOCALES } = require('@/i18n')
return SUPPORT_LOCALES
}

return {
locale,
setLocale,
getLocaleInfo,
getSupportedLocales
}
}

/**
* 表单验证国际化 Hook
*/
export function useFormValidation() {
const { t } = useI18n()

/**
* 验证规则
*/
const rules = {
required: (message?: string) => (value: any) => {
if (!value || (Array.isArray(value) && value.length === 0)) {
return message || t('form.validation.required')
}
return true
},

email: (message?: string) => (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (value && !emailRegex.test(value)) {
return message || t('form.validation.email')
}
return true
},

minLength: (min: number, message?: string) => (value: string) => {
if (value && value.length < min) {
return message || t('form.validation.min_length', { min })
}
return true
},

maxLength: (max: number, message?: string) => (value: string) => {
if (value && value.length > max) {
return message || t('form.validation.max_length', { max })
}
return true
},

numeric: (message?: string) => (value: string) => {
if (value && isNaN(Number(value))) {
return message || t('form.validation.numeric')
}
return true
},

url: (message?: string) => (value: string) => {
try {
new URL(value)
return true
} catch {
return message || t('form.validation.url')
}
},

phone: (message?: string) => (value: string) => {
const phoneRegex = /^[+]?[1-9]\d{1,14}$/
if (value && !phoneRegex.test(value.replace(/[\s-()]/g, ''))) {
return message || t('form.validation.phone')
}
return true
},

password: (min = 8, message?: string) => (value: string) => {
if (value && value.length < min) {
return message || t('form.validation.password', { min })
}
return true
},

confirmPassword: (password: string, message?: string) => (value: string) => {
if (value !== password) {
return message || t('form.validation.confirm_password')
}
return true
}
}

return {
rules
}
}

2. 动态语言加载

懒加载语言包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
// utils/i18nLoader.ts
import type { I18n } from 'vue-i18n'

/**
* 语言包加载器
*/
export class I18nLoader {
private static loadedLocales = new Set<string>()
private static loadingPromises = new Map<string, Promise<any>>()

/**
* 动态加载语言包
*/
static async loadLocale(i18n: I18n, locale: string): Promise<void> {
// 如果已经加载过,直接返回
if (this.loadedLocales.has(locale)) {
return
}

// 如果正在加载,等待加载完成
if (this.loadingPromises.has(locale)) {
await this.loadingPromises.get(locale)
return
}

// 开始加载
const loadingPromise = this.doLoadLocale(i18n, locale)
this.loadingPromises.set(locale, loadingPromise)

try {
await loadingPromise
this.loadedLocales.add(locale)
} finally {
this.loadingPromises.delete(locale)
}
}

/**
* 执行语言包加载
*/
private static async doLoadLocale(i18n: I18n, locale: string): Promise<void> {
try {
// 加载主语言包
const mainMessages = await import(`@/i18n/locales/${locale}.json`)

// 加载模块化语言包
const moduleMessages = await this.loadModuleMessages(locale)

// 合并消息
const messages = {
...mainMessages.default,
...moduleMessages
}

// 设置语言消息
i18n.global.setLocaleMessage(locale, messages)

console.log(`Locale ${locale} loaded successfully`)
} catch (error) {
console.error(`Failed to load locale ${locale}:`, error)
throw error
}
}

/**
* 加载模块化语言包
*/
private static async loadModuleMessages(locale: string): Promise<Record<string, any>> {
const moduleMessages: Record<string, any> = {}

// 定义需要加载的模块
const modules = [
'auth',
'dashboard',
'user',
'product',
'order',
'settings'
]

// 并行加载所有模块
const loadPromises = modules.map(async (module) => {
try {
const messages = await import(`@/modules/${module}/i18n/${locale}.json`)
moduleMessages[module] = messages.default
} catch (error) {
console.warn(`Failed to load module ${module} for locale ${locale}:`, error)
}
})

await Promise.all(loadPromises)

return moduleMessages
}

/**
* 预加载语言包
*/
static async preloadLocales(i18n: I18n, locales: string[]): Promise<void> {
const loadPromises = locales.map(locale => this.loadLocale(i18n, locale))
await Promise.allSettled(loadPromises)
}

/**
* 清理已加载的语言包
*/
static clearLoadedLocales(): void {
this.loadedLocales.clear()
this.loadingPromises.clear()
}

/**
* 获取已加载的语言列表
*/
static getLoadedLocales(): string[] {
return Array.from(this.loadedLocales)
}
}

/**
* 语言包缓存管理
*/
export class I18nCache {
private static readonly CACHE_KEY = 'i18n-cache'
private static readonly CACHE_VERSION = '1.0.0'
private static readonly CACHE_EXPIRY = 24 * 60 * 60 * 1000 // 24小时

/**
* 缓存语言包
*/
static async cacheLocale(locale: string, messages: any): Promise<void> {
try {
const cacheData = {
version: this.CACHE_VERSION,
timestamp: Date.now(),
locale,
messages
}

localStorage.setItem(`${this.CACHE_KEY}-${locale}`, JSON.stringify(cacheData))
} catch (error) {
console.warn('Failed to cache locale:', error)
}
}

/**
* 获取缓存的语言包
*/
static getCachedLocale(locale: string): any | null {
try {
const cached = localStorage.getItem(`${this.CACHE_KEY}-${locale}`)
if (!cached) {
return null
}

const cacheData = JSON.parse(cached)

// 检查版本和过期时间
if (cacheData.version !== this.CACHE_VERSION ||
Date.now() - cacheData.timestamp > this.CACHE_EXPIRY) {
this.clearCachedLocale(locale)
return null
}

return cacheData.messages
} catch (error) {
console.warn('Failed to get cached locale:', error)
return null
}
}

/**
* 清理缓存的语言包
*/
static clearCachedLocale(locale: string): void {
try {
localStorage.removeItem(`${this.CACHE_KEY}-${locale}`)
} catch (error) {
console.warn('Failed to clear cached locale:', error)
}
}

/**
* 清理所有缓存
*/
static clearAllCache(): void {
try {
const keys = Object.keys(localStorage)
keys.forEach(key => {
if (key.startsWith(this.CACHE_KEY)) {
localStorage.removeItem(key)
}
})
} catch (error) {
console.warn('Failed to clear all cache:', error)
}
}
}

3. 国际化组件

语言切换器组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
<!-- components/I18n/LanguageSwitcher.vue -->
<template>
<div class="language-switcher">
<div
class="current-language"
@click="toggleDropdown"
:class="{ active: isDropdownOpen }"
>
<span class="flag">{{ currentLocaleInfo?.flag }}</span>
<span class="name">{{ currentLocaleInfo?.name }}</span>
<ChevronDownIcon
class="icon"
:class="{ rotated: isDropdownOpen }"
/>
</div>

<Transition name="dropdown">
<div
v-if="isDropdownOpen"
class="language-dropdown"
@click.stop
>
<div
v-for="locale in supportedLocales"
:key="locale.code"
class="language-option"
:class="{ active: locale.code === currentLocale }"
@click="selectLanguage(locale.code)"
>
<span class="flag">{{ locale.flag }}</span>
<span class="name">{{ locale.name }}</span>
<CheckIcon
v-if="locale.code === currentLocale"
class="check-icon"
/>
</div>
</div>
</Transition>
</div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ChevronDownIcon, CheckIcon } from '@heroicons/vue/24/outline'
import { useI18n, useLocale } from '@/composables/useI18n'
import { SUPPORT_LOCALES } from '@/i18n'

interface Props {
placement?: 'bottom' | 'top'
size?: 'small' | 'medium' | 'large'
showFlag?: boolean
showName?: boolean
}

const props = withDefaults(defineProps<Props>(), {
placement: 'bottom',
size: 'medium',
showFlag: true,
showName: true
})

const emit = defineEmits<{
change: [locale: string]
}>()

const { locale: currentLocale } = useI18n()
const { setLocale, getLocaleInfo } = useLocale()

const isDropdownOpen = ref(false)
const isLoading = ref(false)

// 当前语言信息
const currentLocaleInfo = computed(() => getLocaleInfo())

// 支持的语言列表
const supportedLocales = computed(() => SUPPORT_LOCALES)

/**
* 切换下拉菜单
*/
const toggleDropdown = (): void => {
isDropdownOpen.value = !isDropdownOpen.value
}

/**
* 选择语言
*/
const selectLanguage = async (locale: string): Promise<void> => {
if (locale === currentLocale.value || isLoading.value) {
return
}

try {
isLoading.value = true
await setLocale(locale)
emit('change', locale)
isDropdownOpen.value = false
} catch (error) {
console.error('Failed to change language:', error)
} finally {
isLoading.value = false
}
}

/**
* 点击外部关闭下拉菜单
*/
const handleClickOutside = (event: Event): void => {
const target = event.target as HTMLElement
if (!target.closest('.language-switcher')) {
isDropdownOpen.value = false
}
}

// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})

onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

<style scoped>
.language-switcher {
position: relative;
display: inline-block;
}

.current-language {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}

.current-language:hover {
border-color: #d1d5db;
background: #f9fafb;
}

.current-language.active {
border-color: #4f46e5;
box-shadow: 0 0 0 1px #4f46e5;
}

.flag {
font-size: 16px;
line-height: 1;
}

.name {
font-size: 14px;
font-weight: 500;
color: #374151;
}

.icon {
width: 16px;
height: 16px;
color: #6b7280;
transition: transform 0.2s ease;
}

.icon.rotated {
transform: rotate(180deg);
}

.language-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 50;
overflow: hidden;
}

.language-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
}

.language-option:hover {
background: #f3f4f6;
}

.language-option.active {
background: #eff6ff;
color: #1d4ed8;
}

.check-icon {
width: 16px;
height: 16px;
color: #10b981;
margin-left: auto;
}

/* 动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}

.dropdown-enter-from {
opacity: 0;
transform: translateY(-8px);
}

.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}

/* 尺寸变体 */
.language-switcher.small .current-language {
padding: 4px 8px;
font-size: 12px;
}

.language-switcher.small .flag {
font-size: 14px;
}

.language-switcher.large .current-language {
padding: 12px 16px;
font-size: 16px;
}

.language-switcher.large .flag {
font-size: 20px;
}
</style>

翻译文本组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<!-- components/I18n/TranslateText.vue -->
<template>
<component
:is="tag"
:class="className"
v-html="translatedText"
/>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'

interface Props {
keypath: string
values?: Record<string, any>
tag?: string
className?: string
plural?: number
fallback?: string
html?: boolean
}

const props = withDefaults(defineProps<Props>(), {
tag: 'span',
html: false
})

const { t, te } = useI18n()

/**
* 翻译后的文本
*/
const translatedText = computed(() => {
// 检查键是否存在
if (!te(props.keypath)) {
console.warn(`Translation key not found: ${props.keypath}`)
return props.fallback || props.keypath
}

// 处理复数
if (typeof props.plural === 'number') {
return t(props.keypath, { count: props.plural, ...props.values }, props.plural)
}

// 普通翻译
return t(props.keypath, props.values)
})
</script>

总结

Vue 3 结合 Vue I18n 提供了完整的国际化解决方案,通过合理的配置和组件化设计,可以轻松实现多语言支持:

核心特性

  1. 组合式 API:更好的 TypeScript 支持和代码组织
  2. 动态加载:按需加载语言包,优化性能
  3. 格式化支持:日期、数字、货币等本地化格式
  4. 复数处理:智能的复数规则支持
  5. 缓存机制:提升语言切换性能

最佳实践

  1. 结构化组织:按模块组织语言文件
  2. 类型安全:使用 TypeScript 确保翻译键的类型安全
  3. 性能优化:懒加载和缓存策略
  4. 用户体验:平滑的语言切换和加载状态
  5. 可维护性:统一的翻译管理和组件化设计

通过这些实践,可以构建出用户友好、性能优秀的多语言 Vue 3 应用。

本站由 提供部署服务