基于AST实现国际化文本提取:原理、实践与工具详解

前端 潘老师 2周前 (04-07) 17 ℃ (0) 扫码查看

今天,我们来深入探讨如何基于抽象语法树(AST)实现国际化文本提取,在阅读前,建议读者先对babel有一定的了解,其架构主要涵盖工具层(像@babeltypes、@babeltemplate等 )、核心部分、生态系统等,其中语法解析器支持多种语法,如ESNext、Typescript、JSX等。

一、确定项目中的中文范围

在开始提取国际化文本前,首先要明确项目里可能出现中文的场景。在JavaScript代码中,常见的情况有普通字符串定义、模版字符串使用、在React组件的DOM子节点以及props属性中。例如:

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="霜序" />;

从AST的角度来看,不同的中文存在形式对应不同的节点类型。

  • StringLiteral节点:普通字符串在AST中对应的节点为StringLiteral。当我们要进行国际化文本提取时,需要遍历所有的StringLiteral节点,然后将其替换为I18N.key这样的节点形式,以便后续进行国际化处理。例如,对于const a = '霜序';,在AST中会表示为:
{
  "type": "StringLiteral",
  "start": 10,
  "end": 14,
  "extra": {
    "rawValue": "霜序",
    "raw": "'霜序'"
  },
  "value": "霜序"
}
  • TemplateLiteral节点:模版字符串对应的AST节点是TemplateLiteral,它的情况相对复杂一些,因为其中可能包含变量。在TemplateLiteral节点中,expressions字段表示变量,quasis字段表示字符串部分。比如const b = ${finalRoles}(质量项目:${projects});,其AST表示如下:
{
  "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": ")"
      }
    }
  ]
}

如果直接遍历TemplateElement节点,只提取中文而不管变量,会导致翻译时上下文缺失,出现翻译不准确的问题。理想的处理方式是将其处理成{val1}(质量项目:{val2})这种形式,并把对应的val1val2传入,例如:

I18N.get(I18N.K, {
  val1: finalRoles,
  val2: projects,
});
  • JSXText节点:在React的JSX中,文本内容对应的AST节点为JSXText。我们需要遍历JSXElement节点,然后在其children中找到JSXText节点来处理中文文本。比如:
{
  "type": "JSXElement",
  "start": 12,
  "end": 25,
  "children": [
    {
      "type": "JSXText",
      "start": 17,
      "end": 19,
      "extra": {
        "rawValue": "霜序",
        "raw": "霜序"
      },
      "value": "霜序"
    }
  ]
}
  • JSXAttribute节点:当中文出现在JSX的属性中时,对应的AST节点是JSXAttribute,而中文实际存在的节点还是StringLiteral。不过在处理时需要特殊对待,因为对于JSX中的数据,我们需要用{}包裹,而不是直接进行文本替换。示例如下:
{
  "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进行文本提取处理

明确了中文在AST中的节点类型后,接下来就可以借助Babel工具来实现国际化文本的提取。Babel主要涉及@babel/parser@babel/traverse@babel/generate等模块。

  • 使用@babel/parser转译源代码为AST:利用@babel/parser可以将源代码解析成AST,代码如下:
const plugins: ParserOptions['plugins'] = ['decorators-legacy', 'typescript'];
if (fileName.endsWith('text') || fileName.endsWith('text')) {
  plugins.push('text');
}
const ast = parse(sourceCode, {
  sourceType: 'module',
  plugins,
});

在这段代码中,根据文件类型选择合适的插件,然后将源代码解析为AST,为后续处理做准备。

  • 使用@babel/traverse处理AST节点@babel/traverse用于遍历和修改AST节点。针对前面提到的不同节点类型,我们进行如下处理:
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节点时,如果存在变量,需要通过截取的方式获取模版字符串templateContent ,然后遍历expressions,用{val(index)}替换掉templateContent中的变量,最后使用I18N.get的方式来获取对应的值。不过,TemplateLiteral节点如果存在嵌套情况,会出现处理问题,这是因为babel不会自动递归处理其嵌套模板。

  • 在AST顶部插入引入语句:处理完AST节点后,我们需要统一引入I18N变量。在文件的AST顶部的import语句后插入相关的importStatement,代码如下:
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);
        }
    },
}
  • 将处理后的AST转为代码:使用@babel/generate将处理后的AST转换回代码,代码如下:
const { code } = generate(ast, {
  retainLines: true,
  comments: true,
});

三、其他相关处理

(一)动态生成key

为了确保每个中文文本在国际化处理中有唯一的标识,我们需要动态生成key。这里的生成方式类似excel列名的生成规则,代码如下:

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;
};

每个文件的key前缀则是根据文件路径生成的,且不包含extractDir之前的内容,具体实现如下:

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脚手架命令,目前支持以下几种操作:

  • 初始化配置文件:执行npx i18n-extract-cli init ,会生成一份i18n.config.json配置文件,内容如下:
{
  "localeDir": "locales",
  "extractDir": "./",
  "importStatement": "import I18N from @/utils/i18n",
  "excludeFile": [],
  "excludeDir": []
}
  • 提取中文文本:运行npx i18n-extract-cli extract ,可以将extractDir目录下的中文文本提取到localeDir/zh-CN中。
  • 检查提取情况:使用npx i18n-extract-cli extract:check命令,能检查extractDir文件夹中的中文是否提取完全,需要注意的是,console中的中文也会被检查。
  • 清理未使用的国际化文案:执行npx i18n-extract-cli extract:clear,可以清理extractDir尚未使用的国际化文案。但要注意,该脚本是按每个文件路径作为key来判断当前文件中的sortKey是否使用,所以必须保证每个文件中使用的keyfileKey + sortKey,否则脚本会失效。

通过上述基于AST的国际化文本提取方法,配合Babel工具以及相关的脚手架命令,开发者能够高效地实现项目的国际化文本提取工作。希望本文能为大家在项目国际化开发过程中提供帮助。


版权声明:本站文章,如无说明,均为本站原创,转载请注明文章来源。如有侵权,请联系博主删除。
本文链接:https://www.panziye.com/front/16839.html
喜欢 (0)
请潘老师喝杯Coffee吧!】
分享 (0)
用户头像
发表我的评论
取消评论
表情 贴图 签到 代码

Hi,您需要填写昵称和邮箱!

  • 昵称【必填】
  • 邮箱【必填】
  • 网址【可选】