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

Vue3项目想做丝滑无刷新的国际化?怎么避开踩坑实现多场景适配?

terry 4小时前 阅读数 61 #Vue

最近接了个面向东南亚小语种市场的电商后台Vue3项目,老板直接提了两个硬要求:一是切换语言页面不能白屏甚至连加载提示都别太明显;二是要覆盖后台管理里的“嵌套路由面包屑、动态接口返回文案、图片替换、富文本多语言、用户浏览器默认语言优先、还有自定义语言切换缓存策略”这些场景,一开始我想直接套之前用过的vue-i18n v8?但查了下适配性,v9才是专门给Vue3写的Composition API版本,赶紧重新做了方案和踩坑测试,最终上线效果老板很满意,切换延迟基本控制在10ms以内,所有场景都完美适配,今天就把从调研、框架选型、到具体实现、踩坑修复的完整经验整理出来,全是干货,新手也能一步步跟着做。

先别着急写代码:框架和前置配置要选对

很多人一开始就直接npm install vue-i18n next,但选对了框架还要看前置依赖的版本,不然连基础启动都会报错。

框架选哪两个?别只盯着vue-i18n

目前Vue3生态里做国际化的主流方案有两个: 第一个是Vue官方推荐的Vue I18n v9,这个不用说了,和Vue3的响应式系统、Composition API/Options API双模式都完美兼容,功能最全,覆盖了我们能想到的99%场景; 第二个是轻量级的vue-i18n-lite或者intlify-messageformat,但这两个更适合小型项目,像电商后台这种多场景、有复杂翻译嵌套的项目,还是Vue I18n v9靠谱。

前置依赖要检查哪些?

Vue的版本,Vue 3.2.0+对响应式的优化更明显,配合Vue I18n v9.2.0+的useI18n composable里的locale响应式变量,切换语言的性能会更好; 其次是TypeScript的版本,如果项目用了TS,建议选4.5.0+,因为Vue I18n v9提供了完整的类型定义,低版本TS可能会有类型不兼容的问题; 还有打包工具,不管是Vite还是Webpack,都要注意配置好语言资源的按需加载,不然会把所有语言包都打包进主包,导致首屏加载变慢——这可是老板很在意的性能点哦。

丝滑无刷新的核心:怎么让语言切换真正“无感”?

很多教程里写的切换语言,都是直接改i18n.global.locale或者组件里的locale变量,但这样如果配合了懒加载语言包,就会出现页面先显示默认占位符,等语言包加载完再替换的“闪烁”问题,体验特别差,要解决这个,得从“预加载常用语言包”和“懒加载语言包时做防抖占位处理”两个方向入手。

先做预加载:把用户大概率用的语言先加载好

用户进入网站的时候,我们可以先获取他的浏览器默认语言,或者用户之前设置过的缓存语言,如果这两个语言属于项目定义的“常用语言库”(比如电商面向东南亚,常用的就是中文简体、中文繁体、英文、泰语、马来语),就直接在App.vue挂载前预加载好,这样切换的时候完全不需要等待网络请求。

举个Vite项目的预加载例子: 首先在src目录下建一个locales文件夹,里面分常用语言的静态js/ts文件和小语种的懒加载文件夹,常用语言比如zh-CN.ts,直接导出翻译对象;小语种比如id-ID.ts,放在locales/async/下面,后面用动态import加载。

