# 前端多语言方案实现总结
# 「方案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即可扫描提取文案。