前端国际化

前言

随着公司业务的飞速发展,我们的市场覆盖范围已扩大至港澳台和欧洲地区。为了满足多语言需求和提升用户体验,我们需要对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,转换的时候有点小问题,就是会把标签转成小写造成部分转换后的代码不能正确执行,也算一个小插曲。

结语

以上就是我们在国际化项目中遇到的一些问题和解决方案,只是描述了其中大概的部分,而在实际的过程中还有很多和历史代码相关的零星问题。所以对于一些项目,特别是老项目,国际化并非一件容易的事情,我们会继续积累经验,把国际化做的更完善,从而更好的服务我们的业务。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,427评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,551评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,747评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,939评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,955评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,737评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,448评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,352评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,834评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,992评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,133评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,815评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,477评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,022评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,147评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,398评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,077评论 2 355

推荐阅读更多精彩内容

  • 需要注意的点 1 .最基本的要求:文字替换.label,placeholder,字段校验提示信息,超链接2 .页面...
    skoll阅读 1,555评论 0 1
  • 关于国际化 一个项目发展到一定的环境或者一开始就是为多国打造的,就需要考虑国际化了。简单来说,就是一套页面,多套语...
    XboxYan阅读 292评论 0 0
  • 由于在去年的大部分时间都在做出海项目,分享一套Web国际化的方案。在出海项目大多转冷的今天,给去年的疯狂留个念想。...
    pengji阅读 741评论 0 0
  • 国际化基础知识 国际化与本地化 国际化与本地化,或者说全球化,其目的是让你的站点支持多个国家和区域。其中国际化是指...
    jsAllen阅读 5,913评论 0 3
  • 需求 项目基于Vue进行开发,使用了ant-design-vue框架,然后需要做国际化。此时做国际化需要考虑两方面...
    world_7735阅读 3,496评论 0 0