互联网产品开发完以后可能会为不同地区的人提供服务,不同地区的语言不同,这就对软件提出了支持国际化的需求。
国际化要把软件中的文字、货币符号、数字等转成当地所支持的格式,对前端代码来说,需要把所有界面上的字符串字面量转成根据 locale 动态获取的。如果代码中有很多需要改动的代码,那工作量还是很大的。
我们知道 babel 可以用于分析代码和转换代码,那么基于 babel 自然可以做到自动的国际化。
思路分析
要转换的是字符串,主要是 StringLiteral 和 TemplateLiteral 节点,把它们替换成从资源包取值的形式。
比如:
const a = "中文";
替换为:
import intl from "intl";
const a = intl.t("intl1");
而模版字符串也要做替换
const name = "babel";
const str = `你好 ${name}`;
替换为:
const name = "babel";
const str = intl.t("intl2", name);
intl.t 是根据 key 从 bundle 中取值的,语言包 bundle 里存储了各种语言环境下 key 对应的文案:
// zh_CN.js
module.exports = {
intl1: "中文",
intl2: "hello {placeholder}",
};
// en_US.js
module.exports = {
intl1: "English",
intl2: "hello {placeholder}",
};
intl.t 是从资源 bundle 中取值,并且用传入的参数替换其中的占位符。
也就是把 {0} {1} {2} 替换为传入的参数。
const locale = "zh-CN";
intl.t = function (key, ...args) {
let index = 0;
return bundle[locale][key].replace(/\{placeholder\}/, () => args[index++]);
};
要实现这种转换,需要做三件事情:
- 如果没有引入 intl 模块,就自动引入,并且生成唯一的标识符,不和作用域的其他声明冲突
- 把字符串和模版字符串替换为 intl.t 的函数调用的形式
- 把收集到的值收集起来,输出到一个资源文件中
有一点需要注意的是在 jsx 中,必须带 {}
const a = <component content="content"></component>;
要替换为 {} 包裹的表达式
import intl from "intl";
const a = <component content={intl.t("intl2")}></component>;
{} 节点叫做 JSXExpressionContainer,顾名思义,就是 jsx 中的表达式容器,用于实现插值语法。
再就是对于模版字符串中的表达式 ${} 要单独处理下。
有的时候,确实不需要转换,我们可以支持通过注释来配置:
const a = /*i18n-disable*/ "content";
带有 /*i18n-disable*/ 注释的字符串就忽略掉。
代码实现
首先,我们搭好插件的基本结构:
const { declare } = require("@babel/helper-plugin-utils");
const autoTrackPlugin = declare((api, options, dirname) => {
api.assertVersion(7);
return {
pre(file) {},
visitor: {},
post(file) {},
};
});
module.exports = autoTrackPlugin;
然后,我们实现 intl 的 import,这个可以在 Program 的 enter 阶段判断: 如果没引入 intl 模块,则引入,并且生成唯一 id 记录到 state 中:
visitor: {
Program: {
enter(path, state) {
let imported;
path.traverse({
ImportDeclaration(p) {
const source = p.node.source.value;
if(source === 'intl') {
imported = true;
}
}
});
if (!imported) {
const uid = path.scope.generateUid('intl');
const importAst = api.template.ast(`import ${uid} from 'intl'`);
path.node.body.unshift(importAst);
state.intlUid = uid;
}
}
}
}
然后,还要对所有的有 /*i18n-disable*/ 注释的字符串和模版字符串节点打个标记,用于之后跳过处理。然后把这个注释节点从 ast 中去掉。
visitor: {
Program: {
enter(path, state) {
path.traverse({
'StringLiteral|TemplateLiteral'(path) {
if(path.node.leadingComments) {
path.node.leadingComments = path.node.leadingComments.filter((comment, index) => {
if (comment.value.includes('i18n-disable')) {
path.node.skipTransform = true;
return false;
}
return true;
})
}
if(path.findParent(p => p.isImportDeclaration())) {
path.node.skipTransform = true;
}
}
});
}
}
}
之后处理 StringLiteral 和 TemplateLiteral 节点,用 state.intlUid + '.t' 的函数调用语句来替换原节点。
注意:替换完以后要用 path.skip 跳过新生成节点的处理,不然就会进入无限循环
比较麻烦的是模版字符串需要吧 ${} 表达式的部分替换为 {placeholder} 的占位字符串。
StringLiteral(path, state) {
if (path.node.skipTransform) {
return;
}
let key = nextIntlKey();
save(state.file, key, path.node.value);
const replaceExpression = getReplaceExpression(path, key, state.intlUid);
path.replaceWith(replaceExpression);
path.skip();
},
TemplateLiteral(path, state) {
if (path.node.skipTransform) {
return;
}
const value = path.get('quasis').map(item => item.node.value.raw).join('{placeholder}');
if(value) {
let key = nextIntlKey();
save(state.file, key, value);
const replaceExpression = getReplaceExpression(path, key, state.intlUid);
path.replaceWith(replaceExpression);
path.skip();
}
},
上面用到的 getReplaceExpression 是生成替换节点的一个方法:
要判断是否在 JSXAttribute 下,如果是,则必须要包裹在 JSXExpressionContainer 节点中(也就是{})
如果是模版字符串字面量(TemplateLiteral),还要把 expressions 作为参数传入。
function getReplaceExpression(path, value, intlUid) {
const expressionParams = path.isTemplateLiteral()
? path.node.expressions.map((item) => generate(item).code)
: null;
let replaceExpression = api.template.ast(
`${intlUid}.t('${value}'${expressionParams ? "," + expressionParams.join(",") : ""})`
).expression;
if (
path.findParent((p) => p.isJSXAttribute()) &&
!path.findParent((p) => p.isJSXExpressionContainer())
) {
replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
}
return replaceExpression;
}
intal 的 key 也需要生成唯一的。
let intlIndex = 0;
function nextIntlKey() {
++intlIndex;
return `intl${intlIndex}`;
}
save 方法则是收集替换的 key 和 value,保存到 file 中
function save(file, key, value) {
const allText = file.get("allText");
allText.push({
key,
value,
});
file.set("allText", allText);
}
这个是在 pre 初始化的,并且在 post 阶段取出来用于生成 resource 文件,生成位置也是通过插件的 outputDir 参数传入的。
pre(file) {
file.set('allText', []);
},
post(file) {
const allText = file.get('allText');
const intlData = allText.reduce((obj, item) => {
obj[item.key] = item.value;
return obj;
}, {});
const content = `const resource = ${JSON.stringify(intlData, null, 4)};\nexport default resource;`;
fse.ensureDirSync(options.outputDir);
fse.writeFileSync(path.join(options.outputDir, 'zh_CN.js'), content);
fse.writeFileSync(path.join(options.outputDir, 'en_US.js'), content);
}
我们来测试一下效果:
当输入为:
import intl from "intl2";
/**
* App
*/
function App() {
const title = "title";
const desc = `desc`;
const desc2 = /*i18n-disable*/ `desc`;
const desc3 = `aaa ${title + desc} bbb ${desc2} ccc`;
return (
<div className="app" title={"测试"}>
<img src={Logo} />
<h1>${title}</h1>
<p>${desc}</p>
<div>{/*i18n-disable*/ "中文"}</div>
</div>
);
}
输出为:
import _intl from "intl";
import intl from "intl2";
/**
* App
*/
function App() {
const title = _intl.t("intl1");
const desc = _intl.t("intl2");
const desc2 = `desc`;
const desc3 = _intl.t("intl3", title + desc, desc2);
return (
<div className={_intl.t("intl4")} title={_intl.t("intl5")}>
<img src={Logo} />
<h1>${title}</h1>
<p>${desc}</p>
<div>{"中文"}</div>
</div>
);
}
并且生成了相应的资源文件:
const resource = {
intl1: "title",
intl2: "desc",
intl3: "aaa {placeholder} bbb {placeholder} ccc",
intl4: "app",
intl5: "测试",
};
export default resource;
其实我们可以更进一步,比如自动对接翻译 api,来生成翻译后的资源文件等,这个案例只是提供思路,大家如果工作中用到了,可以继续扩展和完善。
滴滴、字节等公司都有类似的方案,比如滴滴的 di18n
或者做成 VSCode 插件:
总结
这一节我们实现了自动国际化的案例,主要是要替换字符串和模版字符串为对应的函数调用语句,要做模块的自动引入。引入的 id 要生成全局唯一的,注意 jsx 中如果是属性的替换要用 {} 包裹。
自动国际化的方案也是大厂都在用的,原理就是通过 AST 分析出要转换的代码,然后自动转换。
(代码在这里,建议 git clone 下来通过 node 跑一下)
