【实战抽丝】借助vue plugin写一个i18n的前端国际化组件(包含VUE2/VUE3)

485 阅读5分钟

本期所用到的技术

  • VUE3
  • VUE2
  • VITE

公司最近有一个国际化的需求,调研了市面上的国际化技术,有用现成组件的,比如vue-i18n,react-i18n,也有自己写的。因为我的需求,需要实时请求后端多语言接口,拿到对应语种下的数据,再进行渲染。

所以需要自己单独写一套通用的组件,来让其他人使用,并且要保证最小化影响他人。

考虑到我们部门的技术栈基本都是vue,所以前期先写vue3的版本,一开始也是一筹莫展,不知道该如何下手,后来看到了vue3官网的plugin介绍,一下子就来了灵感。

vue3 plugin链接如下 截图如下

image.png

大体上其实就是三步,这也和其他的vue-i18n等组件比较像

  1. 将原来的原文(要翻译的值),换成一个特定的字符串
  2. 设置一个$translate函数,用于实时替换原文
  3. 将这个第二步的函数全局导入到你的vue3实例里,使得你接下来的组件都可以获取到

站在巨人的肩膀上开始拓展

接下来就是我的组件大体运行流程

image.png

mindmap
      src
         EcloudI18n.vue
          作用是提供一个最外层的组件,并在这个组件内,进行数据的请求和页面刷新
         i18n.js
          提供vue plugin注入的函数,作用是提供一系列全局函数和注册全局组件EcloudI18n.vue
         http
            common.js
             提供请求的函数
            config.js
             封装axios
          
    

结合上面两张图片,其实重点就是在i18n.js这个函数中,下面重点介绍一下

// plugins/i18n.js
import EcloudI18n from './Ecloud-i18n.vue'
import { ref, computed } from 'vue'
import axios from './http/config'
export default {
  install: (app, options) => {
    let $transData = ref({})
    let $languageType = ref(undefined)
    app.provide('$languageType', $languageType)
    // 注入一个全局可用的 $translate() 方法
    let $translate = (key) => {
      let storageTransData
      if ($languageType.value != undefined) {
        storageTransData =
          localStorage.getItem(`$translateStorage_${$languageType.value}`) != null
            ? JSON.parse(localStorage.getItem(`$translateStorage_${$languageType.value}`))
            : null
      }
      const storageSourceTransData =
        localStorage.getItem(`$translateStorage_source`) != null
          ? JSON.parse(localStorage.getItem(`$translateStorage_source`))
          : {}
      if (storageTransData != null && storageTransData[key] != undefined) {
        return storageTransData[key]
      }
      // 这个key如果是当前lang下的value没有,那就看原文有没有,原文没有就展示原来的key
      return $transData.value[key] || storageSourceTransData[key] || key
    }
    app.config.globalProperties.$translate = $translate
    app.provide('$translate', $translate)

    let $translateComputed = (key) => {
      return computed(() => { 
        if ($languageType) { }
        return $translate(key)
      })
    }
    app.provide('$translateComputed', $translateComputed)

    let $changeTransData = (data, languageType) => {
      if (languageType != undefined) {
        localStorage.setItem(`$translateStorage_${languageType}`, JSON.stringify(data))
      }
      $languageType.value = languageType
      $transData.value = data
    }
    app.provide('$changeTransData', $changeTransData)

    // 解耦出来 $translateStorage_source,如果languageType是undefined,则单独赋值
    let $changeSourceTransData = (data, languageType, source) => {
      // 假如切换后的语种,翻译不全,应该要保持已有的原文不被覆盖
      const storageSourceTransData =
        localStorage.getItem(`$translateStorage_source`) != null
          ? JSON.parse(localStorage.getItem(`$translateStorage_source`))
          : {}
      localStorage.setItem(
        `$translateStorage_source`,
        JSON.stringify(Object.assign(storageSourceTransData, data))
      )
      // 如果只是undefined的话,就更新 $transData和$languageType,否则用上面的函数更新,这里就不需要更新了
      if (languageType == undefined) {
        $languageType.value = languageType
        $transData.value = data
        localStorage.setItem(
          `$translateStorage_sourceFull`,
          JSON.stringify(source)
        )
      }
    }
    app.provide('$changeSourceTransData', $changeSourceTransData)

    app.component('EcloudI18n', EcloudI18n)
    
    // 根据options 传入的值来配置请求url和请求route
    if (options) { 
      const { testIp, routeIp } = options
      if (testIp == undefined || routeIp == undefined) {
        throw (new Error(`
        请同时在配置testIp和routeIp,testIp用于本地测试的ip,routeIp为网关转发路由
        参考bcop后台:  
        app.use(i18nPlugin, {
          testIp: 'http://xxxxxx:xxxx',
          routeIp: '/xxxxxx/xxxx'
        })
        `))
      }
      if (process.env.NODE_ENV == 'test' || process.env.NODE_ENV == 'development') {
        axios.defaults.baseURL = testIp + routeIp // 研发环境的
      } else {
        const url = window.location.protocol + '//' + window.location.host
        axios.defaults.baseURL = url + routeIp // 线上环境的
      }
    }
  }
}