然后在main.ts里,先初始化i18n实例,但不要直接把所有常用语言都塞进去,而是先获取用户的目标语言,预加载这个语言,再挂载i18n到Vue实例上:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createI18n, useI18n } from 'vue-i18n'
// 常用语言静态导入
import zhCN from './locales/zh-CN'
import enUS from './locales/en-US'
const STATIC_LOCALES = ['zh-CN', 'en-US']
const DEFAULT_LOCALE = 'zh-CN'
// 获取用户目标语言
function getUserLocale() {
  // 先看localStorage有没有缓存
  const cached = localStorage.getItem('user-locale')
  if (cached && [...STATIC_LOCALES, 'id-ID', 'th-TH'].includes(cached)) {
    return cached
  }
  // 再看浏览器默认语言,取前两位匹配
  const browserLang = navigator.language.split('-')[0]
  const matchLang = {
    zh: 'zh-CN',
    en: 'en-US',
    th: 'th-TH',
    id: 'id-ID'
  }[browserLang]
  return matchLang || DEFAULT_LOCALE
}
const targetLocale = getUserLocale()
// 初始化i18n,先只放默认语言和预加载的用户目标语言
const i18n = createI18n({
  legacy: false, // 必须关,用Composition API
  locale: targetLocale,
  fallbackLocale: DEFAULT_LOCALE,
  messages: {
    [DEFAULT_LOCALE]: zhCN,
    // 如果目标语言是静态常用的,直接加进去
    ...(STATIC_LOCALES.includes(targetLocale) ? { [targetLocale]: require(`./locales/${targetLocale}`).default } : {})
  }
})
// 预加载用户目标语言(如果是懒加载的小语种)
async function preloadLocale() {
  if (!STATIC_LOCALES.includes(targetLocale)) {
    const messages = await import(`./locales/async/${targetLocale}`)
    i18n.global.setLocaleMessage(targetLocale, messages.default)
  }
}
// 先预加载再挂载
preloadLocale().then(() => {
  const app = createApp(App)
  app.use(i18n)
  app.mount('#app')
})

再处理懒加载切换:防抖加骨架屏/透明占位

如果用户切换到了没有预加载的小语种,比如印尼语,这时候就需要用动态import加载,但加载过程中要避免页面“留白”,怎么处理呢? 第一个是加个全局的语言切换加载状态,配合Element Plus或者Ant Design Vue的Loading插件,但老板说不要太明显的Loading,所以我用了“透明占位+防抖验证”:透明占位就是翻译之前先用一段和目标翻译长度差不多的空格或者灰色的“……”代替(长度可以根据默认语言的对应文案预估),防抖验证就是如果用户在1秒内多次切换语言,只加载最后一次选择的语言包,避免网络请求浪费。

举个切换语言组件的例子:

<!-- LanguageSwitcher.vue -->
<template>
  <el-select v-model="currentLocale" @change="handleLocaleChange" size="small">
    <el-option label="简体中文" value="zh-CN" />
    <el-option label="English" value="en-US" />
    <el-option label="ไทย" value="th-TH" />
    <el-option label="Bahasa Indonesia" value="id-ID" />
  </el-select>
</template>
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
const { locale, setLocaleMessage } = useI18n()
const currentLocale = ref(locale.value)
// 防抖函数
let timer: NodeJS.Timeout | null = null
// 长度预估函数,简单版,就是默认语言文案长度的0.8-1.2倍
function getPlaceholderLength(key: string) {
  // 这里可以存一个默认语言的key到长度的映射表,或者动态获取默认语言的文案长度
  const defaultMsg = '默认文案' // 简化示例,实际要从fallbackLocale里取
  return Math.floor(defaultMsg.length * (0.8 + Math.random() * 0.4))
}
const handleLocaleChange = async (newLocale: string) => {
  if (timer) clearTimeout(timer)
  timer = setTimeout(async () => {
    // 如果语言包已经加载过,直接切换
    if (i18n.global.availableLocales.includes(newLocale)) {
      locale.value = newLocale
      localStorage.setItem('user-locale', newLocale)
      return
    }
    // 否则开始动态加载
    try {
      // 这里可以加个全局的小Loading,比如右上角的圆点
      const messages = await import(`../locales/async/${newLocale}`)
      setLocaleMessage(newLocale, messages.default)
      locale.value = newLocale
      localStorage.setItem('user-locale', newLocale)
      ElMessage.success('语言切换成功')
    } catch (error) {
      ElMessage.error('语言切换失败,请稍后重试')
      currentLocale.value = locale.value
    }
  }, 500) // 500ms防抖,避免手滑多次点击
}
</script>

覆盖后台全场景:这5个常见需求的解决方案一定要记牢

预加载和懒加载只是基础,电商后台的国际化需求往往更复杂,下面这5个是我踩过坑最多的,给大家整理了现成的解决方案。

场景1:嵌套路由面包屑的动态翻译

嵌套路由面包屑的翻译不能写死在组件里,因为面包屑的文本是从路由的meta里取的,这时候可以用Vue I18n的$t或者useI18n().t在meta里写函数吗?不行,因为路由的meta在Vue实例挂载前就解析了,这时候i18n还没初始化。 解决方案是:在meta里存翻译的key,然后在面包屑组件里用watchEffect监听locale的变化,动态调用useI18n().t翻译meta里的key。

