评论

一键完成小程序国际化

本文介绍了一个用于小程序国际化的工具

       随着小程序使用的人数增长,小程序管理后台陆续收到非中文母语的用户要求支持英文的请求。小程序一开始是直接在程序里使用中文字符串的方式,要做国际化只能把这些硬编码中文的地方全部替换为i18n调用。写了个脚本跑了下,发现小程序里涉及到需要转换硬编码的地方有2000+处。。。手动修改不太科学,于是写了个名为mina-i18n的工具,用于小程序的国际化转换,有兴趣的同学可以用自己的小程序项目实验一下:

npm install -g mina-i18n
mina-i18n /path/to/origin/mina/project /path/to/i18n/mina/project

工具主要包含对JS和WXML两种文件的处理,JS使用babel处理,WXML使用htmlparser库处理,json和css由于没法使用函数调用,这个需要用户自行处理了。

JS处理:


JS文件的处理思路:找到中文字符串,将其改为以中文字符串为参数的i18n函数调用。我们的目标是一键转换后可以直接运行,而替换的行为发生在任何一个文件里,所以我们需要一个全局可访问的函数。在小程序里,有两个可以全局访问的变量,一个是getApp(),一个是wx,为了代码的简洁性,我们决定在wx上挂载一个L函数作为全局i18n函数。即:

 "中文" 转换为 wx._t("中文")

直接使用正则表达式匹配是无法做到的符合语法的替换,这里使用babel完成保留程序上下文的替换,替换逻辑写在babel 的插件里的 StringLiteral 的回调里(所有JS程序里解析到的字符串都会进入这个回调),代码如下:

const visitor = {
    StringLiteral(path) {
        const parentPath = path.parent
        // 每个中文字符串只处理一次,识别到已处理过就退出,不然会死循环
        if (t.isCallExpression(parentPath)) {
            const callee = parentPath.callee
            if (t.isMemberExpression(callee)) {
                const { object, property } = callee
                if (
                    t.isIdentifier(object) &&
                    object.name === MINA_I18N_JS_FUNCTION_CALLEE &&
                    property.name === MINA_I18N_FUNCTION_NAME
                ) {
                    return
                }
            }
        }
        const reg = /\s*[\u4E00-\u9FA5]+\s*/g
        const stringValue = path.node.value
        if (reg.test(stringValue)) {
            path.replaceWith(t.CallExpression(
                t.MemberExpression(
                    t.identifier(MINA_I18N_JS_FUNCTION_CALLEE),
                    t.identifier(MINA_I18N_FUNCTION_NAME)
                ),
                [t.stringLiteral(stringValue)]
            ))
        }
    }
}


WXML处理:


对WXML的处理思路与JS类似,就是找出WXML文件里的中文文本,替换为以文本为参数的i18n函数调用。在小程序里使用函数调用需要用到 WXS语言 ,这个语言无法使用小程序API,只有极少数的类库,反正你当成是个纯运算逻辑的WXML语法助手就行。因为WXS语言没法从外部获取数据,或者WXML里的i18n函数调用只能是类似 i18nFunc("中文","en") 这种形式,就是说具体要转为哪种语言,必须明确地告知WXS的函数。一般用户的语言都是从系统信息中获取,或者用户手动选择后存在服务端,这个语言的变量信息我们在JS里可以获取,而且这个变量每个页面都会用到,而WXML里是没法访问到getApp()和wx这些全局变量的,我们只能在每个页面的Page函数的data变量里加上这个语言变量Lang,才能在每个WXML里使用类似 i18nFunc("中文", Lang) 的形式来做文本替换。处理代码如下:

