我不知道的Vue-i18n用法-使用组件国际化

425 阅读3分钟

在之前使用vue-i18n组件的时候,都是比较简单的使用$t('label')进行翻译而已。这次接到一个需求,国际化会比较复杂,于是乎好好研究了下vue-i18n组件,竟然发现有很多其它好用的方法。

需求描述

[时间]   [设备名称] 已上线
[时间]   [设备名称] 已下线
[时间]   [操作人][xx设备(红色字体)]添加到白名单
[时间]   [操作人][xx设备(红色字体)]移出白名单
[时间]   [操作人]修改白名单中[xx设备(红色字体)][xx属性/xx属性]

这次的需求是做一个类似于系统公告的功能。比较麻烦的地方在于会有非常多种类型的操作,每种操作上也会有对应的变量需要替换。其中尤其需要注意的是

  1. 不同变量使用的颜色可能是不同
  2. 在修改类型的操作中,修改的属性可能是多个,用 / 进行分隔,而且属性名称也需要进行 国际化

最后需要实现的效果大致是这样的。

在接到需求后的第一想法:

每个操作类型定义一个模板,和后端约定好对应的变量名称进行替换。变量需要展示不同颜色,那就用span标签包起来然后指定对应颜色。将这整个国际化消息使用v-html指令插入到DOM上。

// zh-CN.js
export default {
    // 系统公告,这里的key是操作类型
    systemNotice: {
        // 设备上下线
        '305': '{createTime} {deviceName}已{deviceStatus}',
        '306': '{createTime} {deviceName}已{deviceStatus}',
        // 白名单相关
        '601': '{createTime} {operatorName}将{uavName}添加到白名单',
        '602': '{createTime} {operatorName}修改白名单中{uavName}的{updateFields}',
        '603': '{createTime} {operatorName}将{uavName}移出白名单',
    }
}

但是这样会有几个比较明显的问题:

  1. 会存在XSS漏洞风险
  2. 修改的属性要进行二次国际化会比较麻烦
  3. 每个模板的变量个数不一致,实现起来也达不到要求

如果我们能避免XSS漏洞,然后用v-for的方式插入变量就非常好了。这个时候就把目光移向了官方文档,果然发现了更好的解决办法。

Component Interpolation(组件插值)

先简单介绍下这种用法,更详细的信息可以查看👉 官网

假设我们需要翻译下面一段话,

<p>I accept xxx <a href="/term">Terms of Service Agreement</a></p>

原始做法

先不使用组件插值的做法应该是这样的,分两段进行分别翻译。

定义国际化文件

// 国际化文件
const messages = {
  en: {
    term1: 'I Accept',
    term2: 'Terms of Service Agreement'
  }
}
<p>{{ $t('term1') }}<a href="/term">{{ $t('term2') }}</a></p>

组件插值(列表)

使用组件插值的做法是这样的(能避免XSS漏洞):

定义国际化文件

// 国际化文件
const messages = {
  en: {
    term: 'I Accept {0}',
    tos: 'Terms of Service Agreement',
  }
}
<i18n-t keypath="term" tag="P" class="className">
    <a :href="url" target="_blank">{{ $t('tos') }}</a>
</i18n-t>

组件说明

使用组件的方式进行国际化

  • keypath指向的是在国际化文件中的属性名,可以使用多级,例如:system.notice.name
  • tag是组件最后生成的标签,不指定的话默认是Fragments
  • <i18n-t>组件上的其它属性都会添加到最后生成的标签上,例如上面就可以添加自定义样式
  • 子元素会按照顺序依次插入到模板中的变量中,例如:例子中第一个子元素<a>对应模板中的{0}

组件插值(Slot)

组件插值还可以使用命令Slot的方式进行传递,这种方式针对命令参数会更加方便。

// 国际化文件
const messages = {
  en: {
    term: 'I Accept {content1} and {content2}',
    content1: 'xx1',
    content2: 'xx2',
  }
}
<i18n-t keypath="term" tag="P" class="className">
    <template v-slot:content>
        <a :href="url" target="_blank">{{ $t('content1') }}</a>
    </template>
    <!-- 两种方式都可以 -->
    <template #content2>
        <a :href="url" target="_blank">{{ $t('content2') }}</a>
    </template>
