小玩具:利用AST实现代码文案的自动翻译与替换

2,634 阅读7分钟

补充:i18n常见的实现方式是以中文作为key,其他语言作为value生成语言包。比如en.json。

{
    "你好": "hello"
}

然后再通过一个库比如i18next,将文案导入并初始化,后续用t函数包裹获得对应语言环境的翻译文案。

    import enLocaleJson from '@/locales/en.json'
    import i18next, {t} from 'i18next'
    ...
    i18next.init({
      lng: 'en',
      resources: {
        en: {
          translation: {
            enLocaleJson,
          },
        },
      },
    });
    ...
    t('你好') // 在英文环境下就是 hello

📃 前言

希望读完本文的你能够对AST增加了解,对这方面的技术产生兴趣与好奇心。本文没有办法为你提供一个成熟的国际化库,如果你需要立马在项目中使用国际化,可以看看starling.bytedance.net/ 。但如果你是因为兴趣,想了解这方面的技术原理,那就继续往下看吧,相信可以本文可以给到你想要的东西~

❓ AST是个啥

AST是一个可以表示代码的树结构,而JavaScript的AST会遵循estree规范。estree规范定义了表示JavaScript语言的ast各类型节点以及包含的参数。

比如你的代码中有import a from 'b',这是es2015的语法,在estree仓库的es2015.md文件中找到。根据猜想大致是一个ImportDeclaration节点,他的specifiers是[ImportDefaultSpecifier(identifier('a'))],source是Literal('b')。

image

那如何验证这个猜想?使用astexplorer.net/可以实时预览我们代码的… a from 'b';,可以看到右侧的Tree,大致能证实刚才的猜想。

image

再来试试中文文案在AST中的表示方法。摸清这些节点的规律也方便做后续的处理。下面做了一下简单的尝试:

  1. 中文文案出现在JSX的children区域内,解析结果是JSXText

image

  1. 中文文案在JSX的props中定义,会通过StringLiteral表示

image

  1. 中文文案在Ts Enum中定义,也是通过StringLiteral表示

image

根据上面三个例子,可以猜想所有的中文文案会以StringLiteral或JSXText节点表示。先不管正确与否,先接着往下走,因为在本地开发环境如果发现问题也很方便的添加需要判断的节点。

而代码替换后文案会变成t('原来的文案'),在代码中也可以看得出来是CallExpression类型。

image

而如果原先是在JSX的children区域或props定义的,这个CallExpression还应该包裹在JSXElementContainer内。

image

从以上述内容得到的一些关键信息:

  1. 需要翻译的内容应该是可以在StringLiteral和JSXText类型节点内找到。

  2. 翻译过后的类型应该是一个CallExpression类型,也就是我们需要将上述的2个类型转变为CallExpression或JSXElementContainer。

  3. 假设代码中没有引入t这个函数,我们可以插入一个importDeclaration节点来引入。

  4. 处理边界问题,比如已经引入了t的不应该再引入,已经变更为t(xxx)的节点中的xxx依然是StringLiteral但是因为已经处理过了应该跳过考虑。

所以大致的操作思路应该就有了~

💡 预想的操作步骤

  1. 获取项目中所有的js,tsx,ts文件的路径

  2. 根据文件路径读取代码内容,将代码转换为AST

  3. 遍历AST节点,重点关注StringLiteral与JSXText节点,提取文案并判断是否是中文,如果是则记录下来。

  4. 判断当前的AST节点有没有处理(假设当前节点已经被处理,则字符串StringLiteral应该是CallExpression的value加,并且这个CallExpression的被调用者callee名称为't')过,如果已经处理过则跳过,否则应当替换当前的AST node。

  5. 根据修改后的AST生成新代码写入原来的路径

⬇️ npm install一下需要用到的包

根据上面的思路可以的大致的去找一下要用到的包了~

步骤一找路径,需要用到glob这个库。glob库可以利用glob风格来匹配所有的代码文件。

glob(path.resolve(__dirname, '../**/*.{js,ts,tsx}'), (err, matches) => {
    // matches 匹配到的文件路径 string[]
})

步骤二,将代码转换为AST,要用到@babel/core与@babel/parser。

步骤三与四,遍历AST节点并修改需要用到@babel/traverse和@babel/types。前者会深度优先遍历AST,后者可以提供一些工具方法帮我们判断节点类型(比如通过t.isStringLiteral(node)判断这个节点类型是否为StringLiteral)以及创建AST节点(比如t.callExpression(...args)可以创建CallExpression类型的节点)。

为了更方便我们async/await语法还可以安装一个pify,将回调函数转换为promise。

文案自动翻译可以用各大云厂商提供的机器翻译SDK,批量翻译多国语言。因为懒得配axios,我这里测试用的是腾讯云的SDK。

安装lodash进行数据去重以及组装我们需要的数据格式。

🪜 最终代码

