gmfe / Think

观麦前端团队的官方博客
68 stars 3 forks source link

多语迁移工具总结 #46

Open Realwate opened 6 years ago

Realwate commented 6 years ago

背景

因为业务变化需要将已有项目迁移到多语言版本,我们代码中硬编码的 中文字符串 都需要替换成 函数 的形式,通过函数这个中间层去动态的获取当前语言对应的字符串。

简单来说,工具需要做的是这样一件事。

// before 基本情况
var s1 = '你好'
// after
var s1 = t('你好')
// t函数会根据一个映射关系找到 你好 对应的当前语言  
// 这个映射关系大概长这样 {'你好':'hello'}

// before 组合插值情况
var s2 = '共' + num + '个订单'
// after
var s2 = t('key', {num})
// 映射 {'key': '共${num}个订单'}

方案

我们实际上要做的就是 代码替换字符串提取 ,一般来说有两种方案。

  1. 使用正则,对字符串做匹配替换
  2. 通过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的命名主要考虑到以下几个因素

  1. Key的命名在一份多语文件中必须唯一
  2. 通过Key替换后,能保证代码的可读性
  3. 尽量减少多语文件的体积 (复用,组合)

给出两种策略:

  1. 直接以Key对应的模板做Key
  2. 自增的Key

对于基本情况,直接用模板作为Key,这样能实现复用并保持可读性。

var s1 = '保存'
// 直接使用保存作key
i18next.t('保存'); 

当然,也可能同一个词在不同地方翻译不同,这个时候就需要手动处理。

对于插值情况,我们也可以使用模板作为key值,但是插值情况生成的模板可能会很长,所以插值情况也使用递增的key命名,同时将源码以注释的形式附加在后面,保持代码的可读。

初次迁移时可能会多次运行工具去修复一些问题,为了保证Key的唯一性,工具每次运行完会将Key持久化到一个文件,再次运行时优先从文件中读取nextKey

在迁移了几个项目后,发现也有一些问题。

  1. 基本情况的模板也会很长
  2. 插值情况也有复用的场景

这一块还有改进的空间: 比如如果不用考虑多语文件体积,统一用模板当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-i18nextTrans组件给我们提供了方案,它的使用如下。

{/* 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... 分别对应着childrenvirtual dom节点的索引。

接着对ast树做深度遍历,通过索引与children对应节点建立关系,逐步将children中的文本替换成模板中的多语文本,得到一个新的childrenvirtual dom, 最后用React.createElement()包裹,由React渲染出来。

总结和思考

区分场景的好处

如果我们不区分以下这三种情况

  1. 单个字符串
  2. js插值
  3. 组件插值

而是全部使用单个替换的策略

// 全部单个替换
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插值组合情况 组件插值情况
出现场景 最多 一般 较少
实现难度 简单 一般 较难

根据实际情况,最终工具的替换思路如下

  1. 如果是单独的字符串,就直接替换
  2. 如果是组合而成的(变量、字符串等),那么会先找到一个完整的表达式再替换(为了结合语境)
  3. 如果是组件插值,工具只会 单独的去替换每个部分 。 因为组件插值情况目前来看是很少的,我们可以先人工的提供完整句子来翻译,再分别对应到多语文件不同的key,如果后面发现实在有需要也可以参考Trans组件的解决方案。

工具实现在这里,使用方法见README

----------------------------------------- 🐶 END 🐶 -----------------------------------------

参考资料

i18n-pick 理解Babel插件 Babel 插件手册 astexplorer