举个路由配置和面包屑组件的例子:

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
  {
    path: '/',
    component: () => import('../layout/index.vue'),
    meta: { title: 'nav.home' },
    children: [
      {
        path: 'products',
        component: () => import('../views/products/index.vue'),
        meta: { title: 'nav.products' },
        children: [
          {
            path: 'add',
            component: () => import('../views/products/add.vue'),
            meta: { title: 'nav.products.add' }
          }
        ]
      }
    ]
  }
]
<!-- Breadcrumb.vue -->
<template>
  <el-breadcrumb separator="/">
    <el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index">
      {{ item }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
const route = useRoute()
const { t } = useI18n()
const breadcrumbs = ref<string[]>([])
watchEffect(() => {
  // 获取当前路由的matched数组,过滤掉没有meta.title的路由
  const matched = route.matched.filter(item => item.meta?.title)
  // 翻译每个meta.title
  breadcrumbs.value = matched.map(item => t(item.meta.title as string))
})
</script>

场景2:动态接口返回文案的翻译

有些后端接口返回的不是具体的文案,而是翻译的key,比如返回{ code: 200, message: 'api.success.addProduct' },这时候就需要前端在拿到接口响应后,先判断返回的message是不是翻译key,如果是就翻译,不是就直接显示。 解决方案是:封装一个全局的axios拦截器,在响应拦截器里统一处理message的翻译,同时在请求拦截器里把当前的locale传给后端,方便后端返回对应语言的动态数据(比如商品名称、商品描述这些没法存在前端翻译包的内容)。

场景3:图片替换的国际化

不同语言的按钮图片、Banner图片可能不一样,比如中文Banner用红色,英文Banner用蓝色,这时候怎么处理? 解决方案是:在图片的文件名里加上locale的后缀,比如banner_zh-CN.png、banner_en-US.png,然后在组件里用computed属性根据当前的locale动态拼接图片路径,注意要用Vite的new URL()或者Webpack的require()来处理图片路径,不然打包后图片路径会出错。

场景4:富文本多语言的翻译

富文本多语言分两种情况:一种是富文本的内容是静态的,比如用户协议、隐私政策;另一种是富文本的内容是动态的,比如后端返回的商品详情。 静态富文本的解决方案是:在locales文件夹里建一个对应的文件夹,比如locales/richText/,里面放每个语言的用户协议md或者html文件,然后用动态import加载,配合v-html渲染; 动态富文本的解决方案是:让后端返回对应语言的富文本内容,在请求拦截器里把当前的locale传给后端,这和场景2是一样的。

场景5:自定义语言切换缓存策略

默认的缓存策略是存localStorage,但如果是电商后台,可能需要存sessionStorage(关闭浏览器就清除),或者存到后端的用户信息里(用户登录后不管在哪台电脑登录都能看到自己设置的语言)。 解决方案是:封装一个getUserLocale和setUserLocale的函数,根据项目需求灵活修改存储方式。

性能优化:别让语言包拖垮首屏加载

预加载常用语言包已经是性能优化的一部分了,但还有两个细节可以优化: 第一个是语言包的按需分割,不要把所有小语种的语言包都打包成一个chunk,而是每个小语种单独打包成一个chunk,这样用户切换到印尼语的时候,只加载印尼语的chunk,不会加载泰语的; Vite的话,直接用动态import的魔法注释就可以了:import(/* webpackChunkName: "locale-[request]" */../locales/async/${newLocale},不过Vite不需要魔法注释也会自动分割,但加了更清晰; 第二个是语言包的压缩,用JSONC或者JSON5代替JSON,去掉注释和空格,或者在打包的时候用插件比如vite-plugin-i18n-compress来压缩语言包。

Vue3做丝滑无刷新的国际化,核心就是选对Vue I18n v9框架,做好常用语言包的预加载和小语种的懒加载加防抖占位,然后根据具体场景灵活适配,最后再做一些性能优化,这一套流程下来,不仅能满足老板的所有要求,还能给用户带来很好的体验,如果大家在实现过程中遇到什么问题,欢迎在评论区留言讨论哦!

版权声明

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

热门