用babel插件将现有项目硬编码中文自动国际化
用babel插件将现有项目硬编码中文自动国际化
背景
前段时间接手了一个祖传项目,现在因业务需求,需要对产品进行国际化。 这个工作说起来也简单,但是就是个体力活啊,再说了,花费这么多时间对自己的成长可以一点用也没有啊,万一后面还有其它项目,需要做类似的工作呢,咱这次对下一次可是一点帮助也没有啊,这完全不符合我推崇的可迭加的进步啊。
想到自己之前也接触过AST和babel,看过神说要有光(公号「神光的编程秘籍」)的掘金小册《Babel 插件通关秘籍》,虽然里面的做法不太符合我的项目,但是拿来参考借鉴是足够的,网上搜了搜现成的解决方案,没找到实用的,只能自己动手干起来了,这也不失为一个很好的练习契机嘛。这个项目其实最近一年的新代码已经接入i18n了,但是历史旧账更多,没人愿意动,这次就让我练手吧。
我们的宗旨是尽可能做到用代码解决问题,尽可能地减少人工干预。
提取待翻译中文
首先我们需要先把代码已经硬编码的中文识别出来,这样才能给产品拿去翻译(或者调用翻译api来翻译),怎么识别呢,我们需要通过正则表达式来实现。
/[^\x00-\xff]/
能够识别双字节字符,我们可以借助它来判断,我们平时用到的中文标点符号也是。
我们在编辑器里面用这个正则表达式就能搜到项目里面有多少中文,/[^\x00-\xff]/
其实检测的不只是中文,准确来说叫非英文,后面我们还是简单点直接叫中文吧。
AST
代码里面这么多中文,我们怎么知道它们是什么呢,这就需要用到AST(抽象语法树)了。我们可以借助https://astexplorer.net/ 来查看了。AST的基础知识需要读者自行查阅资料学习了。
我们可以搞一点代码测试下
import React from 'react';
// 多语言
import { IntlProvider, addLocaleData } from 'react-intl'
function Test(props) {
console.log("abc", "你好");
const data = {
value: "文本"
}
const abc = "ctx" + data.value + "嗯";
const efg = `${abc}好的`
return (
<div title="标题">哈哈</div>
);
}
export default Test;
这里面基本包括React项目可能会出现中文的地方了,虽然项目中实际写法比这个复杂多了,我们还是也先确保能把这个demo转换成功把。
我们会发现这里面的中文主要能分成三类
-
StringLiteral 这也是最多的
-
JSXText 这种最简单
-
TemplateElementTemplateLiteral 这种最少 (为什么不是TemplateElement后面会提到)
我们先把中文找出来,让产品翻译去 上面的三类我们只需要把console里面的中文排除掉就行了,如果你懒的话也可以。。。
编写插件
我们来下这个插件bablePluginReplaceToI18nKey.js.
const fs = require('fs');
const result = new Set();
module.exports = function({ types, template }, options, dirname) {
return {
visitor: {
'StringLiteral|JSXText|TemplateElement': function(path, state) {
const value = path.isTemplateElement() ? path.node.value.raw : path.node.value || '';
if (/[^\x00-\xff]/.test(value)) {
console.log('中文 ~ ', value);
if (path.findParent(p => p.isCallExpression()) && (path.parent && path.parent.callee && path.parent.callee.object && path.parent.callee.object.name === 'console')) {
console.log('skip console ~ ', value);
return;
}
if (!result.has(value)) {
result.add(value);
fs.writeFileSync(thePath.join(__dirname, './toTranslate.txt'), value + '\n', {
encoding: 'utf8',
flag: 'a+'
})
}
}
}
};
};
}
运行插件
我们先简单利用项目现成的webpack里面的babel-loader来运行吧。
{
test: /(\.jsx|\.js)$/,
use: {
loader: "babel-loader",
options: {
plugins: [bablePluginReplaceToI18nKey] // 加上刚才编写的babel插件即可
}
},
exclude: /node_modules/
}
中文结果
我们需要翻译的中文就在txt里面了
替换中文
我们拿到翻译好的多语言数据,大概这样的。
下面我们需要如下几个步骤
整理出写入代码的key。
为了尽可能的示意,不推荐用纯粹的C0002或者类似的无意义的作为key了,可以使用翻译好的英文作为key,有的句子可能很长,所以我们可以选取英文翻译前面的四个单词作为key,中间用_链接,如果有重复的话,我们再加上前面的序号确保唯一,前面最后再加上一点前缀,方便以后自动这部分key是用babel自动完成的,以后哪天看了代码中的key感觉怪怪的,就知道原来当时是批量处理,情有可原,哈哈。
产品是以excel文件形式给我的,我需要引入MARKDOWN_HASHbf3e2924dcba1c52da01b5eda1111b2bMARKDOWNHASH
这个npm帮助解析下
图中的第一个中文就可以用下面的key,也可以全部转成小写,像我们公司的公共的shark平台对key还有限制,只能是字母、数字、符号只能是-.,所以还需进行一下处理。
const codeText = getItemValue(`B${rowNum}`);
const zhCNText = getItemValue(`C${rowNum}`);
const enUSText = getItemValue(`E${rowNum}`);
const enUSArr = enUSText.split(' ');
const key = 't1_' + enUSArr.slice(0, 4).join('_').replace(/[^0-9A-Z\._-]/ig, '');
t1_Language_setting_successful。
t1代表第一次自动翻译,前缀加不加,怎么加,自己喜欢就好,个人推荐加上。
整理好的中文和key的映射关系如下。
zhCN2key.js
编写插件
打算把代码中的硬编码的中文改成Intl('t1_Language_setting_successful')
,这样后续具体的多语言功能有Intl
这个方法来完成即可。
'StringLiteral': function (path, state) {
const value = path.node.value || '';
handler(path, state, value);
},
'JSXText': function (path, state) {
// JSXText中会有很多无实际意义的换行等信息,需要移除此干扰
const value = (path.node.value || '').replace(/\n/g, '').trim();
if (value) {
handler(path, state, value);
}
},
具体处理过程都在handler中
function handler(path, state, value) {
if (/[^\x00-\xff]/.test(value)) {
const replaceExpression = getReplaceExpression(path, value);
if (replaceExpression) {
save(state.file, value); // 后面会写到
path.replaceWith(replaceExpression);
path.skip();
}
}
}
function getReplaceExpression(path, value) {
const normalValue = value.replace(/\r\n/, '');
let result = zhCN2key[normalValue]; // zhCN2key就是上一步处理好的中文和key映射关系
// 直接使用自己的Intl来处理
let replaceExpression = api.template.ast(`Intl('${result}')`).expression;
console.log('value ~ ', value, replaceExpression.type);
// JSXAttribute时可能需要根据实际代码处理下
if (path.findParent(p => p.isJSXAttribute())) {
if (!findParentLevel(path, p=> p.isJSXExpressionContainer())
&& !findParentLevel(path, p=> p.isLogicalExpression())
&& !findParentLevel(path, p=> p.isConditionalExpression())
&& !findParentLevel(path, p=> p.isObjectProperty(), 1)
) {
// 就是在外面包裹一层{}
replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
}
} else if (path.isJSXText()) {
replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
}
return replaceExpression;
}
function findParentLevel(path, callback, max = 2) {
let count = 0;
let myPath = path;
while ((count < max) && (myPath = myPath.parentPath)) {
count ++
if (callback(myPath)) return myPath;
}
return null;
}
我们可以看到JSXAttribute
的时候是有几个很不和谐的判断。。。这个也是我在实际运行代码的时候碰到的。
报错主要体现在不该不该加上{}
,这里不能简单粗暴的根据path.findParent(p=> p.isJSXExpressionContainer()
来判断是否应该放弃加上{}
,官方的方法path.findParent
是直接用while一直往上找的直到找到满足判断条件或者没有父级为止。
比如这样的场景
<Form className={styles['keywordform--wrapper']} {...formItemLayout} >
<FormItem label="关键词" >
{getFieldDecorator('keyword', {
rules: [
{ required: true, message: '请输入关键词', },
],
initialValue: editData.keyword || '',
})(<Input disabled={isUpdate} placeholder="请输入关键词" />)}
</FormItem>
</Form>
如果只是判断父级isJSXExpressionContainer
就会认为此处不用加{}
,实际上是要的,在项目中可能有很多没法提前预知的场景,所以我们需要根据报错调整下判断逻辑。
首先说一下,官方的方法path.findParent
是没法指定线上找的层级的,所以我用了自己写的findParentLevel
加了一个max的参数,为了避免path
在前面判断过程中被修改,方法内容引入了新变量myPath
。
-
!findParentLevel(path, p=> p.isJSXExpressionContainer()) 当前向上两级父级均未包裹
{}
报错的意思是这里不应该包裹在
{}
了,因为已经代码里面已经包裹了{}
,这里需要继续保持是表达式 -
!findParentLevel(path, p=> p.isLogicalExpression()) 当前向上两级父级均不是条件表达式
||
-
!findParentLevel(path, p=> p.isConditionalExpression()) 当前向上两级父级均不是逻辑表达式
? :
和上面的第二条类似 -
!findParentLevel(path, p=> p.isObjectProperty(), 1) 当前向上一级父级均不是对象的属性
? :
总之就是如果在jsx里面的写法越简单,越不容易报错,jsx内联的骚气写法越多越不容易提前想到,这个时候就需要报错来提醒我们了。
字符串模板为什么用TemplateLiteral而不用TemplateElement
const efg = `${abc}好的`;
本来是想将上述代码转换成
const efg = `${abc}${Intl('好的')}`;
而实际转换成了
const efg = `${abc Intl('好的');
后面的这部分没了
}`
然后在网上看了下,字符串模板大家都是处理TemplateLiteral,就没有继续走TemplateElement这条路了。
具体方案是
TemplateLiteral: function (path, state) {
const { expressions, quasis } = path.node;
let enCountExpressions = 0;
quasis.forEach((node, index) => {
const raw = node.value.raw;
if (/[^\x00-\xff]/.test(raw)) {
let newCall = t.stringLiteral(raw);
expressions.splice(index + enCountExpressions, 0, newCall);
enCountExpressions++;
node.value = {
raw: '',
cooked: '',
};
// 每增添一个表达式都需要变化原始节点,并新增下一个字符节点
quasis.push(
t.templateElement(
{
raw: '',
cooked: '',
},
false,
),
);
}
});
quasis[quasis.length - 1].tail = true;
}
直接根据TemplateLiteral的quasis中的TemplateElement来改动对应的expressions和quasis,这方法不错,有点根据效果推测产生原因的味道。
现在再次运行替换操作,基本不会因为其它报错而中断了。
上面只是插件的主要功能,还有下面的细节需要处理
- 引入自己的
Intl
方法 还是用babel插件解决,判断当前文件是否引入过Intl,如果没有引入则引入import { Intl } from 'i18nUtils'
。 为了简化对引入路径的计算过程,建议直接在webpack和ts.config.js里面设置别名来解决。webpack
resolve: { alias: { i18nUtils: path.resolve(__dirname, '../src/utils/intl.js'), }, extensions: ['.ts', '.tsx', '.js', 'jsx', '.json'] },
tsconfig.json
"baseUrl": "./src", "paths": { "i18nUtils": ["utils/intl.js"] },
注入Intl方法的引用
Program: {
enter(path, state) {
let imported;
path.traverse({
ImportDeclaration(p) {
const source = p.node.source.value;
const importedInfo = p.node.specifiers.find(item => item.imported && item.imported.name === 'Intl');
// utils/intl.js 自身就不必引入了,直接跳过
if (source.includes('intl')) {
imported = true;
}
if (!imported && importedInfo) {
imported = true;
}
}
});
if (!imported) {
const importAst = api.template.ast(`import { Intl } from 'i18nUtils'`);
path.node.body.unshift(importAst);
}
},
},
- 为代码替换做准备 因为替换过程是将带中文原代码直接替换成移除中文的新代码,我们需要记录下当前文件会在运行插件的时候会替换掉几个中文,如果不需要替换的话,我们尽量就不要替换这个文件了,因为在转换的过程中代码的缩进、换行、分号、注释的位置可能会有所变动,虽然不影响运行,但是没必要改动就不改动了吧。
插件运行过程和代码替换可能没法直接传递数据,我们借助一个本地文件来传递下数据,记录下插件认为当前文件需要替换几处中文。
上面中的save
方法就是干这个的
function save(file, value){
const changedArr = file.get('changedArr');
changedArr.push(value);
file.set('changedArr', changedArr);
}
我们babel插件中这样处理
pre(file) {
console.log('pre ~ ');
file.set('changedArr', []);
},
post(file) {
console.log('post ~ ');
const changedArr = file.get('changedArr');
fs.writeFileSync(thePath.join(__dirname, './changedArr.txt'), JSON.stringify(changedArr));
},
运行插件
这个时候我们不太适合依靠webpack了,如果直接在webpack.dev.config.js里面引入我们的babel插件的话,只会替换在dev-server运行内存中的代码,本地文件并没有进行替换。
我们最好是写一个自己的替换入口index.js
const { transformFromAstSync } = require('@babel/core');
const parser = require('@babel/parser');
const autoI18nPlugin = require('./ChineseReplacePlugin');
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src');
// 记录下每个文件改动了哪个中文
const resultMap = {};
// 递归读取文件(夹)
function fsRead(url) {
if (fs.existsSync(url)) {
if (fs.statSync(url).isDirectory()) {
const files = fs.readdirSync(url);
files.forEach(function (file) {
fsRead(path.join(url, file));
});
} else {
handler(url);
}
} else {
console.log(`${url} not found`);
}
}
function handler(filePath) {
if (!/.[j|t]s(x)?$/.test(filePath)) {
console.log('^^handler ignore^^', filePath)
return;
}
console.log('+handler+', filePath)
const sourceCode = fs.readFileSync(filePath, {
encoding: 'utf-8'
});
if (!/[^\x00-\xff]/.test(sourceCode)) {
console.log('-handler- skipped', filePath)
return;
}
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
plugins: ['jsx', 'typescript', 'classProperties', 'decorators-legacy']
});
const { code } = transformFromAstSync(ast, sourceCode, {
plugins: [[autoI18nPlugin]]
});
const data = fs.readFileSync(path.join(__dirname, './changedArr.txt'), {
encoding: 'utf-8'
});
console.log('changed data ~ ',data);
// 只有带中文的文件才进行改动
if (data.trim() !== '[]') {
fs.writeFileSync(filePath, code);
resultMap[filePath] = data
}
console.log('-handler-', filePath)
}
fsRead(filePath);
console.log('over ~ ', resultMap);
直接执行node index.js
就坐等babel的好消息了。
源代码
上面的代码为截取的,可能引用不完整,完整的代码在github里面 https://github.com/shadowprompt/babel-plugin-replace-to-i18n-key
总结
利用AST我们能更加方便的理解纯文本的源代码,借助babel我们能更加方便的操作AST,然后再生成新的代码,从而达到我们的自动替换掉代码中硬编码的中文的目的。
在本文准备结尾的时候想到对字符串模板的处理还有点问题,Google搜索babeljs generate TemplateElement ast,意外发现AST搞定i18n,这不跟我的搞法类似吗,那我还折腾啥,还不如自己照着弄就完事了啊。。。
参考
- 分类:
- Web前端
相关文章
Software being installed: Android Development Tools 16.0.1.v201112150204-238534 (com.android.ide.eclipse.adt.feature.group 16.0.1.v201112150204-238534)
怎么解决呢 官方的解决方案 During installation, there's an error about requiring org.eclipse.wst.sse.ui. How d 阅读更多…
WordPress项目做vue的后台管理系统
鉴于最近很多朋友想利用手上的wordpress博客来做后台系统,前端页面想使用自己更加上手的vue或者react来开发。 这个想法和我当时差不多啊,我当时就是不想继续使用自己的WordPress做前 阅读更多…
CKEditor系列(一)CKEditor4项目怎么跑起来的
我们先看CKEditor的入口ckeditor.js,它里面有一部分是压缩版,压缩版部分对应的源码地址为src/core/ckeditor_base.js // src/core/ckedit 阅读更多…
vue多语言的解决方案不只是 vue-i18n,前端+后端完整解决方案
网上搜很多vue多语言的,一般都是介绍vue-i18n怎么使用,当然这是不错的,但是我们如果只是讲这个的话,只是解决了静态文字的多语言化。 这一部分我们也简单讲一下 npm install 阅读更多…