// before 基本情况
var s1 = '你好'
// after
var s1 = t('你好')
// t函数会根据一个映射关系找到 你好 对应的当前语言
// 这个映射关系大概长这样 {'你好':'hello'}
// before 组合插值情况
var s2 = '共' + num + '个订单'
// after
var s2 = t('key', {num})
// 映射 {'key': '共${num}个订单'}
var s = '中' + num + users[0].name + foo()
// 替换后
var s = i18n.t('key',{VAR1:num,VAR2:users[0].name,VAR3:foo()})
// 模板 {key:'中 ${VAR1} ${VAR2} ${VAR3}'}
// 变量重命名为 `VAR${count++}`
// 全部单个替换
var s = '你好' + name + '欢迎来到' + where
// var s = i18next.t('你好')+name+i18next.t('欢迎来到')+where
<div>
{i18next.t('当前没有分类,请')}
<span onClick={this.handleAddCategory}>{i18next.t('新建分类')}</span>
{i18next.t(',再新建商品')}
</div> :
背景
因为业务变化需要将已有项目迁移到多语言版本,我们代码中硬编码的 中文字符串 都需要替换成 函数 的形式,通过函数这个中间层去动态的获取当前语言对应的字符串。
简单来说,工具需要做的是这样一件事。
方案
我们实际上要做的就是 代码替换 和 字符串提取 ,一般来说有两种方案。
Babel
,对解析生成的ast
节点做处理两个方案对比,第一种方案需要我们 精心 编写一个正则表达式,即使这样也很难确保覆盖所有情况,当代码较为复杂时正则就显得不够用了。同时因为使用的是正则表达式,代码的可读性、工具的可维护性都可能有所降低,实现方式不够优雅。
再看
Babel
,它会将我们的代码转换成ast
(所有的ast
节点说明都在这里),同时提供了一系列的API
去变换、操作ast
节点,最后Babel
会根据变换过后的ast
结构去输出最终代码,我们只需要专注于处理ast
节点就基本能实现需求。综上,我们最终选择
Babel
去实现工具。实现
Babel
的基本运行机制很简洁,如下图所示。以上三个步骤我们主要关注的是
Transform
这个过程,babel-traverse
模块会帮我们自动的遍历每个ast
节点,同时以visitor
的形式暴露给我们接口,使得 在遍历ast
时我们可以对指定ast
节点做修改和替换 。基本情况
对于最基本的情况,我们只需要在遍历到
StringLiteral
节点时替换成t
函数即可,转换代码如下。上述代码可在
astexplorer
查看但是这种处理略显粗糙,因为针对插值情况时也是单个替换,导致我们语义上的一句话被拆分成了多个
Key
,增加了翻译的难度,其效果如图所示。插值情况
因此,对于插值情况,我们需要换一种方式,不能直接将
StringLiteral
替换成t
函数,而是要先找到一个完整的表达式,再针对这个完整的表达式,去计算得出它的映射关系。如图所示,在遍历到
StringLiteral
时,我们会以该节点为入口,找到整个表达式的根节点(Root
),然后对Root
这颗子树做一次深度遍历,这样就能够得到一个完整的映射关系,从而解决插值情况。命名策略
主要有 变量的命名 和
key
的命名变量占位符命名
模板中的变量占位符实际上对应了一个对象的
key
,而代码中的变量有多种情况,不止标识符(Identifier
)一种 ,转换成模板时不能直接拿变量本身的名字作占位符。所以工具会采用以数字结尾递增的变量名。
Key命名
Key
的命名主要考虑到以下几个因素Key
的命名在一份多语文件中必须唯一Key
替换后,能保证代码的可读性给出两种策略:
Key
对应的模板做Key
Key
值对于基本情况,直接用模板作为
Key
,这样能实现复用并保持可读性。当然,也可能同一个词在不同地方翻译不同,这个时候就需要手动处理。
对于插值情况,我们也可以使用模板作为
key
值,但是插值情况生成的模板可能会很长,所以插值情况也使用递增的key
命名,同时将源码以注释的形式附加在后面,保持代码的可读。初次迁移时可能会多次运行工具去修复一些问题,为了保证
Key
的唯一性,工具每次运行完会将Key
持久化到一个文件,再次运行时优先从文件中读取nextKey
。在迁移了几个项目后,发现也有一些问题。
这一块还有改进的空间: 比如如果不用考虑多语文件体积,统一用模板当
Key
就完事了。如果考虑,那么统一用自增Key
,再根据模板去重实现复用。复杂JSX
有时会遇到以上这种场景: 我们要翻译的一句话包含HTML标签或者组件,此时单个替换 可能 保证不了翻译后的英文的通顺和准确性,而使用处理普通
js
插值的方法来处理这种场景是行不通的。针对这种场景,react-i18next 的
Trans
组件给我们提供了方案,它的使用如下。Trans
组件的实现并不复杂,首先它会拿到两个东西,一个是key
对应的模板"我会于<1>${tip_day}</1>天内通知你"
,另一个是Trans
组件的children
。对于模板,通过
html-parser
转化成ast
,它与children
的树结构其实是一致的,标签的0
,1
,2
... 分别对应着children
的virtual dom
节点的索引。接着对
ast
树做深度遍历,通过索引与children
对应节点建立关系,逐步将children
中的文本替换成模板中的多语文本,得到一个新的children
的virtual dom
, 最后用React.createElement()
包裹,由React
渲染出来。总结和思考
区分场景的好处
如果我们不区分以下这三种情况
而是全部使用单个替换的策略
在类似以上场景翻译起来似乎也很通顺。但是在某些情况:
如上文本,如果直接让翻译人员去分别翻译
我会于
、天内通知你
其实是没法翻译的,因为完整的一句话被拆开导致 丢失了语境。因此我们需要 先告诉翻译人员完整的句子 是 ”我会于${day}
天内通知你“,然后在分别将对应的英文填入模板,生成如下多语文件。虽然这样能拼接成完整的英文,但是多语文件中看起来仍然有些奇怪(句子不完整,没有一一对应,不直观),并且之后每多一种语言,都需要翻译人员知道具体的语境才能翻译,这其实增大了后续的工作量。
而如果采用插值情况来处理,翻译人员直接就能知道一句完整的话,我们只需要告知
<1>
${}
这些符号意味着变量即可。因此,区分这些场景其实是有一定优势的,它能 较为彻底 的解决问题,减少我们的工作量以及与翻译人员的沟通成本,使得双方可以最大程的独立进行工作,提升效率。
重复问题
我们在人工校验多语替换时发现一些类似如下的重复翻译的场景
想要从代码层面去实现剔除如上重复的机制是很困难的(
NLP
相关),如果有需要可以自己手动的去修改以及调整一些不合理的地方。替换总结
我们项目中的场景总结如下
根据实际情况,最终工具的替换思路如下
key
,如果后面发现实在有需要也可以参考Trans
组件的解决方案。工具实现在这里,使用方法见
README
。----------------------------------------- 🐶 END 🐶 -----------------------------------------
参考资料
i18n-pick 理解Babel插件 Babel 插件手册 astexplorer