Code前端首页关于Code前端联系我们

或者用yarn/pnpm

terry 5小时前 阅读数 68 #Vue
文章标签 yarnpnpm

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'

第三步:写主配置文件

这里要注意两个新手常踩的坑:

  1. Composition模式要显式开启:默认是Legacy模式,必须把legacy设为false
  2. 要使用Vue3的响应式API:比如要切换语言,必须把当前语言设为ref或者reactive变量;
  3. 不要一开始就把所有语言包都导入:如果语言包很多,会增大首屏加载体积,后面会讲按需加载的做法,先按基础配置来。
// 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>

解决办法有三个:

  1. 直接在模板里用t函数:最简单,也是最推荐的,Vue3的响应式系统会自动追踪t函数里的locale变化;
  2. 用computed计算属性:如果必须在脚本里用到翻译后的文案,用computed包裹;
  3. 用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.pngbg-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,
    }),
  ],
})

这个插件还有很多其他功能,比如提取模板里的翻译键值对,生成未翻译的键值对列表,方便翻译人员工作。

最后给新手的几个建议

  1. 一开始就用Composition模式:不要用Legacy模式,Composition模式更符合Vue3的开发习惯,也更灵活;
  2. 目录结构要规范:把语言包单独抽出来,方便维护;
  3. 用TypeScript类型定义:有类型提示爽很多,不容易写错翻译键值对;
  4. 按需加载语言包:首屏加载体积很重要,影响用户体验;
  5. 注意XSS攻击:只有在可信的语言包来源里用v-html;
  6. 多测试不同语言的显示效果:比如英文的文案可能比中文长很多,要注意布局会不会乱。

Vue3 i18n其实并不难,只要掌握了核心变化和常见的坑,很快就能上手,要是还有其他问题,可以在评论区留言,我会尽量解答。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门