前言
随着公司业务的飞速发展,我们的市场覆盖范围已扩大至港澳台和欧洲地区。为了满足多语言需求和提升用户体验,我们需要对web运行平台进行国际化支持和改造。下面分享一下我们在国际化的过程中,遇到的问题以及解决方案。其过程主要分为四个部分:
-
词条抽取
-
词条管理
-
词条引入
-
词条调整
词条抽取
我们遇到的第一个问题就是应该提取哪些内容作为词条,以及如何提取。
目前待改造的页面有2000+,纯靠人工的话,耗时长效率低还比较容易遗漏。所以我们想尽可能靠工具解决80%的问题,人工解决剩下的20%。基于这个目标,我们的思路是:对各个前端工程进行扫描,寻找vue文件以及js文件并进行扫描,把里面包含中文的部分提取成词条,并最终输出到excel文件。
对于js文件的处理:
首先通过@babel/parser对文本内容进行ast转换,然后判定是否包含中文,是的话就纳入词条,同时替换成多语言方式。
比如:let message = "提醒",转换后变为
let message = this.$t("XXXXXX","提醒"),XXXXXX代表词条标识,如果词条没匹配到,就用第二个参数(默认值)
其各个处理步骤代码如下:
1,对ast的node内容进行判定,看看是否包含中文
exports.containZH = function(content){
if(!content)return false
return /.*[\u4e00-\u9fa5]+.*$/.test(content)
}
2,将内容"提醒"转换成:this.$t("XXXXXX","提醒")
function createCallExpression(thizz,term,content){
let _objetct = null
if( thizz == 'this'){
_objetct = t.thisExpression()
}else{
_objetct = t.identifier(thizz)
}
let property = t.identifier('$t')
let callee = t.memberExpression(_objetct,property)
let argument = t.stringLiteral(config.appCode +'.'+ term)
let defaultArgument = t.stringLiteral(content)
let callExpression = t.callExpression(callee,[argument,defaultArgument])
return callExpression
}
3,替换节点
下面有几种场景需要考虑:
-
作为表达式:
let message = "提醒"
-
作为判定条件:
if(test === "提醒"){}
-
作为方法入参:
let result = getData("提醒")
-
es5的this变换:
var that = this getData().then(function(){ var message = "提醒" //此时应替换成that.$t("xxxxxx","提醒") })
当然除了上述场景外,还要结合需要转换的代码的新旧程度以及实际情况进行一些兼容,同时我们也会把一些判断不准的,输出到日志进行人工处理,节点替换代码如下:
let callExpression = createCallExpression(thizz,term,path.node.value)//t.callExpression(callee,[argument])
let rPath = path.parentPath
if(t.isCallExpression(rPath)){
rPath.node.arguments = [callExpression]
if(thizz == 'this' || thizz == 'that'){
let checklog = "行:"+path.node.loc.start.line+"列:"+path.node.loc.start.column
console.log("请人工确认this是否正确:",_path,checklog)
writeCheckLog(_path,checklog)
}
}else if(t.isObjectProperty(rPath)){
rPath.node.value = callExpression
} else if(t.isBinaryExpression(rPath)){
if(t.isBinaryExpression(rPath.parentPath) && t.isBinaryExpression(rPath.parentPath.parentPath)){
let plusPath = path.findParent(path => path.isCallExpression())
if(plusPath)plusPath.node.arguments = [callExpression]
}else{
if(rPath.node.left.hasOwnProperty('value') && rPath.node.left.value == path.node.value){
rPath.node.left = callExpression
}
if(rPath.node.right.hasOwnProperty('value') && rPath.node.right.value == path.node.value){
rPath.node.right = callExpression
}
}
}else if(t.isConditionalExpression(rPath)){
if(rPath.node.consequent.hasOwnProperty('value') && rPath.node.consequent.value == path.node.value){
rPath.node.consequent = callExpression
}
if(rPath.node.alternate.hasOwnProperty('value') && rPath.node.alternate.value == path.node.value){
rPath.node.alternate = callExpression
}
}
除了上述场景,还有一个情况是比较难处理的:
let message = "第" + i + "行"
类似上面这种在循环里的拼接字符串,会被分割成两个词条。由于我们在代码中全局搜索后发现,这种情况并不多所以也是采用人工处理的方式。当然ast能做但不太好做,所以对我们来说得不偿失。
对于vue文件的处理
主要是通过vue-template-compiler提取出vue文档的template部分,script部分。script部分的处理和上面类似不再累述,我们重点描述一下template部分:
提取template的html字符串后,首先利用parse5进行ast转换:
exports.vueParse = function vueParse(_path){
globalThis.termIndex = 0
var fileContent = fs.readFileSync(_path).toString()
var result = compiler.parseComponent(fileContent)
let scriptAst = parser.parse(result.script.content,{sourceType: "module"})
let templateAst = parse5.parse(result.template.content,{sourceCodeLocationInfo:true});
globalThis.hasTerm = false
try{
let templateRoot = templateAst.childNodes[0].childNodes[1].childNodes[0]
makeTerms(templateRoot,_path)
}catch(e){
console.warn(_path+",template内容空白,程序忽略,请人工确认!")
}
let html = parse5.serialize(templateAst);
html = html.replace(/<(html|\/*head|body)>/g,'')
html = html.replace(/<\/(body|html)>/g,'')
let scriptData = JsContentParse(_path,result.script.content)
if(!globalThis.hasTerm && !scriptData.hasZH){
globalThis.hasTerm = false
return
}
makeNewFile(_path,html,scriptData.data,result.source)
return {
filePath:_path,
template:templateAst,
script:scriptAst,
result:result,
}
}
转换后,同样对html的节点进行替换,需要考虑的场景有下面几个:
-
忽略注释中的中文,而非作为词条的一部分
// author is 张三
-
替换标签文本部分的内容
<i title="张三"></i> //替换成,注意属性前要加冒号 <i :title="$t('xxxxx','张三')"></i>
-
替换属性部分的内容
<i>张三</i> //替换成 <i>$t('xxxxx','张三')</i>
其整体代码实现如下:
function makeTerms(templateRoot,_path){
if(templateRoot.nodeName == ' #comment'){
return false;
}
if(templateRoot.nodeName == '#text'){
if(containZH(templateRoot.value)){
globalThis.hasTerm = true
let term = record(_path,templateRoot.sourceCodeLocation,templateRoot.value)
templateRoot.value = '{{$t(\''+config.appCode +'.'+ term+'\')}}'
}
return
}
if(templateRoot['attrs']){
templateRoot.attrs.forEach(attr => {
if(containZH(attr.value)){
globalThis.hasTerm = true
let term = record(_path,templateRoot.sourceCodeLocation,attr.value)
attr.value = '$t(\''+config.appCode +'.'+ term+'\')'
attr.name = (':'+ attr.name)
return true
}
});
}
if(templateRoot['childNodes']){
templateRoot.childNodes.forEach(_node=>{
makeTerms(_node,_path)
})
}
}
我们通过上述工具,基本能自动转换80%的代码,其余的就需要人工介入修改检查。词条抽取后,下一步我们要对抽取的内容进行管理。
词条管理
这部分涉及的内容不多。在应用层面,我们对前端词条进行了区分以减少重复词条的数量,主要有三类:
-
公共词条:跨工程页面都会重复使用的词汇,比如:查询
-
业务公共词条:某一块业务经常出现的词汇,比如:促销
-
词条:具体页面个性化的词汇
另外我们也需要保证词条在维护的时候,不会重复不会影响他人。同时由于词条的数量比较多,都决定了我们无法用文件的形式进行词条管理,必须依赖数据库。
基于上述原因,我们构建了一个管理页面,来对相应的表进行数据录入和处理。在词条提取部分我们提到过,提取的词条最终会生成excel,所以在词条管理页面,也支持该excel的直接导入,减少词条录入时间,提高录入效率。有了这些词条,我就可以使用了:
词条引入
一说起vue的国际化,我们首先就会想到vue-i18n。如果只有几个页面十几个页面,甚至几十个页面,直接使用vue-i18n都没太大的问题。
但我们有2000+的页面需要国际化,词条也会超过10万+,所以我们会变通的方式使用vue-i18n从而满足以下几点要求:
-
词条变更不需要重新编译发布
-
可以充分利用浏览器缓存
-
语言切换的时候页面内容不会晃动
最终我们的方案如下:
1,通过监控程序,从词条管理接口获取数据,分别生成公共词条,关键应用维度的业务词条的静态js文件
2,web框架根据当前关键应用信息,加载公共词条以及关键应用下的静态js文件,并将内容放入全局变量localeMessage,i18n根据全局变量初始化:
let messages = {}
//部分代码省略
messages[window.locale] = Object.assign(window.localeMessage,_locale)
// Create VueI18n instance with options
const i18n = new VueI18n({
locale: window.locale,
messages
})
这样当我们在词条管理页面维护词条信息后,线上也能一定时间内同步最新的词条信息。
词条调整
到目前为止,不管是词条抽取,管理,引入都是偏开发视角。考虑下面一个场景:如果一个非开发人员要纠正词条信息,比如产品经理或翻译人员,他不知道一段文字到底对应哪个词条key,需要找开发人员去问,然后再去词条管理维护修改,这样一圈下来效率太低。
所以我们想给非开发人员提供一种在线编辑词条的能力,如图:
image.png其特性主要有以下两点:
-
可以通过定位可以快速找到词条位置
-
可以修改词条内容并保存
这样的话,非开发人员都可以在线上快速进行词条的编辑修正,特别是在非中文的场景下,从而大大提高了词条的纠正效率。
其实现主要分为两部分:
1,如何收集页面到底使用了哪些词条?
我们可以用mixin的方式劫持$t方法,然后收集页面依赖的词条。
2,如何标记词条位置?
要建立dom和词条的关联,需要在dom上生成一个包含词条信息的属性。
比如一个词条"xxxx-yyyy",然后增加属性 data-term-xxxx-yyyy="xxxx-yyyy"。当点击定位的时候,就可以根据属性去查找节点。当然在标记的时候要注意:内容和属性的词条都加到节点上。
我们通过webpack,loader插件的方式实现,插件内容大致如下:
function makeTerms(templateRoot){
if(templateRoot.nodeName == ' #comment'){
return false;
}
if(templateRoot.nodeName == '#text'){
if(containZH(templateRoot.value)){
let terms = getTermCode(templateRoot.value)
terms.forEach(term=>{
if(!term.startsWith('T_S_'))return true
let tName = term.replace(/_|-|\./g,'')
templateRoot.parentNode.attrs.push({
value:term,
name:'data-term-'+tName.toLowerCase()
})
})
}
return
}
if(templateRoot['attrs']){
templateRoot.attrs.forEach(attr => {
if(attr.name.startsWith(':') || attr.name.startsWith('v-bind:')){
if(attr.value && attr.value.startsWith('{'))return true
let terms = getTermCode(attr.value)
terms.forEach(term=>{
if(!term.startsWith('T_S_'))return true
let tName = term.replace(/_|-|\./g,'')
templateRoot.attrs.push({
value:term,
name:'data-term-'+tName.toLowerCase()
})
})
return true
}
});
}
if(templateRoot['childNodes']){
templateRoot.childNodes.forEach(_node=>{
makeTerms(_node)
})
}
}
webpack配置部分如下:
{
test: /.vue$/,
loader: path.resolve(__dirname, '../webpack-plugin/teld-term-loader.js')
}
另外在实现的过程中我们发现,html转ast的库很多,但从ast转html的比较少,最后就找到了parser5,转换的时候有点小问题,就是会把标签转成小写造成部分转换后的代码不能正确执行,也算一个小插曲。
结语
以上就是我们在国际化项目中遇到的一些问题和解决方案,只是描述了其中大概的部分,而在实际的过程中还有很多和历史代码相关的零星问题。所以对于一些项目,特别是老项目,国际化并非一件容易的事情,我们会继续积累经验,把国际化做的更完善,从而更好的服务我们的业务。
网友评论