# 前端多语言方案实现总结
# 「方案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文件中然后就完成了翻译。
- 需要手动提取文案(手动使用
__函数包裹要翻译的文案)。 - 使用i18next-scanner扫描对应文件提取汇总文案。
 - 切换语言需要强刷页面。
 
# 「方案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:使用nodejs的能力自动
 - 无刷新切换:
- 方案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中注册后使用); 
 - 2.1 绑定到全局后,对于一些js文件中的字面量文案,就可以直接调用
 - 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即可扫描提取文案。