即将插入plugin的install函数中,包含下面几个函数和ref

  1. $translate 这个是最关键的函数,用于翻译原文的函数。 它会先判断当前语种下有localStorage里面有没有存储对应的翻译项数据,如果对应key值对应有值的话,会输出来。如果没有的话,就会看原文下的key有没有值,还是没有的话,就会直接输出key。
  2. $transData / languageType,利用vue3的ref来监听他们,当两个数据有变化的时候,会全局刷新页面,来达到页面更新的效果(translate函数中含有两个ref,当两个ref更新时,translate函数会重新刷新)
  3. $changeTransData 这个函数用于更新transData和languageType这个两个ref,并且存储数据到本地
  4. $changeSourceTransData 这个函数用于在一种特殊情况下更新两个ref,即传入的函数的语种是undefined,也就是没有指定语种,这个时候需要渲染key对应的原文
  5. 对options的一些特别处理,主要兼容不同使用方下的调用环境
  6. 最后将上面的函数和Ecloud-i18n组件设置为全局函数和全局组件
  7. $translateComputed这个函数主要是用来兼容一些constant值,比如rules里面设置的message,

Ecloud-i18n组件

<template>
  <div>
    <slot></slot>
  </div>
</template>

<script setup>
import { onMounted, ref, inject, watch, toRefs } from 'vue'
import axios from './http/config' // 线上
import {getData,optionData} from './http/common';
// import axios from 'axios' //本地
const props = defineProps({
  lang: {
    type: String, // 参数类型
    default: undefined //默认值
  },
  serviceCode: {
    type: String,
    default: undefined
  }
  
})
const source = ref({})
const { lang, serviceCode, } = toRefs(props)
let changeTransData = inject('$changeTransData')
let changeSourceTransData = inject('$changeSourceTransData')
let $languageType = inject('$languageType')
// 获取全量数据
async function fetchTransData() {
  const params = {
    serviceCode: serviceCode.value,
    languageType: lang.value
  }
  return (
    getData(params)
      .then((res) => {
        if (res.status == 200) {
          const { data } = res
          const { body } = data
          source.value = body
          init(source.value)
        } else {
          console.error('多语言请求接口报错')
        }
      })
      .catch((e) => {
        console.error(e)
        // window.alert('多语言请求接口报错')
      })
  )
}
// 获取data前的预检
async function optionTransData() {
  const params = {
    serviceCode: serviceCode.value,
    changeId: localStorage.getItem(`i18n_${lang.value}_changeId`)
  }
  return (
    optionData(params)
      .then((res) => {
        if (res.status == 200) {
          console.log(res)
          const { data } = res
          return data
        } else {
          console.error('多语言optionTransData接口报错')
        }
      })
      .catch((e) => {
        console.error(e)
        // window.alert('多语言请求接口报错')
      })
  )
}
onMounted(() => {
  isNeedToFetch()
})
watch(
  () => props.lang,
  (nv) => {
    isNeedToFetch()
  }
)
function handleData(data) {
  let res = {}
  for (let i of data) {
    if (i.acceptLanguage == lang.value) {
      res[i.transKey] = i.objectValue
    }
  }
  return res
}

function handleDataBySource(data) {
  let res = {}
  for (let i of data) {
    res[i.transKey] = i.sourceValue
  }
  return res
}

