企业微信Web国际化方案

Posted by Leo Eatle on 2020-04-17

背景

企业微信的web端包括了管理端、工作台应用、官网、帮助中心等多个模块,每个作为单独的项目已持续迭代较长时间,所有代码中的中文都是直接裸写的,现在要给所有模块加上国际化的处理,要比一个新项目从头开始支持国际化困难的多。
经过一番改造,目前已经支持了包括管理端几乎所有界面的国际化,具体是怎么做的呢。

解析代码,提取中文

首先要做的是从已有代码中解析出中文,简单的正则匹配很容易把不需要翻译的部分如注释带入,也无法支持通过标注跳过一些无需翻译的代码行,最关键的是,我们还需要包裹上国际化函数,所以解析AST是比较妥当的选择。

目前使用的语法解析器是 esprima,解析出中文后,进行一个函数的包裹,这里我们用的是weLANG作为函数名,最后再次通过AST组装代码返回。

扫描代码的时候,把经过weLANG包裹的中文保存在内存中,这样我们就完成了对于旧代码中的中文自动包裹+提取。

比如这样一段代码:

const getWatchRange = function(start_time, end_time) {
let rangeSeconds = end_time - start_time;

const h = Math.floor(rangeSeconds / 3600 % 24);
const m = Math.floor(rangeSeconds / 60 % 60);
if (m < 1) {
return rangeSeconds + '秒';
} else if (h < 1) {
return m + '分钟';
} else {
return h + '小时' + m + '分钟';
}
};

在经过提取和包裹后会变成这样

/* eslint-disable */
function weLANG(b,e,f){
// 省略判断代码
a.D={
'*': {
// '分钟':
// '小时':
// '秒':
}
};
}
/* eslint-enable */


const getWatchRange = function(start_time, end_time) {
let rangeSeconds = end_time - start_time;

const h = Math.floor(rangeSeconds / 3600 % 24);
const m = Math.floor(rangeSeconds / 60 % 60);
if (m < 1) {
return rangeSeconds + weLANG('秒');
} else if (h < 1) {
return m + weLANG('分钟');
} else {
return h + weLANG('小时') + m + weLANG('分钟');
}
};

首先有人会问,为什么这里的weLANG函数是以这样的方式插入到每个js中的呢?这个是有些历史原因的,目前企业微信大部分历史项目都不是采用webpack,而是使用seajs去异步加载模块,如果全部翻译都放在一个语言包里单独加载,整个包会变得非常大,而单独几个页面可能根本不需要加载整个语言包。

由于此时中文虽然提取了,但是对应翻译是还没有给到的,所以这里的weLANG不会做任何事情,所有对应翻译是以注释的形式插入。

提取出来的中文被存放于一个po文件中,这个文件类型是专门用来做翻译的,此时po文件会是这样

# Create by I18NC PO
"Project-Id-Version: I18N Project - Create By I18NC Tool\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"MIME-Versio: 1.0\n"
"X-Generator: I18NC-PO\n"
"Language: en\n"

msgid "分钟"
msgstr ""

msgid "小时"
msgstr ""

msgid "秒"
msgstr ""

po文件中会包含编码、语言信息,以及每个词条和它对应翻译,这个文件就可以之后交给第三方进行翻译。他们也有专业的软件去填写这样的文件。最后对应的翻译会填入msgstr中。

模板文件的处理

企业微信web项目是基于node服务的,对于一些比较简单的页面是直接采用art-template模版引擎直接在node层直出前端,对于这部分tpl文件当然也是需要做国际化的。

但是对于模板文件我们没有办法采用AST去解析了,所以就采用了比较简单的正则直接匹配中文去包裹,并且这一步是放在压缩tpl后做的,这样能避免把tpl中注释的部分也加入翻译。

翻译插入

拿到第三方给的文件之后,就可以将po文件中的中文再插回到原来的js中了,这一步事实上也同样是利用AST去做的,通过扫描识别每个文件顶部的weLANG,将原来注释的中文补充上英文。

补充前

{
'*': {
// '分钟':
// '小时':
// '秒':
}
}

补充后