出于不想让代码变复杂的考虑,文案扫描和代码替换的两个动作我分开写了。

文案扫描:i18n-scan.mjs 专门用来扫描中文文案

代码替换:i18n-replace.mjs 专门用来替换AST node并根据新的AST生成新代码

/scripts/i18n-scan.mjs

import parser from '@babel/parser';
import traverser from '@babel/traverse';
import fs from 'fs';
import glob from 'glob';
import _ from 'lodash';
import path from 'path';
import pify from 'pify';
import tencentCloud from 'tencentcloud-sdk-nodejs';

// esm模式的node环境不支持__dirname了所以写了一下这个
const __dirname = import.meta.url.match(/(\/[^\/]+)+(?=\/)/)?.[0];

const supportedLocales = ['zh', 'en', 'ja', 'zh-TW']

const resolveChineseWordsFromTSX = (path) =>
  new Promise((resolve, reject) => {
    fs.readFile(path, 'utf-8', (err, data) => {
      const hasChinese = (str) => /\p{sc=Han}/gu.test(str);
      const ast = parser.parse(data, { plugins: ['jsx', 'typescript'], sourceType: 'module' });
      const traverse = traverser.default;
      const words = [];
      traverse(ast, {
        enter(path) {
        // 这边用t.isStringLiteral()和t.isJSXText()进行判断也行
          if (path.node.type !== 'StringLiteral' && path.node.type !== 'JSXText') return;
          const value = path.node.value?.replace(/^[\n ]+/, '')?.replace(/[\n ]+$/, ''); // JSXText往往有一些/n      /t       会让文案长度增加,不利于节省流量
          if (!hasChinese(value)) return;
          words.push(value);
        },
      });
      resolve(words);
    });
  });

glob(path.resolve(__dirname, '../**/*.{ts,tsx}'), async (err, matches) => {
// 初始化翻译SDK,你可以换成任何一个能翻译的接口
  const tmtClient = tencentCloud.tmt.v20180321.Client;
  const client = new tmtClient({
    credential: {
      secretId: '',
      secretKey: '',
    },
    region: 'ap-shanghai',
  });

  const chineseWords = [];

  for (const match of matches) {
    const words = await resolveChineseWordsFromTSX(match);
    chineseWords.push(...words);
  }

  const uniqChineseWords = _.uniq(chineseWords);

// 这里可以用pify直接promisify,但是当时忘记用了。。
  const translate = (list, target) =>
    new Promise((resolve, reject) => {
      client.TextTranslateBatch(
        {
          SourceTextList: list,
          Source: 'zh',
          Target: target,
          ProjectId: 0,
        },
        (err, resp) => {
          if (err) return reject(err);
          resolve(resp?.TargetTextList);
        }
      );
    });

  const jsonZH = _.zipObject(uniqChineseWords, uniqChineseWords);
  for (const locale of supportedLocales) {
    const localeJsonPath = path.resolve(__dirname, '../locales/' + locale + '.json');
    if (locale === 'zh') {
      fs.writeFileSync(localeJsonPath, JSON.stringify(jsonZH));
      continue;
    }

    // 尝试读取已有的[locale].json文件,只新翻译当前没有的文案
    // 因为调用接口会花钱,全量翻译成本贵。而且只翻译增量文案也能不变动老文案上可能人为修改过的内容
    const needToTranslate = [];
    let jsonObject = {};
    if (fs.existsSync(localeJsonPath)) {
      const json = await pify(fs.readFile)(localeJsonPath, 'utf-8');
      jsonObject = JSON.parse(json);
      needToTranslate.push(...Object.keys(_.omit(jsonZH, Object.keys(jsonObject))));
    } else {
      needToTranslate.push(...Object.keys(jsonZH));
    }

    if (!needToTranslate.length) continue;

    const translationResult = await translate(needToTranslate, locale);
    const newTranslationObject = _.zipObject(needToTranslate, translationResult);
    const localeJson = { ...jsonObject, ...newTranslationObject };
    const sortedLocaleJson = {};
    // 咳咳,这边做了一下排序,是为了方便后续的优化
    // 目前在外语环境下依然会用中文作为key,如果所有文案的顺序保持一致,未来可以用index作为key,节省空间
    Object.keys(jsonZH).forEach((key) => (sortedLocaleJson[key] = localeJson[key]));

    fs.writeFileSync(localeJsonPath, JSON.stringify(sortedLocaleJson), 'utf-8');
  }
});

/scripts/i18n-replace.mjs

import generator from '@babel/generator';
import parser from '@babel/parser';
import traverser from '@babel/traverse';
import t from '@babel/types';
import fs from 'fs';
import glob from 'glob';
import path from 'path';
import pify from 'pify';

