背景
最近参与到了隔壁组自研的一个国际化工具 oic 的开发中,它能够对项目中的中文文案进行收录处理并且提翻,具体说就是我们日常做国际化的时候需要导入intl,即:import {intl} from @util/intl,并且对代码中的中文文案做处理:
console.log('这是一段文案');
// 处理为:
console.log(intl.formatMessage(id:'xxxx',defaultMessage:'这是一段文案'));
// 并且收录到某个目录下
// src/i18n/strings/zh-CN.json
{
"id":"这是一段文案"
}
// src/i18n/strings/en-US.json
{
"id":"This is a piece of text"
}
这个工具可以为我们自动完成这些重复的工作,并且支持配置 format 模板、是否自动机翻、导出目录等;为了能更好地上手这个项目地研发我对这个工具进行了简易实现,具体代码放在了我自己的仓库:github.com/yang1666204… (项目源码暂未开源,不过有对应的 npm包 可以下载使用)
具体实现
实现过程中会涉及到一些 Babel 的使用,本质上这个工具做的事情也就是使用 Babel 把源码解析成 AST,遍历 AST 并找到需要转换的节点,针对这些节点做处理生成新的 AST,再还原成代码字符串写入文件中;中间会收录文案进行机翻(调用谷歌翻译)并写入到 i18n 相关目录中,下面重点围绕这个流程来介绍。
获取源码
从入口路径对每一个目录/文件进行判断,如果是文件路径则收集起来,如果是目录进入这个目录继续对这个目录下的每一个目录/文件进行判断;
const fse = require("fs-extra");
const path = require("path");
async function traversalFiles(absPath, filePaths) {
const filelist = await fse.readdir(absPath);
for (let fileName of filelist) {
const filePath = path.join(absPath, fileName);
const stats = await fse.stat(filePath);
if (stats.isDirectory()) {
await traversalFiles(filePath, filePaths);
} else {
filePaths.push(filePath);
}
}
}
收集完毕之后,根据路径读取每一个文件的内容
const fse = require("fs-extra");
const path = require("path");
async function getFileList(absPath) {
const filePathArr = [],
fileList = [];
await traversalFiles(absPath, filePathArr);
for (let filePath of filePathArr) {
const fileContent = fse.readFileSync(filePath, { encoding: "utf-8" });
fileList.push({
fileContent`,`
filePath,
extname: path.extname(filePath),
});
}
return fileList;
}
生成抽象语法树(AST)
AST (Abstract Syntax Tree) 是 Babel 将源码经过词法分析、语法分析之后把源码抽象成了一棵包含了源码各种信息的树,可以通过这个网站 astexplorer.net/ 将你的代码转化为 AST,例如:
AST 中每一个节点都有自己的含义,有定义字面量的 Literal、有定义语句的 Statement、有定义表达式的 Expression等等,不用记忆直接打开网站看就好。
生成 AST 依赖 @babel/parser,它默认只支持解析 js 代码,jsx、typescript 这些非标准的语法解析需要指定语法插件。它暴露的 parse 接口第一个参数 code 接收字符串类型的源码,第二个参数 options 配置以什么样的方式去解析。
const parser = require("@babel/parser");
for (let file of fileList) {
if (file.extname !== ".js" && file.extname !== ".jsx") continue; // 这里只针对 js 和 jsx 文件做处理
const ast = parser.parse(file.fileContent, {
// 指定是否支持解析模块语法,"module":解析模块语法 "script":不解析模块语法
// "unambiguous":根据内容是否有import 和 export 来自动设置 module 还是 script
sourceType: "unambiguous",
plugins: ["jsx"],
});
await handleAst(ast, file, output);
}
遍历并转换抽象语法树(AST)
这一步主要依赖 @babel/traverse 这个库对 AST 做遍历和替换节点,@babel/types 去对节点类型做判断并且和生成单个节点 以及 @babel/template 来生成单/多个节点。需要处理的节点类型主要有 JSXText 例如:<span>text</span>中的 text、stringLiteral 如:type === 'car' 中的 car 以及TemplateLiteral 例如 ${text}这样的模板字符串。
实现思路:首先是注入import { intl } from '@/utils/intl';语句,需要在节点 Program 对其子节点ImportSpecifier做遍历,如果导入对象没有包含 intl,则在当前节点插入该语句,反之则不用;然后是对不同文案类型的节点做处理(目前暂时只实现了 JSXText,其余类型待补充),JSXText 类型的节点处理只需要用 template.ast 生成intl.formatMessage({id:'xxx',defaultMessage:'text'})语句的 ast,因为这个语句是一个表达式,所以我们通过 ast.expression 拿到其表达式;又因为我们这里是 JSXText,表达式需要用{}包裹,所以通过 types.jSXExpressionContainer 生成包裹后的节点。
const traverse = require("@babel/traverse").default;
const template = require("@babel/template").default;
const types = require("@babel/types");
const checkTextReg = /.*[\u4e00-\u9fa5]+.*$/; // 检查是否包含中文
// 生成有个随机的十六进制的 key
const genRanHex = (size) =>
[...Array(size)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join("");
async function handleAst(ast, file, output) {
const outputData = [];
traverse(ast, {
// 注入 import { intl } from '@/utils/intl';
Program: {
enter: (path) => {
let imported = false;
path.traverse({
ImportSpecifier(p) {
if (p.node.local.name === "intl") {
imported = true;
}
},
});
if (!imported) {
path.node.body.unshift(
template.ast(`import { intl } from '@/utils/intl'`)
);
}
},
},
// text => intl.formatMessage({id:'xxx',defaultMessage:'text'})
JSXText: {
enter: (path) => {
const value = path.node.value.trim();
if (value && checkTextReg.test(value)) {
const id = genRanHex(6);
const jsxNode = template.ast(
`intl.formatMessage({id:'${id}',defaultMessage:'${value}'})`
);
const temp = types.jSXExpressionContainer(jsxNode.expression);
path.replaceWith(temp); // 替换 AST 中的节点
outputData.push({ [id]: value }); // 收集文案
}
},
},
});
输出
输出分为两部分,一部分是依赖 @babel/generator 生成新的 AST 的代码,代码需要 prettier;另一部分是将收集到的待翻译的文案写入指定目录,中文直接写入,对应英文需要调用接口进行机翻,暂未实现,待补充。
const generator = require("@babel/generator").default;
const format = require("prettier-eslint");
const fse = require("fs-extra");
const path = require("path");
async function prettierCode(code) {
const options = {
text: code,
eslintConfig: {
rules: {
"comma-dangle": [0, "never"],
},
},
};
const formatted = await format(options);
return formatted;
}
async function outputGenerate(outputData, outputPath) {
const i18nPath = path.join(__dirname, `../${outputPath}`);
if (!fse.existsSync(i18nPath)) {
fse.mkdirSync(i18nPath);
}
try {
const tempCode = JSON.stringify(
outputData.reduce((pre, cur) => ({ ...pre, ...cur })),
null,
4
);
fse.writeFileSync(path.resolve(i18nPath, "zh-CN.json"), tempCode);
} catch (err) {
console.log("err", err);
}
}
function saveCode(code, filePath) {
fse.writeFile(filePath, code);
}
const { code } = generator(ast); //这是上一步转换后的 ast
const formatedCode = await prettierCode(code);
if (outputData.length) {
outputGenerate(outputData, output);
}
saveCode(formatedCode, file.filePath);
总结
基于 Babel 实现的自动国际化工具的主要流程基本上都跑通了,收集源码——》解析源码——》转换AST——》重新生成AST——》输出,里面还有很多功能没有实现,比如:对模板字符串的处理,后续会继续更新。完整代码收录于github.com/yang1666204… 如果觉得还不错的话,欢迎star~