{
'$': ['en'],
'*': {
'分钟': ['minutes'],
'小时': ['Hour'],
'秒': ['Sec']
}
};

多语言的识别

这里语言的识别是基于HTTP头部的accept-language来做的,但事实上这个语言头是不止一种语言的,因为浏览器是可以设置多个语言而且有顺序之分,拿Chrome浏览器打比方,假如你的语言设置顺序如下

此时你的accept-language是这样的:

accept-language: zh-CN,zh-HK;q=0.9,zh;q=0.8,ja;q=0.7,en;q=0.6

可以看出首先顺序是按照语言设置优先级排的,后面跟着q=xx也同样是表示权重优先级。另外,虽然同样是中文,简体和繁体显然是不同的。

按照通用的做法,应该是拿此时服务端支持的语言序列和accept-language做比对,取优先级最高且能支持的语言返回,但在这里做了一些优化,由于目前对于绝大部分页面都只需要支持中、英、繁三种语言,所以只要非中文都优先返回的是英文,中文里只要是非zh-CN都优先返回繁体。因为无论是中文-香港还是中文-台湾,都是使用繁体的。

语言信息的保存

对于前端,是用cookie来保存语言信息的。不过这里使用了两个cookie。

在用户第一次访问的时候,通过前面说的语言识别,为用户提供一个默认的语言设置,并通过Set-Cookie头部设置到cookie中。
但是我们也允许用户手动修改语言设置,这里的设置不仅会修改原来的cookie,而且还会设置另一个标志位注明属于用户手动修改,之后不再进行自动判断。

对于node层,也同样是以这个cookie为准,并且目前把它保存在process.domain中方便业务逻辑使用。

版本迭代策略

目前是在企业微信每个版本发布前,进行一波中文的抽取,然后交付给第三方安排翻译。原因是在版本发布前需求可能不断发生变动,文案之类的很难能够最后确定,只有到发布的最后阶段才比较稳定,所以安排在版本前才提取翻译。

提取翻译后,第三方翻译根据翻译的量一般能在三到四个工作日内返回,这时再进行翻译的插入和上线。

遇到的一些问题

自动提取导致翻译原文质量不高

由于中文是自动提取的,这就导致有时因为需求原因,在文案中会插入大量的判断、断句,比如在模板中的自动提取会出现这样的状况:

<span class="util_c_gray">{{#weLANG('每增加')}} {{item.incremental_unit}} {{#weLANG('人,增加 ')}}{{formatPrice(item.incremental_price)}}</span>
<!-- 提取出来的文案:
每增加
人,增加 -->

对于翻译方来说,这样的文案缺乏上下文,是基本不可能翻译得好的,这种情况我会让第三方整理成excel文档,发过来我在代码里找到对应业务,解释清楚逻辑,并把原代码改为通过占位符替换的方式,如下:

<span class="util_c_gray">{{#weLANG('每增加%s人增加%s', [item.incremental_unit, item.incremental_price])}}</span>
<!-- 提取出来的文案:
每增加%s人增加%s -->

并且,在逐步解决完历史代码的提取问题后,现在对于新的代码会要求开发自己包裹函数和确保原文质量,这也是客户端国际化方案中的统一做法,在 iOS 和 Android 开发中都会有语言包,在代码中是用自定义的key作为占位表示的,这样的国际化自然质量会高很多。只不过这里是中文做key。

webpack 打包文件的支持

由于后来有些页面比较独立,并不需要按照之前的规范采用sea.js模块化,所以也换成了webpack进行打包,并且对于模板tpl文件用对应loader进行处理。对于这类文件显然不能通过修改源码来插入,其实解决方式也很简单,可以通过在上述的国际化脚本已经由bacrawu封装成i18n-loader,可以配置其跟在art-template-loader后面。达到同样的效果。

样式问题

同等长度的中文翻译成英文时会变长很多,如果之前的样式写的比较死板,就很容易出现各种样式问题。

最常见的是原有的宽度放不下翻译出来的英文,导致文字被省略或者被迫换行。其实可以和翻译方讨论一下,能否尽量和中文的长度对齐。

由于中文的习惯下经常把表单的字段放在左边,内容放在右边,这种情况则可以考虑能否把字段和内容分行放置,许多英文网站的习惯其实是尽量垂直排列,而不是像中文网站更力求把所有内容都在一屏内展示。

ES6语法解析

由于之前使用的语法解析器是esprima,比较老旧,虽然在 Github 上声称支持 ECMAScript 2017,但事实上 npm 上的版本依然落后于 Github 主干上的版本。所以在我们升级 node 到 10+,用上ES6之后,esprima 的解析会经常报错,主要是对于模板字符串的解析。

横向对比了 esprima, acorn, babylon(babel的parser) 和 typescript 的compiler,发现如果要换编译器,对于语法解析部分的逻辑还是要做修改的,虽然每个解析器都遵守了一定的规范,不同解析器解析出来的AST确实有所不同。对于模板字符串的解析,acorn 和 esprima 解析出来的某些 range 是不同的。

最后决定还是不换解析器,干脆用 esprima 的主干版本自己打了个 esprima-master

构建流程问题

按照我们目前的 git 工作流,在之前的km文章也提到过,是通过 git 变更来做增量的构建,这对于js还好,因为目前js是会修改源码插入翻译的,但对于线上构建的tpl来说,翻译更新时源码tpl并不会更新,这也就导致不会产生构建 -> 不会在发布系统中提单 -> 不会上线等一系列问题。目前的解决方案是每次更新翻译时跑一个脚本,把所有变动到的tpl增加一行以日期区分的注释,这样就会有 git 变动并继续之后的流程。但这里其实是还有改进空间的,后面会提到。

改进方案

AST 提取

目前对于 Vue 和 Typescript 的支持都比较粗暴,是直接在构建流程上,对 Vue 和 Typescript 编译出来的js进行扫描和插入,这是因为目前使用的 esprima 是不支持解析 Vue 和 Typescript 的。

如果要从源码层面支持直接提取,可以考虑换成 typescript 和 vue 的解析器分别做对应的处理。

Vue

对于 vue 来说有更成熟的开源国际化方案 Vue I18N,虽然没有自动提取,但其实也可以利用到提取工具,中文作key。但还是建议在开发的时候就充分考虑到原文可以国际化的质量够高,使得语料库也能更高效的复用。

Node + Typescript

出于性价比考虑,Typescript 目前只用于 node 端,前文提到 typescript 也是依赖线上构建的,所以有增量更新的问题,但其实对于 node 端我们完全可以接受在启动时把翻译加载好,而不是插入到每个 js 文件中。所以这里可以改成运行时读取一个全量的翻译文件。通过一个 js 模块做缓存处理。这样就无需插入weLANG而是读取weLANG模块了。

const weLANG = require(modulePath);
weLANG('分钟')

这里需要考虑加载一个三万行翻译文件的运行成本,经过测试脚本的测试,数据如下

平均时间(毫秒):128.46315701010099
平均内存(byte):10398292.711111112
运行weLANG时间(纳秒):522.6666666666666

加载时间和运行时间都没什么问题,主要在于需要的内存比较高,达到了10MB,虽然原po文件只有972kb,这里猜想可能是 pofile 的读取会比较耗性能,之后可以改成自己实现的简单读取。

总结

国际化说难也不难,说简单也不简单,应该说如果要做到60分的国际化可能有很多种方案,要做到100分的国际化很难,这块业界也还在持续探索阶段。像微软就是直接接入翻译API去自动翻译页面的,成本是降低了,但是质量自然是不敢恭维,而像淘宝更是直接做了国际版,作为两个完全独立域名、独立项目去管理。所以国际化也是要看性价比的。

企业微信web集中在管理端,但也有一些注入审批、打卡的功能是web或者小程序实现,全量国际化的工作量巨大,采用此文的自动国际化方案,目前已经将国际化覆盖了95%的模块,剩下的一些运营页或者不对国外开放的功能通过黑名单机制排除,翻译出来的po文件加起来已经有6万+行。每个版本基本只需要花半天时间提取翻译、插入翻译,但是之后需要投入的成本是样式处理、源代码优化这些将60分提升到100分的工作。整个国际化方案目前也仍然在不断探索和改进中。