# 前端多语言方案实现总结

# 「方案1」回顾

对于国际化的实现现在已经有很多成熟的方案了,不论是Vue、React或者其他框架,甚至nodejs。

我们公司之前已经有一套在react项目中的多语言方案:使用i18n-scanner实现国际化的方案,我们暂且将其称为「方案1」,简单回顾下(部分代码):

/** i18n */
interface ILangConfig {
    [key: string]: {
        cn?: string;
        tw?: string;
        en?: string;
    };
}

type LangReturn<T> = { [P in keyof T]: string };

type DefaultLang = typeof zh_CN;

interface I18n extends DefaultLang {
    <T extends ILangConfig>(config: T): LangReturn<T>;
    language: string;
    printf: typeof printf;
    __: typeof __;
}

i18n.__ = window.__ = __;

// @ts-ignore
if (pkg.locals) {
    globalTranslation = require(`locals/${language}.json`);
}

/**
 * @description
 * 语言包匹配
 */
export function __(text: string): string {
    return globalTranslation[text] || text;
}

通过接口可以看出该i18n是一个函数,该函数可以接收一个config(语言包)参数然后返回一个带有对应当前语言的翻译文案;该函数有一个language属性代表当前语言;__则是收集与翻译字符串文案的关键;

/**
 * @description
 * 扫描源代码文件,匹配需要翻译的文案,并输出excel文件待翻译
 */
function scanner() {
    const i18nParser = new Parser({
        lngs: pkg.locals,
        nsSeparator: false,
        keySeparator: false,
        pluralSeparator: false,
        contextSeparator: false
    });

    fs.ensureDirSync(path.join(paths.locals, 'xlsx'));

    glob.sync(paths.appSrc + '/**/*.{js,jsx,ts,tsx}').forEach(file => {
        const content = fs.readFileSync(file);

        i18nParser.parseFuncFromString(content, { list: ['__', 'i18n.__', 'window.__'] }, key => {
            if (key) {
                i18nParser.set(key, key);
            }
        });
    });

    const i18nJson = i18nParser.get();

    Object.keys(i18nJson).forEach(key => {
        const jsonDestination = path.join(paths.locals, key + '.json');
        const excelDestination = path.join(paths.locals, 'xlsx', key + '.xlsx');

        const translation = i18nJson[key].translation;
        const existConfig = fs.existsSync(jsonDestination) ? JSON.parse(fs.readFileSync(jsonDestination)) : {};
        const newConfig = lodash.pickBy(existConfig, (value, key) => key in translation);

        lodash.each(translation, (value, key) => {
            if (!(key in newConfig)) {
                newConfig[key] = value;
            }
        });

        fs.outputFile(path.join(paths.locals, key + '.json'), JSON.stringify(newConfig, '\n', 2));

        convertJson2Excel(newConfig, key, path.join(excelDestination));

        spinner.succeed('输出 ' + chalk.bold(chalk.green(key)) + ' 到 ' + chalk.cyan(excelDestination));
    });

    console.log();
    spinner.warn(chalk.yellow('你可以将生成的excel文件进行翻译后,放回原处。然后运行:'));
    console.log(chalk.green('   npm run i18n-read'));
}

上面采用i18next-scanner (opens new window)实现,上面的函数实现了扫描指定后缀名文件中使用__函数包裹的文案(16行-19行)。 最终导出了json文件和Excel。当然还有一个read函数可以读取Excel然后写入json文件中然后就完成了翻译。

  1. 需要手动提取文案(手动使用__函数包裹要翻译的文案)。
  2. 使用i18next-scanner扫描对应文件提取汇总文案。
  3. 切换语言需要强刷页面。

# 「方案1」与「方案2」对比

我们本次要做一个Vue项目的国际化,我们将其称为「方案2」吧。vue国际化基本都采用了Vue-i18n作为基本的工具,调研中发现,很多都是用一个有意义的英文键名还有一定的层次结构去组织文案。可以和上面react的「方案1」对比一下:

# 方案1」语言包

// zh
{
  "标题": "标题"
}
// en
{
  "标题": "title"
}

// 使用
<div>{{ __('标题') }}</div>

# 「方案2」语言包

// zh
{
	"home": {
  	"title": "标题"
  }
}
// en
{
	"home": {
  	"title": "title"
  }
}

// 使用
<div>{{ $t('home.title') }}</div>

# 痛点分析

  • 提取文案
    • 方案1:使用nodejs的能力自动一键提取文案,十分方便,且维护的json语言包是一层键值对对应,还直接使用中文作为key方便维护;
    • 方案2:需要手动提取文案,且json语言包结构是多层嵌套对象,并使用英文别名来取对应文案,不方便使用和维护。
  • 无刷新切换
    • 方案1:需要强刷页面才能切换语言包,这是因为从一开始对于字面量中的文案就执行了翻译过程,之后语言切换,但翻译函数不会再次执行;
    • 方案2:可无刷新翻译(依赖vue响应式数据的能力)。

