Description
前端国际化(一): 手撸一个一键自动国际化工具
前言
发现已经很久没有更新技术博客,最近打算再捡起来,培养定时总结输出的习惯。
背景
之前公司打算挖掘海外客户,需要将现有以及未来的功能都支持国际化,因此便有了本次的国际化改造项目。公司的项目架构大致可以总结为一个平台基座应用 + 几十个微应用,通过 qiankun
连接。平台已经支持国际化,但是平时都没怎么测试英文版,有各种细小的问题。而微应用则未支持国际化。下面会拆成两篇文章分别介绍平台项目和微应用项目的国际化实践。
手撸一个一键自动国际化工具
作为一个技术人,肯定是不能接受手动替换翻译几十个微应用来进行国际化改造的,机械性重复的复制粘贴便是浪费生命。于是第一反应便是去找找一些开源的自动化工具。然而在尝试了几个工具后,发现质量都不太高,要么不太符合自己的需求,要么没人维护,存在这样那样的 bug。大概看了下代码,感觉也不麻烦,于是便花了两三天时间自己实现了一个。
需求分析
微应用相比于平台而言,代码量并不多,架构也相对简单,因此相比于功能完备度,更注重开发效率。既希望能够将已有的几十个微应用快速国际化,也希望后续能相对无感开发。以下是基本需求:
- 自动提取、收集代码中的中文,其中需要支持
ts
、react
的语法 - 自动将中文翻译为多种语言
- 直接使用原始中文作为
key
,相比特殊code
或者uuid
具有更好的代码可读性,也方便后续搜索关键字来定位代码 - 自动将代码中的中文替换为
i18n
函数 - 自动生成
i18n
函数的实现 - 支持多次、增量运行
预期的流程如下:
解决方案
要从源码中提取并替换某些特定代码,最常见的手段是正则替换或者抽象语法树解析。正则替换适合比较简单、单一的场景,对复杂场景处理则非常困难,例如替换模板字符串中的中文:
const test = `${test0}测试`;
若使用正则替换,经常就错误地替换为:
const test = `${test0}i18n('测试')`;
而预期的效果应该是
const test = `${test0}${i18n("测试")}`;
而抽象语法树则能轻松处理此类问题。因此我们可以通过遍历所有源码文件,通过 babel
解析生成 ast
,再通过遍历 ast
进行下一步处理,整体流程如下
抽象语法树生成
babel
能够简单的将源码字符串转换为 ast
,并且提供了插件支持 typescript
、react
、装饰器
等语法的解析
import * as babelParser from "@babel/parser";
const ast = babelParser.parse(code, {
sourceType: "module",
plugins: ["jsx", "typescript", "decorators"],
});
节点遍历
我们需要对所有的字符串节点进行遍历,从 astexplorer (一个实用的 ast 分析网站)分析可以得知,除了普通的字符串节点 StringLiteral
,模板字符串节点 TemplateLiteral
外,react
还有一个 JSXText
子节点也需要处理。
StringLiteral
普通字符串的处理比较简单,若节点值含有中文,则直接将节点替换为 i18n
函数调用即可。不过需要注意 react
属性字符串的替换与普通字符串不太一样,例如 <App title="标题" />
,若直接替换,生成的代码为 <App title=i18n('标题') />
,而正确的结果应该是 <App title={i18n('标题')} />
,因此我们需要再包裹一层 JSXExpressionContainer
节点。
StringLiteral(path) {
const value = path.node.value
if (hasChineseString(value)) {
chineseTexts.push(value)
const i18nCall = generateI18nCall(value, i18nFn)
if (path.parentPath.type === 'JSXAttribute') {
path.replaceWith(types.jSXExpressionContainer(i18nCall))
} else {
path.replaceWith(i18nCall)
}
}
}
JSXText
JSX
子节点的处理跟上面类似,不过需要注意代码格式化后的换行和缩进空格都会被认为是字符串的一部分,需要去掉这些空格,如:
<div>
<p>
很长很长很长很长很长的字符串
</p>
<div>
拿到的字符串值为 \n 很长很长很长很长很长的字符串\n
JSXText(path) {
const value = path.node.value.trim()
if (hasChineseString(value)) {
chineseTexts.push(value)
path.replaceWith(
types.jsxExpressionContainer(generateI18nCall(value, i18nFn))
)
}
}
TemplateLiteral
模板字符串处理则相对复杂一些,模板字符串节点将内部的字符和变量分成 quasis
和 expressions
两个数组分别存放。因此不能像上面一样简单地把节点替换掉就行,而是要从 quasis
去掉匹配的字符节点,在 expressions
中增加 i18n
函数调用节点,并且需要保证插入到数组正确的位置中。
TemplateLiteral(path) {
const { node } = path
const newQuasis: types.TemplateElement[] = []
const newExpressions: Array<types.Expression | types.TSType> = []
node.quasis.forEach((quasis, index) => {
const value = quasis.value.raw
if (hasChineseString(value)) {
chineseTexts.push(value)
const i18nCall = generateI18nCall(value, i18nFn)
newQuasis.push(types.templateElement({ raw: '', cooked: '' }))
newQuasis.push(types.templateElement({ raw: '', cooked: '' }))
if (index !== node.quasis.length - 1) {
newExpressions.push(i18nCall, node.expressions[index])
} else {
newExpressions.push(i18nCall)
}
} else {
newQuasis.push(quasis)
if (index !== node.quasis.length - 1) {
newExpressions.push(node.expressions[index])
}
}
})
if (newQuasis.length !== newExpressions.length + 1) {
log.error(
`模板字符串处理错误,[${newQuasis.map(q => q.value.raw).join(', ')}],
[${newExpressions.join(', ')}]`
)
}
path.replaceWith(types.templateLiteral(newQuasis, newExpressions))
}
中文匹配
我们可以通过中文 unicode 范围来判断上面的节点字符串中是否含有中文
const INCLUDE_CHINESE_CHAR = /[\u4e00-\u9fa5]+/;
export function hasChineseString(str: string): boolean {
if (!str) {
return false;
}
return INCLUDE_CHINESE_CHAR.test(str);
}
函数替换
生成函数调用的代码比较简单,这里就不做过多说明
export function generateI18nCall(text: string, funcName: string) {
return types.callExpression(types.identifier(funcName), [
types.stringLiteral(text),
]);
}
增量翻译
要支持增量翻译,主要有 3 点需要考虑
- 避免重复处理
因为我们使用中文作为 key
,因此已经处理过的 i18n('xxx')
需要忽略掉,不能重复处理,否则会造成死循环,例如 i18n(i18n(i18n(...)))
。我们可以在遍历节点的时候判断其父节点是否是 i18n
函数,若是则直接跳过处理。
let isInI18nCall = false;
if (path.parentPath?.isCallExpression()) {
const callee = path.parentPath?.get("callee");
isInI18nCall = callee.isIdentifier() && callee.node.name === i18nFn;
}
if (isIn18nCall) {
return;
}
- 语言包增量保存
这个比较简单,只需要把原有的内容读取出来,若 key
不存在则往里追加
async function updateI18nLocale(
path: string,
lang: string,
chineseTexts: Array<string | [string, string]>
) {
const filePath = nodePath.join(path, `${lang}.json`);
const json = readJSONFile(filePath);
const promises = chineseTexts.map(async (item) => {
const key = typeof item === "string" ? item : item[0];
const text = typeof item === "string" ? item : item[1];
if (!Object.prototype.hasOwnProperty.call(json, key)) {
let translatedText = text;
if (lang !== ELocale.zh) {
translatedText = await translate(text, lang);
}
json[key] = translatedText;
}
});
await Promise.all(promises);
writeJSONFile(filePath, json);
}
- 跳过没有代码变更的文件
增量开发时,往往只会修改少部分文件,因此大部分文件没有必要每次都进行代码转换、遍历等一系列步骤。如果能有效判断文件是否可以跳过解析,能大幅缩短执行时间。因此我们在每次处理结束后对每个文件计算 hash
值,下次处理前先对比一下 hash
值是否一致,就能快速跳过解析。
async function calculateHash(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const input = fs.createReadStream(filePath);
input.on("error", reject);
input.on("data", (chunk) => {
hash.update(chunk);
});
input.on("end", () => {
const fileHash = hash.digest("hex");
resolve(fileHash);
});
});
}
忽略 console.log,忽略自定义规则
前端控制台打印的日志一般是给开发者定位问题用的,没有必要进行国际化。因此字符串如果在 console.log
函数里面,则可以直接跳过。另外实际项目上会有一些特殊场景,需要支持自定义规则来忽略一些代码的国际化,例如判断一些特殊用户名
if (name === '张三') {
...
}
我们可以参考 eslint
、ts
检查的忽略规则,用注释语法来标记
// i18n-disable-next-line
if (name === '张三') {
...
}
对于这些处理,我们可以统一对 StringLiteral|TemplateLiteral|JSXText
节点统一进行一遍预遍历,判断并标记哪些节点需要跳过。
Program: {
enter(path) {
path.traverse({
'StringLiteral|TemplateLiteral|JSXText'(path) {
// 在console.log内的文字不处理
const isInConsoleLog = path.findParent(p => {
return (
p.isCallExpression() &&
types.isMemberExpression(p.node.callee) &&
types.isIdentifier(p.node.callee.object, { name: 'console' })
)
})
const startLine = path.node.loc?.start.line
const endLine = path.node.loc?.end.line
// 命中自定义忽略规则
const isDisabled = (startLine && endLine && isInDisableRule(startLine, endLine)
if (isInConsoleLog || isDisabled) {
// 标记节点
;(path.node as any).skipTransform = true
}
}
})
}
},
StringLiteral(path) {
if ((path.node as any).skipTransform) {
return
}
},
TemplateLiteral(path) {
if ((path.node as any).skipTransform) {
return
}
}
JSXText(path) {
if ((path.node as any).skipTransform) {
return
}
}
效果示例
转换前
import React, { useReducer } from "react";
// i18n-disable-next-line
const ignored = "忽略提取的中文";
export default function Example() {
const [time, addTime] = useReducer((a: number) => a + 1, 0);
return (
<div>
<button onClick={addTime}>按钮</button>
<p title="点击次数">{`已点击${time}次`}</p>
</div>
);
}
转换后
import i18n from "i18n";
import React, { useReducer } from "react";
// i18n-disable-next-line
const ignored = "忽略提取的中文";
export default function Example() {
const [time, addTime] = useReducer((a: number) => a + 1, 0);
return (
<div>
<button onClick={addTime}>{i18n("按钮")}</button>
<p title={i18n("点击次数")}>{`${i18n("已点击")}${time}${i18n("次")}`}</p>
</div>
);
}
结语
到此,一个自动国际化工具的核心功能基本都实现,已经能够满足项目需求。不过要作为一款开源工具,则需要一些更灵活的配置项,例如:
- 支持自定义
i18n
的调用方法名、导入路径、语言包的存放位置 - 支持修改
i18n
调用函数的实现,方便结合第三方国际化方案 - 支持不同的
prettier
格式化配置等
完整代码可以访问 github
仓库地址 i18n-auto-transformer,感兴趣的读者有一些好的想法和建议的话,也可以提一些 issue
或 pr