const visitor = {
    ObjectProperty(path, state) {
        if (path.node.key.name === 'data') {
            const parentPath = path.parentPath || {}
            const parentParentPath = parentPath.parentPath || {}
            const node = parentParentPath.node
            if (
                t.isObjectExpression(parentPath) &&
                t.isCallExpression(parentParentPath) &&
                node && node.callee && t.isIdentifier(node.callee) &&
                node.callee.name === 'Page'
            ) {
                // 变量插入只处理一次,识别到已处理过就退出,不然会死循环
                if (
                    t.isCallExpression(path.node.value) &&
                    t.isObjectExpression(path.node.value.arguments[0]) &&
                    path.node.value.arguments[0].properties[0].key.name === MINA_I18N_LANG_NAME
                ) {
                    return
                }
                path.replaceWith(
                    t.objectProperty(
                        path.node.key,
                        t.CallExpression(
                            t.MemberExpression(
                                t.identifier('Object'),
                                t.identifier('assign')
                            ),
                            [
                                t.objectExpression([
                                    t.objectProperty(
                                        t.identifier(MINA_I18N_LANG_NAME),
                                        t.CallExpression(
                                            t.MemberExpression(
                                                t.identifier(MINA_I18N_JS_FUNCTION_CALLEE),
                                                t.identifier(MINA_I18N_GETLANG_FUNCTION_NAME)
                                            ),
                                            []
                                        )
                                    )
                                ]),
                                path.node.value
                            ]
                        )
                    )
                )
            }
        }
    }
}


有了可在WXML里运行的 i18n函数,接下来就是对WXML源文件里的中文文本进行替换。WXML的转换处理要比JS复杂一些,处理JS的时候babel帮我们做好了语法树的解析、遍历和代码生成过程,我们只要实现语法树访问的回调就行,WXML就没有这么好用的工具了,所以需要自己做多点工作。WXML的解析用了 htmlparser2 ,这个库会返回给你一棵DOM树,按着根节点就能遍历整棵树。不过在实际解析生成WXML的时候,发现这个库有个地方没法满足,就是它解析出来的节点属性无法区分开是为空还是 boolean attributes ,也就是说<view hidden></view>和<view hidden=""></view>解析出来的结果都是一样的{hidden: ""},这其实有很大的歧义,因为在小程序里<view hidden></view>会表示这个view的hidden=true,而<view hidden=""></view>这会被解释为hidden=false,因此按照这个解析结果生成的话,就发现原来小程序里应该隐藏的view都被显示了。。。搜了下其他有其他人给作者提过这个问题的 issue ,我看作者的意思大概是这个库不是用来处理序列化或者生成代码这类事的,在github搜了下好像这个库又确实是JS里HTML解析库里排名第一的,于是就自己fork出来 一份,加了个选项来做boolean attributes 的区分。

在做WXML处理的时候,我犯了个错误,就是把处理WXML跟处理HTML这两件事等同起来了,可能之前做过一些HTML相关的处理工作,所以写着写着就很顺地按着原来用正则表达式处理的方式去做了,结果发现在处理节点属性为{{xxx}}的动态属性时总是有edge case处理不到。中间隔了两天去做其他需求后再回来一想,这{{xxx}}里的xxx就是JS代码啊,直接用babel不就完事了吗?困在原来处理HTML的思路里调试了大半天浪费时间。正确的处理WXML的方式就是用htmlparser解析静态文本,解析到的{{xxx}}语法交给babel处理,最后再重新合成WXML代码。这里面还有属性的单双引号处理、uincode与汉字的一些处理等细节,具体就不展开了,代码里都有:

function buildWXML(root) {
    if (Array.isArray(root)) {
        let xmlString = ''
        root.forEach(node => {
            xmlString += buildWXML(node)
        })
        return xmlString
    } else if (root.type === 'text') {
        const text = root.data
        return processMinaTemplateText(text)
    } else if (root.type === 'tag') {
        const tagName = root.name.replace('wx-', '')
        let tagString = `<${tagName}`
        const attr = root.attribs || {}
        Object.keys(attr).forEach(key => {
            if (attr[key] === null) {
                tagString += ` ${key}`
            } else {
                const attrValue = processMinaTemplateText(attr[key], {
                    isAttrValue: true
                })
                tagString += ` ${key}="${attrValue}"`
            }
        })
        const children = root.children || []

        if (isSelfCloseTag(tagName) && children.length === 0) {
            tagString += '/>'
        } else {
            tagString += '>'
            children.forEach(node => {
                tagString += buildWXML(node)
            })
            tagString += `</${tagName}>`
        }
        return tagString
    } else if (root.type === 'comment') {
        return `<!--${root.data}-->`
    } else {
        return ''
    }
}
function processMinaTemplateText(text, options = {}) {
    const textArray = text.split(/({{[^}]*}})/g)
    let returnText = ''
    textArray.forEach(item => {
        if (item.startsWith('{{') && item.endsWith('}}')) {
            let expression = item.substring(2, item.length - 2)
            returnText += '{{' + processMinaTemplateExpression(expression) + '}}'
        } else {
            returnText += processPlainText(item)
        }
    })
    return returnText
}
function processMinaTemplateExpression(code) {
    const visitor = {
        StringLiteral(path) {
            const parentPath = path.parent
            if (t.isCallExpression(parentPath)) {
                const callee = parentPath.callee
                if (t.isIdentifier(callee) && callee.name === MINA_I18N_FUNCTION_NAME) {
                    return
                }
            }
            const reg = /\s*[\u4E00-\u9FA5]+\s*/g
            const stringValue = path.node.value
            if (reg.test(stringValue)) {
                path.replaceWith(t.CallExpression(
                    t.identifier(MINA_I18N_FUNCTION_NAME),
                    [t.stringLiteral(stringValue), t.identifier(MINA_I18N_LANG_NAME)]
                ))
                createI18NData(stringValue)
            }
        }
    }
    try {
        const result = babel.transform(code, {
            plugins: [
                { visitor }
            ],
            generatorOpts: {
                quotes: 'single',
                compact: false
            }
        })

        const i18nScriptContent = transformText(toSingleQuotes(result.code))
        return i18nScriptContent.replace(/[\r\n]+$/g, '').replace(/^;/g, '').replace(/;$/g, '')
    } catch (e) {
        console.log(`parser expression : ${code}, error: ${JSON.stringify(e)}`)
        return code
    }
}


