基于AST实现国际化文本提取

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:霜序

前言

在阅读本文之前,需要读者有一些 babel 的基础知识,babel 的架构图如下:

确定中文范围

先需要明确项目中可能存在中文的情况有哪些?

js 复制代码
const a = '霜序';
const b = `霜序`;
const c = `${isBoolean} ? "霜序" : "FBB"`;
const obj = { a: '霜序' };
// enum Status {
//     Todo = "未完成",
//     Complete = "完成"
// }
// enum Status {
//     "未完成",
//     "完成"
// }
const dom = <div>霜序</div>;
const dom1 = <Customer name="霜序" />;

虽然有很多情况下会出现中文,在代码中存在的时候大部分是string或者模版字符串,在react中的时候一个是dom的子节点还是一种是props上的属性包含中文。

json 复制代码
// const a = '霜序';
{
  "type": "StringLiteral",
  "start": 10,
  "end": 14,
  "extra": {
    "rawValue": "霜序",
    "raw": "'霜序'"
  },
  "value": "霜序"
}

StringLiteral

对应的AST节点为StringLiteral,需要去遍历所有的StringLiteral节点,将当前的节点替换为我们需要的I18N.key这种节点。

json 复制代码
// const b = `${finalRoles}(质量项目:${projects})`
{
  "type": "TemplateLiteral",
  "start": 10,
  "end": 43,
  "expressions": [
    {
      "type": "Identifier",
      "start": 13,
      "end": 23,
      "name": "finalRoles"
    },
    {
      "type": "Identifier",
      "start": 32,
      "end": 40,
      "name": "projects"
    }
  ],
  "quasis": [
    {
      "type": "TemplateElement",
      "start": 11,
      "end": 11,
      "value": {
        "raw": "",
        "cooked": ""
      }
    },
    {
      "type": "TemplateElement",
      "start": 24,
      "end": 30,
      "value": {
        "raw": "(质量项目:",
        "cooked": "(质量项目:"
      }
    },
    {
      "type": "TemplateElement",
      "start": 41,
      "end": 42,
      "value": {
        "raw": ")",
        "cooked": ")"
      }
    }
  ]
}

TemplateLiteral

相对于字符串情况会复杂一些,TemplateLiteral中会出现变量的情况,能够看到在TemplateLiteral节点中存在expressionsquasis两个字段分别表示变量和字符串

其实可以发现对于字符串来说全部都在TemplateElement节点上,那么是否可以直接遍历所有的TemplateElement节点,和StringLiteral一样。

直接遍历TemplateElement的时候,处理之后的效果如下:

js 复制代码
const b = `${finalRoles}(质量项目:${projects})`;

const b = `${finalRoles}${I18N.K}${projects})`;

// I18N.K = "(质量项目:"

那么这种只提取中文不管变量的情况,会导致翻译不到的问题,上下文很缺失。

最后我们会处理成{val1}(质量项目:{val2})的情况,将对应val1val2传入

js 复制代码
I18N.get(I18N.K, {
  val1: finalRoles,
  val2: projects,
});

JSXText

对应的AST节点为JSXText,去遍历JSXElement节点,在遍历对应的children中的JSXText处理中文文本

json 复制代码
{
  "type": "JSXElement",
  "start": 12,
  "end": 25,
  "children": [
    {
      "type": "JSXText",
      "start": 17,
      "end": 19,
      "extra": {
        "rawValue": "霜序",
        "raw": "霜序"
      },
      "value": "霜序"
    }
  ]
}

JSXAttribute

对应的AST节点为JSXAttribute,中文存在的节点还是StringLiteral,但是在处理的时候还是特殊处理JSXAttribute中的StringLiteral,因为对于这种JSX中的数据来说我们需要包裹{},不是直接做文本替换的

json 复制代码
{
  "type": "JSXOpeningElement",
  "start": 13,
  "end": 35,
  "name": {
    "type": "JSXIdentifier",
    "start": 14,
    "end": 22,
    "name": "Customer"
  },
  "attributes": [
    {
      "type": "JSXAttribute",
      "start": 23,
      "end": 32,
      "name": {
        "type": "JSXIdentifier",
        "start": 23,
        "end": 27,
        "name": "name"
      },
      "value": {
        "type": "StringLiteral",
        "start": 28,
        "end": 32,
        "extra": {
          "rawValue": "霜序",
          "raw": "\"霜序\""
        },
        "value": "霜序"
      }
    }
  ],
  "selfClosing": true
}

