vue国际化实践

迈向国际化之路

Posted by Li Yucang on October 21, 2019

vue国际化实践

我们在做国际化时会面临的问题:

  1. 语言翻译
  • 静态文案翻译(前端静态模板文案)

  • 动态文案翻译(server端下发的动态数据)

  1. 样式
  • 不同语言文案长度不一样造成的样式错乱

  • 图片的替换

  1. map表维护

  2. 第三方服务

  • SDK
  1. 本地化
  • 货币单位

  • 货币汇率

  • 时间格式

  1. 打包方案
  • 运行时

  • 编译后

vue-i18n是一个针对于vue的国际化插件,本文基于vue-i18n实现vue项目运行时的国际化,并对其中常见的疼点给出相应的解决办法。

常规方案

新项目

我们根据vue-i18n的官方文档,将国际化的插件运用于我们的项目:

// 引入插件和语言包
import VueI18n from 'vue-i18n'
import zh from '@/i18n/langs/zh'
import en from '@/i18n/langs/en'
Vue.use(VueI18n)

// 从本地存储中取,如果没有默认为中文,
// 这样可以解决切换语言后,没记住选择的语言,刷新页面后还是默认的语言
function getLocal() {
  const cookies = document.cookie ? document.cookie.split('; ') : [];
  let locale = cookies.find((cookie) => decodeURIComponent(cookie).split('=')[0] === 'locale') || 'locale=zh';

  locale = locale.split('=');
  return locale[1];
}

const locale = getLocal();

//实例化vue-i18n
const i18n = new VueI18n({
  locale,
  messages: {
    'zh': zh, // 中文语言包
    'en': en // 英文语言包
  }
})

// 将i18n实例挂载到vue上
new Vue({
  el: '#app',
  i18n,
  router,
  store,
  template: '<App/>',
  components: { App }
})

接着我们需要创建中、英文翻译文件,这里一般会按照模块或文件路径将翻译文件进行模块化:

//zh.js
export default {
  nav: {
    home: '首页',
    monitor: '监控',
    analyze: '分析',
    alarm: '报警',
    maintenance: '运维',
    config: '配置',
    device: '设备',
    scada: '画面'
  },
  confirm: {
    ok: '确认',
    cancel: '取消',
    content: '你确认要退出系统吗?',
    logout: '退 出'
  },
}
//en.js
export default {
  nav: {
    home: 'Home', //首页
    monitor: 'Monitor', //监控
    analyze: 'Analyze', //分析
    alarm: 'Alarm', // 报警
    maintenance: 'Maintenance', //运维
    config: 'Config', //配置
    device: 'Device', //设备
    scada: 'Scada' //画面
  },
  confirm: {
    ok: 'Logout', //退出
    cancel: 'Cancel', //取消
    content: 'Are you sure you want to quit the system?', //你确认要退出系统吗
    logout: 'Logout' //
  }
}

然后在项目中使用:

<span v-text="$t('nav.home')"></span>
or
<span></span>

老项目

对于大多数项目,其实并不是一开始就会做国际化,往往是在项目迭代一段时间后才会有国际化的需求。此时,我们需要将项目中已经存在的中文进行替换:

1,先使用脚本来替换常规写法

2,对于非常规写法,则需要我们一个一个文件进行查漏补缺

至于到底怎么去替换,需要结合项目的实际情况来具体分析,这里不再讨论。给大家看一个简单的例子,里面的$spt()函数下文会讲到:

存在的问题

一切看起来都那么美好,然而在我们后续的维护和开发中发现这种方式存在这些问题:

1,在新增页面的时候,需要将页面中的展示文字添加到对应的中文、英文翻译文件中。现在只有两种语言,如果之后需要支持的语言增多,则需要同步增加到所有文件,这样不仅增加工作量且容易造成遗漏。

2,使用的映射key不直观,在写代码时不能直接知道其含义,代码易读性低下。

3,映射key值不好取,取名一直是令人头疼的事,一般使用中文对应的英文或者直接取中文的拼音,然而不管那种都需要消耗开发的时间。

4,中英文在于不同的文件,每次修改或查找都不方便,且翻译人员翻译时还需要对照两个文件。

下面我们就来一个个解决它们。

优化方案

中、英文翻译集中

首先,我们来看问题4,对于翻译文件,我们完全可以把所有翻译文件集中在一起。为了使翻译文件更加简洁清晰我们使用yml文件来储存翻译的映射关系,类似下面这样:

// translationFile.yml

nav.home
  zh: 首页
  en: home
nav.monitor
  zh: 监控
  en: monitor
...

当然这样做了以后需要进行相应的适配:

