或者用yarn/pnpm
Vue3 i18n 新手入门到落地要避哪些坑?连配置翻译都讲透
做过Vue2项目的人应该知道Vue I18n是官方指定的国际化工具,转到Vue3后工具升级成了Vue I18n 9+,API和逻辑都变了不少,刚上手很容易踩各种小问题——比如配置了半天翻译不生效、切换语言页面没刷新、动态参数塞不对位置、图片或者组件内的文案怎么处理,今天就把我最近用Vue3做一个跨境SaaS后台踩过的所有坑整理出来,从0到1讲配置、讲使用、讲场景化的坑怎么填,看完直接能落地。
先搞懂Vue3 i18n的核心变化,别拿Vue2的老思路套
很多人一开始犯的错就是直接复制Vue2 i18n的代码,结果报错连原因都找不到,其实Vue I18n 9+为了适配Vue3的Composition API、Tree Shaking这些新特性,核心逻辑做了重构,三个最关键的点必须先记牢:
- 不再默认导出一个全局i18n实例,而是要手动创建;
- Composition API优先(虽然还保留Options API的写法,但Composition API能更好控制Tree Shaking,不会把用不到的语言包打包进去);
- 引入了“Legacy模式”和“Composition模式”两种模式,新手直接选Composition模式就行,Legacy模式主要是给老项目升级用的,配置更繁琐。
之前我做过一个老项目升级到Vue3的活,一开始图省事用了Legacy模式,结果和Vue3的script setup里的变量有点冲突,排查了半天才发现Legacy模式下的$t()在组合式函数里不能直接用,必须单独引入,后来干脆重构用Composition模式,反而更顺。
0基础一步一步配置Vue3 i18n(附最规范的目录结构)
很多教程里的目录结构都是把语言包放在assets或者components里,其实不对——语言包是独立的资源,应该单独抽出来,方便后续维护、导出给翻译人员,也方便Tree Shaking按需加载,我现在用的跨境SaaS后台语言包已经有12种了,还是很清晰:
src/
├── i18n/
│ ├── index.ts // i18n主配置文件
│ ├── locales/ // 语言包存放目录
│ │ ├── zh-CN.ts // 中文简体
│ │ ├── en-US.ts // 英文
│ │ └── ja-JP.ts // 日文
│ └── types.ts // TypeScript类型定义(非必需,但有类型提示爽很多)
接下来是配置步骤:
第一步:安装依赖
记得区分项目是Vite还是Vue CLI搭建的,Vite要安装vue-i18n@next,Vue CLI可以直接用脚手架的i18n插件,但我还是推荐手动安装,更可控:
Vite命令:
npm install vue-i18n@nextyarn add vue-i18n@next pnpm add vue-i18n@next
Vue CLI命令(可选插件安装):
vue add i18n
第二步:写类型定义(TypeScript必做)
如果不用TypeScript,可以跳过这一步,但用的话一定要加,不然编辑器会给$t()、t()这些函数画波浪线,提示找不到参数或者类型错误,类型定义很简单,就是把语言包的结构套进去:
// src/i18n/types.ts // 先导入中文简体的语言包当基准 import zhCN from './locales/zh-CN' // 定义MessageSchema类型,继承zhCN的类型 export type MessageSchema = typeof zhCN // 定义Locale类型,就是所有支持的语言字符串 export type Locale = 'zh-CN' | 'en-US' | 'ja-JP'
第三步:写主配置文件
这里要注意两个新手常踩的坑:
- Composition模式要显式开启:默认是Legacy模式,必须把
legacy设为false; - 要使用Vue3的响应式API:比如要切换语言,必须把当前语言设为
ref或者reactive变量; - 不要一开始就把所有语言包都导入:如果语言包很多,会增大首屏加载体积,后面会讲按需加载的做法,先按基础配置来。
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import type { MessageSchema, Locale } from './types'
import zhCN from './locales/zh-CN'
import enUS from './locales/en-US'
// 从localStorage读取上次保存的语言,默认中文简体
const savedLocale = localStorage.getItem('app-locale') as Locale || 'zh-CN'
// 创建i18n实例
export const i18n = createI18n<[MessageSchema], Locale>({
legacy: false, // 显式开启Composition模式
locale: savedLocale, // 当前语言
fallbackLocale: 'zh-CN', // 当某个语言没有对应文案时,回退到中文简体
messages: { // 语言包对象
'zh-CN': zhCN,
'en-US': enUS,
},
silentTranslationWarn: true, // 生产环境关闭翻译警告,开发环境可以设为false
silentFallbackWarn: true, // 生产环境关闭回退警告
})
第四步:在main.ts/main.js里注册
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { i18n } from './i18n'
const app = createApp(App)
app.use(i18n) // 注册i18n
app.mount('#app')
第五步:写个简单的语言包测试一下
// src/i18n/locales/zh-CN.ts
export default {
common: {
welcome: '欢迎使用跨境SaaS后台',
switchLang: '切换语言',
},
user: {
login: '登录',
register: '注册',
placeholder: {
username: '请输入用户名',
password: '请输入密码',
},
},
}
// src/i18n/locales/en-US.ts
export default {
common: {
welcome: 'Welcome to Cross-border SaaS Dashboard',
switchLang: 'Switch Language',
},
user: {
login: 'Login',
register: 'Register',
placeholder: {
username: 'Please enter username',
password: 'Please enter password',
},
},
}
然后在App.vue里测试:
<template>
<div class="app">
<h1>{{ t('common.welcome') }}</h1>
<div class="lang-switch">
<button @click="switchLocale('zh-CN')">中文</button>
<button @click="switchLocale('en-US')">English</button>
</div>
<div class="user-form">
<input type="text" :placeholder="t('user.placeholder.username')">
<input type="password" :placeholder="t('user.placeholder.password')">
<button>{{ t('user.login') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { Locale } from '@/i18n/types'
// 解构出t函数、locale响应式变量
const { t, locale } = useI18n<[MessageSchema], Locale>()
// 切换语言的函数
const switchLocale = (newLocale: Locale) => {
locale.value = newLocale
localStorage.setItem('app-locale', newLocale)
}
</script>
这时候应该就能正常切换语言了,要是没反应,检查一下是不是开启了Legacy模式,或者在组合式函数之外的地方直接用了$t()(Legacy模式才可以全局用$t(),Composition模式不行,必须用useI18n解构)。
新手最容易踩的10个Vue3 i18n场景化坑
刚才的基础配置可能很顺利,但一到实际项目里,各种奇奇怪怪的问题就来了,我整理了10个自己踩过或者身边朋友踩过的高频坑,一个个给解决办法:
坑1:切换语言页面没刷新,部分组件的文案没更新
这个坑是我一开始踩的第一个,后来排查了半天,发现是在组件初始化时把t函数的返回值赋给了普通变量,而不是响应式变量或者直接在模板里用。
<!-- 错误写法 -->
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 普通变量不会随locale变化而更新
const welcomeText = t('common.welcome')
</script>
<template>
<h1>{{ welcomeText }}</h1>
</template>
解决办法有三个:
- 直接在模板里用t函数:最简单,也是最推荐的,Vue3的响应式系统会自动追踪t函数里的locale变化;
- 用computed计算属性:如果必须在脚本里用到翻译后的文案,用computed包裹;
- 用watch监听locale变化,重新赋值:不推荐,太麻烦,不如用computed。
正确的computed写法:
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const welcomeText = computed(() => t('common.welcome'))
</script>
坑2:动态参数塞不对位置,用户{username}登录成功”
Vue3 i18n的动态参数语法和Vue2差不多,但要注意如果参数里有特殊字符(}、$、%),要转义,或者用named参数、list参数两种方式灵活处理:
list参数(位置参数)
// 语言包
export default {
common: {
loginSuccess: '用户{0}登录成功,现在是{1}年{2}月{3}日',
},
}
// 使用
t('common.loginSuccess', ['张三', 2024, 5, 20])
named参数(键值对参数,更推荐,位置不敏感)
// 语言包
export default {
common: {
loginSuccess: '用户{username}登录成功,现在是{year}年{month}月{day}日',
},
}
// 使用
t('common.loginSuccess', { username: '张三', year: 2024, month: 5, day: 20 })
特殊字符转义
如果语言包里本身就要用到{0}或者{username}这种占位符格式的字符,要用单引号或者双引号包裹?不对,Vue3 i18n的转义是用{{}}包裹占位符:
// 语言包(要显示“用户{username}是合法的占位符”)
export default {
common: {
placeholderTip: '用户{{username}}是合法的占位符',
},
}
// 使用后显示:用户{username}是合法的占位符
t('common.placeholderTip')
坑3:有复数形式的文案怎么处理?1条消息”“5条消息”
跨境项目里复数形式是必不可少的,中文虽然只有一种,但英文、日文、法语都有好几种复数形式,Vue3 i18n专门提供了tc()函数(translation count的缩写)来处理复数:
// 语言包
// 复数形式的规则:|分隔,|前面是单数,后面是复数;如果有多种复数(比如阿拉伯语有6种),可以用|分隔多次
export default {
common: {
messageCount: '1条消息 | {n}条消息',
},
// 英文复数(1是单数,其他都是复数)
'en-US': {
common: {
messageCount: '1 message | {n} messages',
},
},
// 日语复数(只有一种,但也可以用tc())
'ja-JP': {
common: {
messageCount: '{n}件のメッセージ',
},
},
}
// 使用
tc('common.messageCount', 1) // 中文:1条消息;英文:1 message;日语:1件のメッセージ
tc('common.messageCount', 5) // 中文:5条消息;英文:5 messages;日语:5件のメッセージ
这里要注意:tc()函数的第二个参数是数量,第三个参数是动态参数对象(如果需要的话);Vue3 i18n默认用的是Unicode CLDR的复数规则,不需要自己写复杂的判断逻辑。
坑4:图片或者组件里的文案怎么处理?
图片里的文案如果是静态的,最好直接做不同语言的图片资源,比如bg-welcome-zh-CN.png、bg-welcome-en-US.png,然后通过动态src来切换:
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const welcomeBg = computed(() => import.meta.env.BASE_URL + `images/bg-welcome-${locale.value}.png`)
</script>
<template>
<img :src="welcomeBg" alt="欢迎背景">
</template>
如果图片里的文案是动态的,或者不想做太多图片资源,就用CSS或者Canvas把文字写在图片上,但这种方式加载速度可能慢一点,要根据实际情况选择。
组件里的文案处理方式和普通页面一样,用useI18n解构t函数就行,但要注意如果是全局组件,不要忘记在组件内部引入useI18n。
坑5:日期、时间、数字怎么国际化?
很多人以为Vue3 i18n只能处理文案,其实它还提供了日期、时间、数字的格式化函数,分别是d()(date)、t()不对,是d()、tm()(time?不对,是d()可以同时处理日期和时间,还有单独的n()(number):
数字格式化
// 语言包(可选,也可以直接传格式化选项)
export default {
numberFormats: {
'zh-CN': {
currency: { // 货币格式化
style: 'currency',
currency: 'CNY',
currencyDisplay: 'symbol',
},
percent: { // 百分比格式化
style: 'percent',
minimumFractionDigits: 2,
},
},
'en-US': {
currency: {
style: 'currency',
currency: 'USD',
},
percent: {
style: 'percent',
minimumFractionDigits: 2,
},
},
},
}
// 使用
n(123456.789, 'currency') // 中文:¥123,456.79;英文:$123,456.79
n(0.6789, 'percent') // 中文:67.89%;英文:67.89%
日期时间格式化
// 语言包(可选,也可以直接传格式化选项)
export default {
datetimeFormats: {
'zh-CN': {
short: { // 短日期时间
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
},
long: { // 长日期时间
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
},
},
'en-US': {
short: {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: true,
},
long: {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true,
},
},
},
}
// 使用(可以传Date对象、时间戳、ISO字符串)
d(new Date(), 'short') // 中文:2024/05/20 14:30;英文:05/20/2024, 02:30 PM
d(Date.now(), 'long') // 中文:2024年5月20日 星期一 14:30:45;英文:Monday, May 20, 2024 at 02:30:45 PM
格式化选项完全符合JavaScript的Intl API的规范,要是有特殊的格式化需求,可以查Intl API的文档。
坑6:语言包太多,首屏加载体积太大怎么办?
刚才说过,不要一开始就把所有语言包都导入,应该用Vite或者Webpack的动态导入(import())来按需加载语言包,这样首屏只会加载当前语言的语言包,体积会小很多。
修改一下主配置文件:
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import type { MessageSchema, Locale } from './types'
// 只导入默认回退的中文简体语言包
import zhCN from './locales/zh-CN'
const savedLocale = localStorage.getItem('app-locale') as Locale || 'zh-CN'
// 创建i18n实例(先不把messages填完整,后面动态加载)
export const i18n = createI18n<[MessageSchema], Locale>({
legacy: false,
locale: savedLocale,
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
silentTranslationWarn: true,
silentFallbackWarn: true,
})
// 定义一个动态加载语言包的函数
export const loadLocaleMessages = async (locale: Locale) => {
// 如果语言包已经加载过了,直接返回
if (i18n.global.availableLocales.includes(locale)) return
// 动态导入语言包
const messages = await import(`./locales/${locale}.ts`)
// 把加载到的语言包添加到i18n实例里
i18n.global.setLocaleMessage(locale, messages.default)
}
然后修改main.ts/main.js,在mount之前先加载当前语言的语言包:
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { i18n, loadLocaleMessages } from './i18n'
const app = createApp(App)
app.use(i18n)
// 先加载当前语言的语言包,再mount
const initApp = async () => {
await loadLocaleMessages(i18n.global.locale.value)
app.mount('#app')
}
initApp()
最后修改App.vue里的switchLocale函数,先加载再切换:
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { Locale } from '@/i18n/types'
import { loadLocaleMessages } from '@/i18n'
const { t, locale } = useI18n<[MessageSchema], Locale>()
const switchLocale = async (newLocale: Locale) => {
await loadLocaleMessages(newLocale)
locale.value = newLocale
localStorage.setItem('app-locale', newLocale)
}
</script>
这样首屏加载体积就会小很多,比如12种语言的话,之前首屏要加载所有语言包(可能几MB),现在只加载一种(可能几十KB)。
坑7:有HTML标签的文案怎么处理?点击 这里 登录”
有些文案里会有HTML标签,比如超链接、加粗、换行,Vue3 i18n提供了v-html指令配合t函数来处理,但要注意安全性问题——如果翻译人员或者后端返回的文案里有恶意的HTML标签(比如script),会导致XSS攻击,所以只在可信的语言包来源里用v-html。
// 语言包
export default {
common: {
loginTip: '点击 <a href="/login">这里</a> 登录,或者 <a href="/register">注册</a>',
},
}
// 使用
<div v-html="t('common.loginTip')"></div>
坑8:嵌套的翻译怎么处理?订单状态:已完成”
嵌套的翻译可以用$t()不对,Composition模式里可以直接嵌套t函数,但更推荐的是用语言包的嵌套结构:
// 推荐的嵌套结构
export default {
order: {
status: {
label: '订单状态:',
values: {
completed: '已完成',
pending: '待处理',
cancelled: '已取消',
},
},
},
}
// 使用
const status = 'completed'
t('order.status.label') + t(`order.status.values.${status}`)
或者用动态键值对的方式,刚才的写法已经是动态的了。
坑9:在组合式函数(hooks)里怎么用Vue3 i18n?
和在组件里一样,直接引入useI18n解构就行,但要注意useI18n必须在组件的setup函数或者另一个组合式函数里调用,不能在普通的JavaScript/TypeScript函数里调用,因为它依赖Vue3的Provide/Inject系统:
// src/hooks/useUser.ts
import { useI18n } from 'vue-i18n'
import type { MessageSchema, Locale } from '@/i18n/types'
export const useUser = () => {
const { t } = useI18n<[MessageSchema], Locale>()
const loginTip = computed(() => t('common.loginTip'))
// 其他逻辑
return { loginTip }
}
坑10:生产环境怎么压缩语言包?
Vite和Webpack默认都会压缩JavaScript/TypeScript文件,但语言包里的注释、空格可能不会被完全压缩,还可以用i18n的插件或者第三方工具来进一步压缩,比如@intlify/unplugin-vue-i18n,这个插件可以把语言包编译成更紧凑的格式,还能在构建时预编译翻译函数,提升运行时的性能:
Vite配置:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
VueI18nPlugin({
// 语言包的路径
include: resolve(__dirname, './src/i18n/locales/**'),
// 生产环境压缩语言包
jitCompilation: true,
// 预编译翻译函数
strictMessage: false,
}),
],
})
这个插件还有很多其他功能,比如提取模板里的翻译键值对,生成未翻译的键值对列表,方便翻译人员工作。
最后给新手的几个建议
- 一开始就用Composition模式:不要用Legacy模式,Composition模式更符合Vue3的开发习惯,也更灵活;
- 目录结构要规范:把语言包单独抽出来,方便维护;
- 用TypeScript类型定义:有类型提示爽很多,不容易写错翻译键值对;
- 按需加载语言包:首屏加载体积很重要,影响用户体验;
- 注意XSS攻击:只有在可信的语言包来源里用v-html;
- 多测试不同语言的显示效果:比如英文的文案可能比中文长很多,要注意布局会不会乱。
Vue3 i18n其实并不难,只要掌握了核心变化和常见的坑,很快就能上手,要是还有其他问题,可以在评论区留言,我会尽量解答。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


