基于Babel的自动化翻译工具实现

274 阅读4分钟

背景

最近参与到了隔壁组自研的一个国际化工具 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,例如:

image.png 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~