const translationData = require('json-loader!yaml-loader!./translationFile.yml');

function getYmlData() {
  const enObj = {};
  const cnObj = {};

  Object.entries(translationData).forEach(([key, val]) => {
    const enStr = val.en;
    const cnStr = val.zh;
    enObj[key] = enStr || '';
    cnObj[key] = cnStr || '';
  });

  return {
    enObj,
    cnObj
  };
}

const { enObj, cnObj } = getYmlData();

const i18n = new VueI18n({
  locale,
  messages: {
    'zh': cnObj, // 中文语言包
    'en': enObj // 英文语言包
  }
})

这样使用:

<span v-text="$t('nav.home')"></span>
or
<span></span>

中文做key

接着来看问题2和问题3,都是因为需要用字母做key才导致的,那我们是不是可以考虑用中文来做key:

// translationFile.yml

nav.首页
  zh-CN: 首页
  en: home
nav.监控
  zh-CN: 监控
  en: monitor
...

这样使用:

<span v-text="$t('nav.首页')"></span>
or
<span></span>

是不是可读性高了点,但是前缀nav.总感觉有点瑕疵,可不可以去掉呢?

如果对于同一个中文,给出的翻译是一致的,那就可以去除前缀的限制。于是我们查阅了项目中对于同一个中文给出的翻译,发现对于我们的项目,同一个中文的英文翻译基本是相同的,偶尔不同也是大小写的区别是同义不同词的翻译。

所以我们决定以中文作为key,但由于中文中可能会存在标点符号,而标点符号在yml文件中可能会引起格式不正确,这里我们对中文做一次hash再当作key:

// translationFile.yml

f203d577d14268cd853ce37418cb14f8e268c2bce88220ae131c761e2255954a:
  zh-CN: "首页"
  en: "home"
fac2a67ad87807c4112af2b9201ef929d8c1c80214a6ad5198423398369371a4:
  zh-CN: "监控"
  en: "monitor"

同样需要进行相应的适配:

const SHA256 = require('crypto-js/sha256');

const i18n = new VueI18n({
  locale,
  messages: {
    'zh': cnObj, // 中文语言包
    'en': enObj // 英文语言包
  }
})

// 在原来函数的基础上做一层封装,如果没有翻译默认返回中文
Vue.prototype.$spt = i18n.$spt = (str, ...arg) => {
  const val = i18n.t(SHA256(str), ...arg);
  return val || str;
};

这样使用,是不是代码可读性提高了很多:

<span v-text="$spt('首页')"></span>
or
<span></span>

最后,在需要使用同一个中文对应不同的翻译时,可以为其中某个中文添加额外信息(这里需要在返回中文翻译时去除相关的额外信息):

<span></span>
or
<span v-text="$spt('首页(导航栏)')"></span>

生成的翻译文件:

// translationFile.yml

f203d577d14268cd853ce37418cb14f8e268c2bce88220ae131c761e2255954a:
  zh-CN: "首页"
  en: "home"
fac2a67ad87807c4112af2b9201ef929d8c1c80214a6ad5198423398369371a4:
  zh-CN: "首页(导航栏)"
  en: "Home"

自动生成翻译文件

我们回过头再来看第问题1,我们每次增加了新的内容都需要手动添加到翻译文件吗?

不,我们拒绝这份重复而繁重的工作。

只需约定在添加展示文案时使用$spt()来包裹中文,我们就可以轻易的找出项目中所有需要翻译的中文,脚本代码如下:

const fs = require('fs');
const path = require('path');
const SHA256 = require('crypto-js/sha256');
const YAML = require('yamljs');

const ymlData1 = YAML.parse(fs.readFileSync(path.resolve('./system/i18n/translationFile.yml'), 'utf-8'));

const exitObj = {}; // 用来保存当前文件中存在的翻译key

// 获取对应的翻译文字
function getValue(hash) {
  const obj1 = ymlData1[hash] || {};

  return {
    enStr: obj1.en || ''
  };
}

function transferFile(filePath) {
  // 类似:yi-components.yiParamEditor.yiParamEditor
  const fileKey = filePath.replace(/^.+\/system\//, '').replace(/\.vue/g, '').replace(/\//g, '.');

  const file = fs.readFileSync(filePath, 'utf-8');

  // 匹配除去'和"的其余所有标点符号
  const reg = /\$spt\((['"])([\u4e00-\u9fa5a-zA-Z0-9\s\,\.\/\;\[\]\\\`\-\=\<\>\?\:\{\}\|\~\!\@\#\$\%\^\&\*\(\)\_\+\,\。\/\;\‘\【\】\、\·\-\=\《\》\?\:\“\”\「\」\|\~\!\@\#\¥\%\…\&\*\(\)\—\+]+)['"]/g;

  let result = reg.exec(file);

  while (result) {
    const zhCnStr = result[2];
    const hashStr = SHA256(zhCnStr);
    const { enStr } = getValue(hashStr);

    if (!exitObj[hashStr]) {
      const obj = {
        enStr,
        zhCnStr
      };
      exitObj[hashStr] = obj;
    } else if (!exitObj[hashStr].enStr) {
      exitObj[hashStr].enStr = enStr;
    }

    result = reg.exec(file);
  }
}

function readDirSync(dirPath) {
  const pa = fs.readdirSync(dirPath);
  pa.forEach((ele) => {
    const currentPath = path.join(dirPath, ele);
    const info = fs.statSync(currentPath);

    // 文件夹则继续递归
    if (info.isDirectory()) {
      readDirSync(currentPath);
    } else if ((ele.endsWith('.vue') || ele.endsWith('.js')) && (ele !== 'translation.js')) {
      // .vue、.js文件
      transferFile(currentPath);
    }
  });
}

function geneFile() {
  // system目录下进行递归遍历
  readDirSync(path.resolve('./system'));

  let targetStr = '';
  Object.entries(exitObj).forEach(([key, val]) => {
    const { enStr, zhCnStr } = val;
    targetStr += `${key}:\n  zh-CN: "${zhCnStr}"\n  en: "${enStr}"\n`;
  });

  fs.writeFileSync('./system/i18n/translationFile.yml', targetStr, 'utf-8');
}

geneFile();

然后将执行语句写入package.json中:

"scripts": {
  "translation": "node system/i18n/translation.js"
}

团队规范

基于我们的方案,我们可以确立如下规则:

1,中文需要使用$spt( '中文')函数包裹起来,js文件和vue文件中都需要

2,$spt()函数包裹的中文不能包含英文的单引号、双引号即:’和”,其余符号都可以使用

3,$spt()函数不得换行,若下所示:

$spt(
  '中文'  // 不能换行
)

需要改为

$spt('中文')

这是由于我们的脚本没有匹配\n,不做这个匹配是因为希望统一格式。

4,$spt()函数使用单引号包裹中文:$spt( '中文')

5,每次添加或修改中文后需要执行:npm run translation 来生成新的翻译文件

6,如果需要使用同一个中文对应不同的英文翻译,可以在中文中加上相应的解释:

$spt( '总览')

$spt( '总览(导航栏)')

后续问题

在后续开发中,我们发现如果文字中包含 \ ,使用我们前面的脚本会出现问题。

首先,我们查询了 yml 文件中值的写法有几种方式:

1. k: v:字面直接来写;
字符串默认不用加上单引号或者双引号;

2. "":双引号;不会转义字符串里面的特殊字符;特殊字符会作为本身想表示的意思
  name: "zhangsan \n lisi":输出;zhangsan 换行 lisi

2. '':单引号;会转义特殊字符,特殊字符最终只是一个普通的字符串数据
  name: 'zhangsan \n lisi':输出;zhangsan \n lisi

接着,我们来复现这个问题 ,比如:

// 需要使用转义符来转义'\'
<div></div>

... 

// 获取到的参数为:'如果使用“@”符号,请使用转译符号,如“\@”'
Vue.prototype.$spt = i18n.$spt = (str, ...arg) => {
  const val = i18n.t(SHA256(str), ...arg); // 注意,这里是对:'如果使用“@”符号,请使用转译符号,如“\@”' 进行hash计算
  return val || str;
};

...

const zhCnStr = result[2]; // '如果使用“@”符号,请使用转译符号,如“\\@”'
const hashStr = SHA256(zhCnStr); // 注意,这里是对:'如果使用“@”符号,请使用转译符号,如“\\@”' 进行hash计算

...

34869c6ac5bf72cef373c2f818b43b5b93725d001015d379bfab485c5e468611:
  zh-CN: "如果使用“@”符号,请使用转译符号,如“\\@”"
  en: ""

我们发现翻译文件中生成 hash 的字符串和翻译函数中进行 hash 计算的字符串是不同的,导致翻译失败。所以我们进行如下修改:

const zhCnStr = result[2]; // '如果使用“@”符号,请使用转译符号,如“\\@”'
const hashStr = SHA256(zhCnStr.replace(/\\\\/g, '\\')); // 现在正确了,是使用:'如果使用“@”符号,请使用转译符号,如“\@”' 进行hash计算

ok,现在页面能正确显示了:

如果使用“@”符号,请使用转译符号,如“\@”