</i18n-t>

这样的话对应的命令插槽中的内容会替换到模板的同名变量中去。

实现需求

// 后端返回值示例
{
    "data": [
        {
            "id": 1285249044751450112,
            "operationType": 602,
            "content": {
                "fieldList": [
                    {
                        "name": "operatorName",
                        "value": "00000000001-Pad",
                        "colourType": 1
                    },
                    {
                        "name": "uavWhiteName",
                        "value": "001-3464563",
                        "colourType": 1
                    },
                    {
                        "name": "updateFields",
                        "value": [
                            "usage",
                            "vendor"
                        ],
                        "colourType": 0
                    },
                    {
                        "name": "createTime",
                        "value": "2024-06-25T03:42:42.141535",
                        "colourType": 0
                    }
                ]
            },
        },
        {
            "id": 1285225551280340992,
            "operationType": 603,
            "content": {
                "fieldList": [
                    {
                        "name": "operatorName",
                        "value": "00000000001-Pad",
                        "colourType": 1
                    },
                    {
                        "name": "uavWhiteName",
                        "value": "001-3464563",
                        "colourType": 1
                    },
                    {
                        "name": "createTime",
                        "value": "2024-06-25T02:11:32.256318",
                        "colourType": 0
                    }
                ]
            },
        },
    ],
    "pageTotal": 3,
    "pageIndex": 1,
    "pageSize": 30
}

上面是和后端约定好的返回值结构,在下面使用Slot的方式实现。

  1. <i18n-t>组件内部,每个变量都使用Slot进行插入,变量名是动态的,使用方式是 #[field.name]
  2. 颜色通过span标签上的style具体指定
  3. 针对模板里面的特殊变量,例如修改内容字段和设备上下线等需要二次进行国际化的字段,特殊处理。修改属性可能会存在多个,所以使用v-for进行遍历
  4. 在修改内容字段里,多个属性之间用 / 进行分隔。这个实现我是用m-n12n-item__updateLabel类的伪元素进行实现的,然后用last-child去掉最后一个 /。这样避免在template里面用过多的判断逻辑。
<template>
  <!-- 使用组件的方式  -->
  <i18n-t :keypath="template" tag="p" class="m-n12n-item">
    <!-- 每个变量使用插槽方式替换: https://vue-i18n.intlify.dev/guide/advanced/component.html -->
<template v-for="field in fields" :key="field.name" #[ field.name ] >
      <span class="m-n12n-item__label" :class="field.name" :style="{ color: ColorMap[field.colourType] }">
        <!-- 修改内容字段 -->
<template v-if="field.name === 'updateFields' && field.value">
          <span class="m-n12n-item__updateLabel" v-for="label in field.value" :key="label">{{ t(`systemNotice.updateFields.${label}`) }}</span>
        </template>

        <!-- 设备上下线 -->
<template v-else-if="field.name === 'deviceStatus' && field.value">
          {{ t(`systemNotice.deviceStatus.${field.value}`) }}
        </template>

        <!-- 其它内容 -->
<template v-else>
          {{ field.value }}
        </template>
      </span>
    </template>
  </i18n-t>
</template>
// vue3 JS
const props = defineProps({
  data: {
    type: Object,
    default: () => ({}),
  },
})

// 操作类型,对应国际化模板
const template = computed(() => `systemNotice.${props.data.operationType}`)

// 变量列表
const fields = computed(() => {
  const list = props.data.content.fieldList || []
  return list.map((item) => {
    if (item.name === 'createTime') {
      item.value = utcTime2localtime(item.value)
    }
    return item
  })
})
// SCSS
.m-n12n-item {
    &__updateLabel {
      padding-right: 6px;
      display: inline-block;
      position: relative;
    
      &::after {
        content: '/';
        position: absolute;
        top: 2px;
        right: 1px;
      }
    
      &:last-child {
        padding-right: 0;
    
        &::after {
          content: '';
        }
      }
    }
}

我的实现方法就是这样,如果有更好的实现方法,欢迎交流🙌