// 用正则判断一下是不是中文内容
const hasChinese = (str) => /\p{sc=Han}/gu.test(str);
// 判断当前代码已经被替换过的规则是当前节点的父节点是一个函数调用,并且调用的函数是t
const needToReplace = (path) => {
  const parentNode = path.parentPath?.node;
  if (t.isCallExpression(parentNode) && parentNode?.callee?.name === 't') {
    return false;
  }
  return true;
};

const replaceStringLiteralToI18nCallExpression = async (path) => {
  const code = await pify(fs.readFile)(path, 'utf-8');
  const ast = parser.parse(code, { plugins: ['jsx', 'typescript'], sourceType: 'module' });
  const traverse = traverser.default;

  const resolvedChineseWords = [];
  let firstImportDeclarationPath;
  let needToInsertImportDeclaration = true;

  traverse(ast, {
    enter(path) {
      const node = path.node;
      if (!needToReplace(path)) return;

      const nodeType = node.type;
      const supportedNodeType = ['StringLiteral', 'JSXText'];
      if (!supportedNodeType.includes(nodeType)) return;

      const nodeValue = path.node.value?.replace(/^[\n ]+/, '')?.replace(/[\n ]+$/, '');
      if (!hasChinese(nodeValue)) return;
      resolvedChineseWords.push(nodeValue);

      const parentNode = path.parentPath.node;
      const parentType = parentNode.type;
      const tCallExpression = t.callExpression(t.identifier('t'), [t.stringLiteral(nodeValue)]);
      switch (parentType) {
        case 'JSXElement':
        case 'JSXAttribute':
        case 'JSXText': {
        // 在JSX里面的内容因为需要{}来包裹变量,因此需要将函数调用放入JSXExpressionContainer中
          path.replaceWith(t.JSXExpressionContainer(tCallExpression));
          break;
        }
        default: {
          path.replaceWith(tCallExpression);
          break;
        }
      }
    },
    ImportDeclaration(path) {
      if (!firstImportDeclarationPath) firstImportDeclarationPath = path;

      const node = path.node;
      const sourceValue = node.source.value;
      const specifiers = node.specifiers;

      if (sourceValue === 'i18next') {
        for (let specifier of specifiers) {
          if (t.isImportSpecifier(specifier)) {
            if (specifier?.imported?.name === 't') {
              needToInsertImportDeclaration = false;
              return;
            }
          }
        }
      }
    },
  });

  if (!resolvedChineseWords.length) return;
  if (needToInsertImportDeclaration) {
    const importDeclaration = t.importDeclaration(
      [t.importSpecifier(t.identifier('t'), t.identifier('t'))],
      t.stringLiteral('i18next')
    );
    firstImportDeclarationPath.insertBefore(importDeclaration);
  }

// 我这边用了esm的node,但是babel那边的export是cjs格式的,蛮奇怪嘻嘻
// 所以得加一个暂时default解决下问题
  const generate = generator.default;
  fs.writeFileSync(
    path,
    generator.default(ast, { jsescOption: { minimal: true } }, code).code,
    'utf-8'
  );
};

const __dirname = import.meta.url.match(/(\/[^\/]+)+(?=\/)/)?.[0];
glob(path.resolve(__dirname, '../{pages,components}/**/*.{ts,tsx}'), (err, matches) => {
  matches.forEach(replaceStringLiteralToI18nCallExpression);
});

package.json

把命令行存入npm scripts方便调用。

  "scripts": {
    "i18n:replace": "node ./src/scripts/i18n-replace.mjs",
    "i18n:scan": "node ./src/scripts/i18n-scan.mjs"
  },

🎉 使用方法

执行npm run i18n:scan扫描文案

执行npm run i18n:replace替换代码中未翻译的内容

// 处理前
import React from 'react'

const Demo = () => {
    return <div>测试文案</div>
}

export default Demo
// 处理后
import {t} from 'i18next'
import React from 'react'

const Demo = () => {
    return <div>{t('测试文案')}</div>
}

export default Demo

📃 结语

不知道阅读完你是否对如何利用AST搭建基建工具有了一个初步了解,如果有那恭喜你又收获了一份精彩。如果有疑惑也欢迎评论区聊聊~

我对AST和babel这些技术蛮感兴趣,周末好好研究了一番,感觉收获还是蛮大的。经过这样一个小玩具的折腾,大致了解了AST的开发步骤。这个小玩具还有一部分没有做,就是没有把中文文案替换为index,这样少一半的文案节约的体积还是很可观的,特别是在文案非常多的情况下。

把中文文案替换为index我的一些思路,我认为在构建阶段进行替换是合适的。而要在构建阶段替换可以写一个webpack plugin。这个插件的功能如下:

  1. 扫描所有t(key),查找key在zh.json中的行号,将key替换为行号

  2. 将import导入的json文案,通过Object.values(json)转换为数组

这样就能通过index索引到文案内容,节约50%的空间~

🔗 参考资料

Step-by-step guide for writing a custom babel transformation

github.com/jamiebuilds…

starling.bytedance.net/