翻译:

一开始做这个工具的目的,就是要做到一键转换后就可以在微信开发者工具里跑起来。经过上面两步的处理,我们已经把项目里出现过的所有中文字符串都提取并替换,接下来我们就要实现 i18n 翻译函数。

最简单的是简体和繁体的转换,因为已经有 opencc 这个优秀的开源库可以处理 。

const OpenCC = require('opencc')
const opencc = new OpenCC()
const hant_text = opencc.convertSync(text) 


中英文的翻译没法离线,只能找线上服务。在找了google,有道,金山,必应,百度等翻译服务后,发现微软的必应是最方便的,不需要申请token或者去对抗防爬虫,翻译效果也不错,非常适合用在一个可以无条件使用的工具里,接口调用非常简洁:

 request.post({
    url: 'https://cn.bing.com/ttranslatev3',
    form: {
        fromLang: 'zh-Hans',
        to: 'en',
        text: text
    },
    json: true,
    timeout: 2000
}).then(res => {
    return res[0].translations[0].text
})


绝大多数中文项目的i18n做到繁体+英文就够了,外贸项目等特殊情况的话,改一下调用bing API的语言参数就行,bing支持多种语言翻译。

至此,工具也算基本完成了,使用了自己小程序和github上找的几个开源小程序项目做测试,基本都是一键转换后就能直接在开发者工具上跑的,真机预览也没出问题。有兴趣的同学可以在自己项目的小程序上跑跑看。我看了下日常使用的各个小程序,可以说几乎100%都是没有提供切换语言选项的,万一以后有需求的话,希望这个工具可以方便到大家。

对源码有兴趣的同学可以到 github 看一下,如果转换的小程序跑起来有错误的话也欢迎提issue,我会尽快解决的。

最后一次编辑于  08-13  (未经腾讯允许,不得转载)
点赞 5
收藏
评论

2 个评论

  • 流苏
    流苏
    09-18

    大佬。。。能说下demo是怎么引入到项目吗。。

    09-18
    赞同
    回复 1
    • 春寒料峭
      春寒料峭
      09-19
      这个工具不是引入项目的,是对你现有的项目进行转换,npm isntall mina-i18n 然后mina-i18n /path/to/your/project /path/to/new/project 就可以生成你的小程序的国际化版了
      09-19
      回复
  • 相信自己
    相信自己
    08-13

    可以转成英文吗

    08-13
    赞同
    回复 3
    • 春寒料峭
      春寒料峭
      08-13
      可以的,自动转换,用开发者工具预览就可以预览到自己英文版的小程序了。
      08-13
      回复
    • Slience 🍃
      Slience 🍃
      08-21回复春寒料峭
      请问 有做过wepy 框架 的国际化吗
      08-21
      回复
    • 春寒料峭
      春寒料峭
      09-19回复Slience 🍃
      各种框架最后都是转换成最原始小程序的文件结构,在转换后的代码里再跑一遍这个工具就可以
      09-19
      回复