两周前接到了国际化前端项目的需求。
为方便行文,假设我们只需要中英两个版本。
手动替换
起初,我觉得很简单:把所有文本全换成变量,根据配置读取就行了嘛。
幸好产品只要求国际化项目的一部分,需要替换的文本不多
一边摸鱼一边复制粘贴,花了三天时间,终于全部替换完成了。
碰到的问题
但是,手动替换,除了不需要动脑筋以外,毫无优点:
- 需要给每一条文本取一个变量名。
- 需要确定每一条文本所属的域。
对一个大项目而言,文本肯定会有大量重复。当不需要国际化时,文本就hard code在各自的地方。但是,当需要将他们提取出来时,就需要纠结了,两个相同的文本,在整个系统中,是同一个文本,还是只是两个不同文本碰巧相同了。确定这一点也是一个非常纠结头大的事情。
不解决这个问题,日后修改文本时,可能导致两个不同的文本被错误的提取为同一个变量,导致日后无法维护。 - 需要不停地复制粘贴。
- 开发时就需要将文本改成变量,日后开发起来很麻烦。
解决方案
但是我已经替换完成了,只能祈祷产品不会提进一步的要求了。
但是这不可能,终于过了一周,我又接到了国际化整个项目的需求。
打开vscode,用正则全局搜索了一下,我惊讶的发现,如果继续手动替换,有400个文件等着我修改。
于是我只能想办法去弄自动国际化了。
自动国际化
自动国际化前置要求
想要进行自动国际化,必须有这两个能力:
- 能够编译Vue源码成AST,并把修改后的AST转换成Vue源码.
- 能够编译TS源码,并把修改后的AST转换成TS源码.
- Vue转AST
可以用vue-template-compiler
把vue的template部分转换成AST
vue-template-compiler的compile方法的whitespace
需要设置成condense
。
生成的AST中会删除多余的空白文本节点,否则会因为这些多余的节点,导致布局错乱。
const compiler = require("vue-template-compiler");
const { ast } = compiler.compile(source, {
whitespace: "condense",
});
- 修改Vue template AST树。
修改node.text
和node.attrsMap
两个属性即可。
- AST转Vue
我没有找到成熟的AST转VUE的包,
只找到了这个:vue-template-ast-to-template
。
这个包有许多bug,比如
- 会生成重复的属性
- 会丢失命名slot
好在相比生成AST设计编译的知识,由AST得到Vue源码的逻辑还是很简单的,稍微改了改这个包,把这些问题都解决了。
import TemplateTransform from "./src/vue-template-transformer.mjs";
const transformer = new TemplateTransform();
// 传入vue-template-compiler创建的ast
const { code } = transformer.generate(ast);
- 搜索Vue template AST树。
首先,我们需要递归搜索Vue template的AST树。
树结构,一般子节点都会保存在名为children
的属性中,
但是vue template的AST有些特殊。
以下三个节点都保存了子节点 - children: 绝大部分子节点保存在这里
- ifConditions :相邻的v-if, v-else-if, v-else的节点保存在这个属性中
- scopedSlots:slot节点保存在这个属性中
所以,搜索整棵树时,需要把这三个属性中保存的子节点都找出来,否者会漏掉一些节点
以下是我写的搜索Vue template AST树的方法,供参考。
其中,因为某个节点的ifConditions中会包含自身,需要使用变量记录节点是否被处理过,防止出现回路死循环了。
function vueAstLooper(node, cb) {
if (node.__scanned__) return;
node.__scanned__ = true;
cb(node);
const children = [
...(node.children || []),
...Object.values(node.scopedSlots || {}),
...(node.ifConditions || [])
.map((k) => k.block)
.filter((k) => !k.__scanned__),
];
if (children.length > 0) {
children.forEach((item) => vueAstLooper(item, cb));
}
};
然后,需要找到树中的包含中文的节点和属性
对于文本节点,那么文本保存在node.text
中
对于属性,文本保存在node.attrsMap
中
{
ref: "cp-table",
class: "adjust-tab-margin",
"@change": "queryRuleList",
":loading": "ruleListLoading",
}
- 遍历和修改TS的AST
使用Babel全家桶:
const parser = require("@babel/parser");
const _traverse = require("@babel/traverse");
const _generate = require("@babel/generator");
const _template = require("@babel/template");
const template = _template.default;
const traverse = _traverse.default;
const generate = _generate.default;
const ast = parser.parse(source, {
sourceType: "unambiguous",
plugins: ["typescript", "decorators-legacy"],
});
traverse(ast, {
StringLiteral(path) {
//在这里处理普通字符串
const originString = path.toString();
path.replaceWith(template.ast(`"new String"`));
path.skip()
},
TemplateLiteral(path) {
//在这里处理模板字符串
const originString = path.toString();
path.replaceWith(template.ast(`"new String"`));
path.skip()
},
});
- TS的AST还原成TS
因为我使用了装饰器,所以需要把decoratorsBeforeExport设为true,否则生成的代码里 export default 的位置不对。
const { code } = generate(ast, {
decoratorsBeforeExport: true,
});
自动国际化步骤
前置技能要求准备就绪,可以开始了。
第一步
使用nodejs写一个脚本,用于提取文本。
把每个文件中的代码转换成AST,从AST中把中文文本全部提取并保存。
将扫描出来的所有中文文本,保存在zh.json
文件里。key是文本的hash,值是文本的本身
注意,必须是文本原文的hash。
{
"e9e8054f8b9b30a5bc0eab3aa4645f9c": "邮件",
}
得到zh.json
文件之后,复制一份,然后将翻译,得到en.json
文件。
{
"e9e8054f8b9b30a5bc0eab3aa4645f9c": "Email",
}
在项目源码中,我们写下的是邮件
二字,他的hash值是e9e8054f8b9b30a5bc0eab3aa4645f9c
,这个hash作为key,能从zh.json
和en.json
文件中找到其对应的中英双语。
第二步
写一个webpack的loader。
- 在loader中获取Vue和TS文件中的文本,对文本取hash
- 拿着hash,取前保存好的
zh.json
和en.json
中找到所需的翻译 - 拿着得到的翻译,去替换掉代码中的文本节点。
这样,基本上,核心功能,就大功告成了。
但是不要得意,接下来还需要解决这些问题:
如何解决Vue的模板变量
大部分时候,模板变量都会很简单,用正则可以轻松提取someAppName
变量<div>你确定要删除{{someAppName}}吗?</div>
但是防不住自己手贱写得过于复杂
这时用正则就不行了
<div>你确定要删除{{booleanA?firstAppName:booleanB?secondAppName:thirdAppName}}吗?</div>
因为vue的模板变量中可以写任意合法的js的表达式,所以需要将{{}}
之间的代码提取出来,交给babel去解析,在这里可以复用解析ts文件的代码。
- 如何解决TS的模板字符串
`你确定要删除{{booleanA?firstAppName:booleanB?secondAppName:thirdAppName}}吗?`
表面上看,TS的模板字符串和Vue的模板变量是同样的问题。但其实不是。
- 因为Vue的模板变量尚且可以用当成TS代码去解析,TS本身已经是代码了,babel默认就解析好了。
- 对一段话中的变量解析得太细,会丢失上下文。
如果没有将这一整段话:你确定要删除{{booleanA?firstAppName:booleanB?secondAppName:thirdAppName}}吗?
保存下来,而是进一步解析,保存成了你确定要删除
和吗?
到json文件中。
做翻译工作时,就会丢失上下文,不知道这个吗?
是要干嘛,也不知道你确定要删除
是要干什么。
这样不好。
所以国际化时,也需要规范一下代码,不要在模板变量和模板字符串中写过于复杂的表达式。
总结
因为两周前开始做国际化时,除了能想到准备好翻译,在运行时替换,我对国际化一无所知,可谓被赶鸭子上架。如果我的方法中有错误或漏洞,那是很正常的。请大家帮我指出来。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。