function handleChangeId(data) {
  if (data != undefined && data[0] != undefined) { 
    localStorage.setItem(`i18n_${lang.value}_changeId`, data[0].changeId)
  }
}
async function isNeedToFetch() {
  $languageType.value = lang.value
  if (localStorage.getItem(`i18n_${lang.value}_changeId`)) {
    const res = await optionTransData()
    if (res != '') {
      // 兼容一下失误
      setTimeout(fetchTransData,100)
    }
  } else { 
    fetchTransData()
  }
}
/**
 * 1. 如果没有传递lang
 *    lang 为 undefined 将当前transKey无论是哪个语种的sourceValue都存到一个localStorage里
 * 2. 传递lang
 *    2.1 lang 有的话 将当前transKey对应下的这个value存到一个localStorage里
 *    2.2 当前transKey的sourceValue都存到一个localStorage
 */
function init(data) {
  handleChangeId(data)
  // lang 为空 要展示的就是原文
  const result = handleDataBySource(data)
  // 处理接口返回的全量数据,lang是undefined那就是全量的
  changeSourceTransData(result, lang.value, data)
  if (lang.value != undefined) {
    {
      // 展示特定语种下的翻译文
      const result = handleData(data)
      changeTransData(result, lang.value)
    }
  }
}
</script>

下面介绍一下里面各个函数和props的作用

  1. props中的lang为传入的当前语种,也就是要进行翻译的目标语种;serviceCode为录入的工程代码名称,后续通过这个serviceCode和lang来请求对应的数据
  2. fetchTransData函数,获取全量数据,并更新页面
  3. init函数用于更新数据并刷新页面
  4. handleChangeId函数,用于记录这个工程在当前语种下的发布版本(每当数据有更新的时候,传入的changeId会有改变),这个changeId用于记录当前工程下的后台多语言数据有没有变化
  5. handleDataBySource函数,用于存储工程的原文数据,并通过inject注入i18n的changeSourceTransData函数来更新transData数据和本地存储的原文数据
  6. handleData函数同上操作,只不过是更新特定语种下的翻译数据。

上面7条是基础,后续为了减少每次服务请求,大量拉取数据对服务的影响,特加入了预检函数,也就是利用上面的changeId,在每次请求前进行判断,数据有无改变,如果没有就不再请求全量数据接口。

  1. isNeedToFetch函数就是在做上面的事
  2. optionTransData则是拿着工程代码和changeId去请求后端接口

http下的两个文件

common.js 文件主要就是封装了全量请求函数和预检函数

不过这里可以给出返回的data的数据格式,用java来简单表示一下,帮助理解代码:

type Res =  {
    // 原文
    sourceValue: string;
    // 目标值
    objectValue: string;
    // 翻译ID
    transKey: string;
    // 需要转换的语音类型
    acceptLanguage: string;
    // 服务编码
    serviceCode: string;
    // 变更ID
    changeId: string;
}
type Data = Array<Res>; // 接口返回的数据格式

config.js 就是给axios加了一些拦截器之类的,不是很刚需了

上面两个文件不是很重要,只是为了后期可能的拓展和代码维护,写出来的。

说了这么多,如何使用

总共就两步

第一,全局注入,在main.js中

import i18nPlugin from 'xxx/xxxx/plugins/src/i18n'
// 当然这里后续可以用npm publish 发布了,然后再下载下来,直接导入,这里就是演示一下,代表引入i18n.js这个文件
。。。。这里省略。。。
const app = createApp(App)
。。。。这里省略。。。
//导入
app.use(i18nPlugin, {
  testIp: 'http://xxx.xxx.xxx.xxx:8080',
  routeIp: '/点赞点赞点点赞/大佬大佬大大佬'
})
app.mount('#app')

第二步 vue最外层组件内使用,在app.vue中

<EcloudI18n :lang="en-us" serviceCode="serviceCode-123">
    **这里是你原来的内容,下面是演示如何利用$translate替换原文**
    <h1>{{ $translate('test1') }}</h1>
    <h1>{{ $translate('test2') }}</h1>
    <h1>{{ $translate('test3') }}</h1>
</EcloudI18n>

这样当你进入页面或者后期更新语种lang的时候页面会更新所有被$translate包裹的key