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