使用 Babel 处理

使用 @babel/parser 将源代码转译为 AST

js 复制代码
const plugins: ParserOptions['plugins'] = ['decorators-legacy', 'typescript'];
if (fileName.endsWith('text') || fileName.endsWith('text')) {
  plugins.push('text');
}
const ast = parse(sourceCode, {
  sourceType: 'module',
  plugins,
});

@babel/traverse 特殊处理上述的节点,转化 AST

js 复制代码
babelTraverse(ast, {
  StringLiteral(path) {
    const { node } = path;
    const { value } = node;
    if (
      !value.match(DOUBLE_BYTE_REGEX) ||
      (path.parentPath.node.type === 'CallExpression' &&
        path.parentPath.toString().includes('console'))
    ) {
      return;
    }
    path.replaceWithMultiple(template.ast(`I18N.${key}`));
  },
  TemplateLiteral(path) {
    const { node } = path;
    const { start, end } = node;
    if (!start || !end) return;
    let templateContent = sourceCode.slice(start + 1, end - 1);
    if (
      !templateContent.match(DOUBLE_BYTE_REGEX) ||
      (path.parentPath.node.type === 'CallExpression' &&
        path.parentPath.toString().includes('console')) ||
      path.parentPath.node.type === 'TaggedTemplateExpression'
    ) {
      return;
    }
    if (!node.expressions.length) {
      path.replaceWithMultiple(template.ast(`I18N.${key}`));
      path.skip();
      return;
    }
    const expressions = node.expressions.map((expression) => {
      const { start, end } = expression;
      if (!start || !end) return;
      return sourceCode.slice(start, end);
    });
    const kvPair = expressions.map((expression, index) => {
      templateContent = templateContent.replace(
        `\${${expression}}`,
        `{val${index + 1}}`,
      );
      return `val${index + 1}: ${expression}`;
    });
    path.replaceWithMultiple(
      template.ast(`I18N.get(I18N.${key},{${kvPair.join(',\n')}})`),
    );
  },
  JSXElement(path) {
    const children = path.node.children;
    const newChild = children.map((child) => {
      if (babelTypes.isJSXText(child)) {
        const { value } = child;
        if (value.match(DOUBLE_BYTE_REGEX)) {
          const newExpression = babelTypes.jsxExpressionContainer(
            babelTypes.identifier(`I18N.${key}`),
          );
          return newExpression;
        }
      }
      return child;
    });
    path.node.children = newChild;
  },
  JSXAttribute(path) {
    const { node } = path;
    if (
      babelTypes.isStringLiteral(node.value) &&
      node.value.value.match(DOUBLE_BYTE_REGEX)
    ) {
      const expression = babelTypes.jsxExpressionContainer(
        babelTypes.memberExpression(
          babelTypes.identifier('I18N'),
          babelTypes.identifier(`${key}`),
        ),
      );
      node.value = expression;
    }
  },
});

对于TemplateLiteral来说需要处理expression,通过截取的方式获取到对应的模版字符串 templateContent,如果不存在expressions,直接类似StringLiteral处理;存在expressions的情况下,遍历expressions通过${val(index)}替换掉templateContent中的expression,最后使用I18N.get的方式获取对应的值

js 复制代码
const name = `${a}霜序`;
// const name = I18N.get(I18N.test.A, { val1: a });

const name1 = `${a ? '霜' : '序'}霜序`;
// const name1 = I18N.get(I18N.test.B, { val1: a ? I18N.test.C : I18N.test.D });

对于TemplateLiteral节点来说,如果是嵌套的情况,会出现问题。

js 复制代码
const name1 = `${a ? `霜` : `序`}霜序`;
// const name1 = I18N.get(I18N.test.B, { val1: a ? `霜` : `序` });

🤔 为何对于TemplateLiteral中嵌套的StringLiteral会处理,而TemplateLiteral就不处理呢?