# 「方案3」vue-i18n + i18next-scanner

现在我们想使用「方案1」提供的便捷但又想使用「方案2」带来的无刷新体验。那就需要综合两者实行方案3。

「方案3」所采取的方式是:与「方案2」一样,使用vun-i18n提供无刷新切换语言包能力,但不使用其维护“多层嵌套语言对象”的方式,在这点上采取方案1中使用的i18next-scanner扫描提取键值对的方式维护语言包。

# 第一步:安装vue-i18n插件

import Vue from 'vue'
import VueI18n from 'vue-i18n'

Vue.use(VueI18n)

# 第二步:实例化vuei8n

import EN_US from './lang/en_US.json'
import ZH_CN from './lang/zh_CN.json'

const esop = getTokenObj()
// 读取用户本地保存的语言配置
const locale = esop ? esop.lang : 'zh-CN'
const i18n = new VueI18n({
  locale,
  messages: {
    'en-US': EN_US,
    'zh-CN': ZH_CN
  }
})

# 第三步:__ + _scan

  • 1、以别名__替代原$t方法,因为$符号无法配置在parseFuncFromString的检索关键字中
  • 2、将 翻译方法t 绑定到 全局Vue原型上:
    • 2.1 绑定到全局后,对于一些js文件中的字面量文案,就可以直接调用__执行翻译过程,并将字面量对象写成函数返回的形式,引入到组件中后放到computed中访问(也可以使用下面的3提供的_scan方案处理);
    • 2.2 绑定到Vue原型后,在组件中的template就可以直接拿到__方法来使用了(因为本身template是无法直接拿到window下的方法的,只能先在js部分引入并在data中注册后使用);
  • 3、提供一个_scan方法用来扫描字面量文案,「方案1」中就是因为对于字面量文案在第一次就执行了翻译过程,导致切换语言包后不能改变,我们有了_scan后就能将扫描和翻译方法区分开,在字面量中只用_scan扫描,将字面量对象引入组件后,再使用__执行翻译过程(当然对字面量文案的处理,使用2.1提供的方法也可以)。
const t = i18n.t.bind(i18n)
// 只翻译不扫描,__(_scan('你是{name}'), { name: '小虎' }) 

Vue.prototype.__ = window.__ = t // 挂到Vue上,使得template中也能拿到
// 只扫描不翻译
window._scan = v => v

document.documentElement.lang = locale
document.documentElement.classList.add(locale)

上面不同方法的使用场景: 所有的__()包裹的文案都将被匹配扫描。

# vue组件与js文件的函数中:

  • 普通文案:__('文案')
  • 带插值:__(_scan('你是{name}'), { name: '小虎' })

# js文件字面量中:

  • 普通文案:_scan('文案')

第四步:使用i18next-scanner

/**
 * @description
 * 扫描源代码文件,匹配需要翻译的文案
 */
function scanner() {
  const i18nParser = new Parser({
    lngs: pkg.locals,
    nsSeparator: false,
    keySeparator: false,
    pluralSeparator: false,
    contextSeparator: false
  })

  glob.sync(paths.appSrc + '/**/*.{vue,js,jsx,ts,tsx}').forEach(file => {
    const content = fs.readFileSync(file)

    i18nParser.parseFuncFromString(
      content,
      { list: ['__', 'window.__', 'window._scan', '_scan'] },
      key => {
        if (key) {
          i18nParser.set(key, key)
        }
      }
    )
  })

  const i18nJson = i18nParser.get()

  Object.keys(i18nJson).forEach(key => {
    const jsonDestination = path.join(paths.i18nLang, key + '.json')

    const translation = i18nJson[key].translation
    const existConfig = fs.existsSync(jsonDestination)
      ? JSON.parse(fs.readFileSync(jsonDestination))
      : {}
    const newConfig = lodash.pickBy(
      existConfig,
      (value, key) => key in translation
    )

    lodash.each(translation, (value, key) => {
      if (!(key in newConfig)) {
        newConfig[key] = value
      }
    })

    fs.outputFile(
      path.join(paths.i18nLang, key + '.json'),
      JSON.stringify(newConfig, '\n', 2)
    )
  })
}

第五步:编写npm script

{
	"script": {
  	"scan": "node scripts/i18n.js"
  }
}

然后执行npm run scan即可扫描提取文案。

Last Updated: 4/24/2020, 1:44:36 PM