💡 导致原因为babel不会自动递归处理TemplateLiteral的子级嵌套模板。 上述的代码中通过遍历一些AST处理完了之后,我们需要统一引入当前I18N这个变量。那么没我们需要在当前文件的AST顶部的import语句后插入当前的importStatement

js 复制代码
Program: {
    exit(path) {
        const importStatement = projectConfig.importStatement;
        const result = importStatement
            .replace(/^import\s+|\s+from\s+/g, ',')
            .split(',')
            .filter(Boolean);
        // 判断当前的文件中是否存在 importStatement 语句
        const existingImport = path.node.body.find((node) => {
            return (
                babelTypes.isImportDeclaration(node) &&
                node.source.value === result[1]
            );
        });
        if (!existingImport) {
            const importDeclaration = babelTypes.importDeclaration(
                [
                    babelTypes.importDefaultSpecifier(
                        babelTypes.identifier(result[0]),
                    ),
                ],
                babelTypes.stringLiteral(result[1]),
            );
            path.node.body.unshift(importDeclaration);
        }
    },
}

转为代码

js 复制代码
const { code } = generate(ast, {
  retainLines: true,
  comments: true,
});

因为我们的场景不适合将该功能封装成plugin,但是整体和写plugin的思路差不多。在.babelrc中配置对应的plugin即可

js 复制代码
const i18nPlugin = () => {
  return {
    visitor: {
      StringLiteral(path) {},
      TemplateLiteral(path) {},
      JSXElement(path) {},
      JSXAttribute(path) {},
      Program: {},
    },
  };
};

其他处理

动态生成 key

每一个中文生成key的方式都是固定的,类似excel列名

js 复制代码
export const getSortKey = (n: number, extractMap = {}): string => {
  let label = '';
  let num = n;
  while (num > 0) {
    num--;
    label = String.fromCharCode((num % 26) + 65) + label;
    num = Math.floor(num / 26);
  }
  const key = `${label}`;
  if (_.get(extractMap, key)) {
    return getSortKey(n + 1, extractMap);
  }
  return key;
};

每一个文件的前缀都是一定的,按着路径生成的,不会包含extractDir之前的内容

js 复制代码
export const getFileKey = (filePath: string) => {
    const extractDir = getProjectConfig().extractDir;

    const basePath = path.resolve(process.cwd(), extractDir);

    const relativePath = path.relative(basePath, filePath);

    const names = slash(relativePath).split('/');
    const fileName = _.last(names) as any;
    let fileKey = fileName.split('.').slice(0, -1).join('.');
    const dir = names.slice(0, -1).join('.');
    if (dir) fileKey = names.slice(0, -1).concat(fileKey).join('.');
    return fileKey.replace(/-/g, '_');
};

脚手架命令

i18n-extract-cli

目前支持命令如下:

diff 复制代码
- init: 用于初始化配置化文件
- extract: 根据配置文件提取 extractDir 的中文写入到对应的文件
- extract:check: 检查 extractDir 文件夹中的中文是否提取完全
- extract:clear: 清理 extractDir 尚未使用的国际化文案
bash 复制代码
npx i18n-extract-cli init

会初始化一份i18n.config.json

json 复制代码
{
  "localeDir": "locales",
  "extractDir": "./",
  "importStatement": "import I18N from @/utils/i18n",
  "excludeFile": [],
  "excludeDir": []
}

执行如下命令,开始提取extractDir目录下的中文文本到localeDir/zh-CN

bash 复制代码
npx i18n-extract-cli extract

执行如下命令,检查 extractDir 文件夹中的中文是否提取完全,需要注意 console 中的中文也会被检查

bash 复制代码
npx i18n-extract-cli extract:check

执行如下命令,清理 extractDir 尚未使用的国际化文案

值得注意,是按着每个文件路径作为key来判断当前文件中的 sortKey 是否使用,因此必须保证每个文件中使用的 key 为fileKey + sortKey,否则会导致当前脚本失效

bash 复制代码
npx i18n-extract-cli extract:clear

最后

欢迎关注【袋鼠云数栈UED团队】~

袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax