译:使用自动化搭建 React 国际化框架

原文:https://lingual.dev/blog/automating-your-react-internationalization/
作者:lingual.devs
译者:Claude 3.5 Sonnet

编者注:1) 国际化在项目中经常被忽视,然后后期需要大量工作才能完成国际化。2) 可以使用 ts-morph 工具来自动化处理硬编码的字符串,将其替换为翻译函数。3) 然后分别针对 react-i18next 和 react-intl 提供了具体的实现方案。

使用自动化搭建 React 国际化框架

简介

国际化通常是在开始构建应用时被忽视的,因为开发初期往往只关注单一市场和单一语言。在某个时间点,特别是当应用需要扩展到新的市场或地区时,国际化就成为一个重要话题。这时现有的代码库就需要适配多语言,这不仅包括翻译字符串,还可能包括货币、复数形式或时间处理。

根据代码库的成熟度和规模,这可能意味着需要更新和重构数千个文件,并需要引入数百或数千个翻译键。React 中有许多流行的 i18n 库,它们能够处理大多数国际化需求,并提供 React 特定的实现,如 context、hooks 或高阶组件。这些库的例子包括 react-intlreact-i18nextlingui

将硬编码的字符串替换为翻译函数(如 hook)的任务可能非常耗时,你可能会遗漏一些可翻译的内容或引入其他错误,比如破坏句子的复数形式。这不仅仅是关于查找静态字符串,还包括本地化代码中的货币、数字或时间出现的地方。

为了加快在现有代码库中引入 i18n 的过程,我们可以尝试自动化部分流程。在接下来的章节中,我们将尝试自动化替换 react-i18next 和 react-intl 代码库中的硬编码字符串为翻译函数。

用翻译函数替换硬编码字符串

让我们从一个非常基础的例子开始理解:

import React from "react";

export const Basic = () => {
  return (
    <div>
      <h1>This is some title</h1>
      <p>This is some paragraph</p>
      <div>This is some div</div>
    </div>
  );
};

我们可以在上面的组件中识别出三个可能的翻译键。h1 标签内的内容将被包装在 t 函数(或等效的翻译函数)中:

<h1>{t("some.key.h1.title", "This is some title")}</h1>

t 函数内部会定义一个键和一个可选的默认翻译。同样的 t 函数也可以应用于该示例中的 p 和 div 标签。

如果我们假设使用的是 react-i18next,我们希望有一个脚本能自动找到 React 组件内的所有硬编码字符串,然后用 t 函数包装这些找到的字符串。此外,脚本应该基于一些规则创建键,并导入 hook(如果受影响的文件中没有导入 hook)。它还应该在组件内初始化 t 函数。所以需要完成以下几件事:

  1. 如果文件中没有导入 useTranslation hook,则导入它
  2. 在组件内调用 hook:const {t} = useTranslation()
  3. 将所有硬编码字符串替换为创建键并使用字符串作为默认翻译的 t 函数 {t("some.specific.key", "This is some title")}

基于前面的例子,预期的转换结果将是:

import React from "react";
import { useTranslation } from "react-i18next";

export const Basic = () => {
  const { t } = useTranslation();

  return (
    <div>
      <h1>{t("some.key.h1.title", "This is some title")}</h1>
      <p>{t("some.key.p.content", "This is some paragraph")}</p>
      <div>{t("some.key.div.content", "This is some div")}</div>
    </div>
  );
};

显示所需更改的完整差异:

自动化前后的对比

设置 ts-morph

在编写实际的 React 特定脚本之前,我们需要一种方法来解析、导航和操作 ASTTypeScript 有一个编译器 API,它能够转换 JavaScript/TypeScript。ts-morph 是原始 TypeScript 编译器 API 的包装器,用于简化与该编译器的交互和使用。运行以下命令安装 ts-morph

// npm
npm install --save-dev ts-morph

// pnpm
pnpm add --save-dev ts-morph

// yarn
yarn add --dev ts-morph

安装完 ts-morph 后,我们可以开始实现自动化代码,将 JSX 组件内的任何硬编码字符串转换为包含生成的键和被替换的硬编码字符串作为默认值的翻译函数。

我们要编写的转换应该适用于 React 函数组件,因为我们要使用 useTranslationuseIntl hook。所以在第一次迭代中,我们将只关注这些函数。在后续迭代中,转换也可以检查 alt 标签和其他可能需要翻译的内容。

我们可以定义一个 transform 来启用交互:

import { Project, ProjectOptions, SyntaxKind, ts } from "ts-morph";

export const transform = ({
  tsConfigFilePath,
  filesPaths,
}: {
  tsConfigFilePath?: ProjectOptions["tsConfigFilePath"];
  filesPaths: string | string[];
}) => {
  const project = new Project({
    tsConfigFilePath,
  });

  project.addSourceFilesAtPaths(filesPaths);
};

为了帮助探索我们想要转换的源代码的 AST,我们可以使用 TypeScript AST Viewer

为 react-i18next 实现转换脚本

我们可以通过调用 getSourceFiles 来获取所有提供的源文件,然后遍历这些文件。对于每个文件,我们要检查语句是否包含 JsxText 类型的节点。如果一个语句没有可翻译的内容,我们要跳过并测试下一个语句。

for (const sourceFile of project.getSourceFiles()) {
  for (const statement of sourceFile.getStatements()) {
    const hasTranslatableContent = statement
      .getDescendantsOfKind(SyntaxKind.JsxText)
      .some((node) => !node.containsOnlyTriviaWhiteSpaces());

    if (!hasTranslatableContent) {
      continue;
    }

    // 用翻译替换任何字符串
  }
}

一旦我们知道一个语句有任何可翻译的字符串,我们使用 transform 方法来操作任何 JsxText 类型的节点并具有可翻译的内容。transform 还提供了 traversal,我们可以用它来调用 updateJsxText 并更新可翻译的节点。在我们的例子中,我们只是基于文件名和文本内容进行简单的键生成。这个键生成可以更复杂,应该根据你的假设来定义和实现。

statement.transform((traversal) => {
  const node = traversal.visitChildren();
  if (ts.isJsxText(node) && !node.containsOnlyTriviaWhiteSpaces) {
    const text = node.getText();
    const key = `${sourceFile
      .getBaseNameWithoutExtension()
      .toLowerCase()}.${text
      .substring(0, 100)
      .replace(/ /g, "_")
      .toLowerCase()}`;

    return traversal.factory.updateJsxText(node, `{t("${key}", "${text}")}`);
  }
  return node;
});

下一步是检查我们是否有 useTranslation hook 的命名导入,如果没有就将其添加到导入中。我们的检查更简单,假设只有在没有 react-i18next 导入声明时才应该添加 useTranslation。作为进一步的改进,我们也可以检查是否有该库的导入但没有 useTranslation 的命名导入。

// 添加导入函数
const hasUserTranslationImport =
  sourceFile.getImportDeclaration("react-i18next");

if (!hasUserTranslationImport) {
  sourceFile.addImportDeclaration({
    namedImports: ["useTranslation"],
    moduleSpecifier: "react-i18next",
  });
}

一旦我们更新了所有字符串并用 t 函数替换它们,我们还需要检查组件中是否已经有可用的 t 函数。通过遍历所有可用的 CallExpression 节点并检查是否有任何表达式以 useTranslation 开头。

// 添加 useTranslation hook
const hasTranslateHook = statement
  .getDescendantsOfKind(ts.SyntaxKind.CallExpression)
  .some((node) => node.getText().startsWith("useTranslation"));

如果我们没有现有的翻译 hook,我们可以创建 hook 并将其设置在组件的最开始。

if (!hasTranslateHook) {
  // 由于没有找到翻译,添加 useTranslation hook
  const [blocks] = statement.getDescendantsOfKind(SyntaxKind.Block);
  const useTranslationStatement = printNode(
    factory.createVariableStatement(
      undefined,
      factory.createVariableDeclarationList(
        [
          factory.createVariableDeclaration(
            factory.createObjectBindingPattern([
              factory.createBindingElement(
                undefined,
                undefined,
                factory.createIdentifier("t"),
                undefined,
              ),
            ]),
            undefined,
            undefined,
            factory.createCallExpression(
              factory.createIdentifier("useTranslation"),
              undefined,
              [],
            ),
          ),
        ],
        ts.NodeFlags.Const,
      ),
    ),
  );

  if (blocks) {
    blocks.insertText(blocks.getStatements()[0].getPos(), (writer) => {
      writer.newLine();
      writer.write(useTranslationStatement);
      writer.newLine();
    });
  }
}

最后我们需要格式化并保存文件。

sourceFile.formatText();
sourceFile.saveSync();

有了这些更改,我们现在可以将任何文件传递给 transform 函数,它将尝试自动将 JSX 代码中的任何硬编码字符串替换为包含生成的键和被替换字符串作为默认值的翻译函数。你可以在这里查看完整实现。

为 react-intl 实现转换脚本

用于转换 react-intl 代码库的代码与之前的 react-i18next 实现非常相似。主要区别在于导入语句需要从 react-intl 库导入 useIntl hook,并且硬编码字符串应该转换为以下函数调用:intl.formatMessage({id: 'some.key', defaultMessage: 'This is the default text'})

找到的语句的转换与之前的实现相同,唯一的区别是我们返回 intl.formatMessage 函数调用,并传递一个包含 id(我们生成的键)和 defaultMessage(我们要替换的字符串)的对象。

statement.transform((traversal) => {
  const node = traversal.visitChildren();
  if (ts.isJsxText(node) && !node.containsOnlyTriviaWhiteSpaces) {
    const text = node.getText();
    const key = `${sourceFile
      .getBaseNameWithoutExtension()
      .toLowerCase()}.${text
      .substring(0, 100)
      .replace(/ /g, "_")
      .toLowerCase()}`;

    return traversal.factory.updateJsxText(
      node,
      `{intl.formatMessage({id: "${key}", defaultMessage: "${text}"})}`,
    );
  }
  return node;
});

导入函数的插入也与 react-i18next 实现相同,只是替换了命名导入和模块名:

// 添加导入函数
const hasUserTranslationImport = sourceFile.getImportDeclaration("react-intl");

if (!hasUserTranslationImport) {
  sourceFile.addImportDeclaration({
    namedImports: ["useIntl"],
    moduleSpecifier: "react-intl",
  });
}

检查组件内是否已定义翻译 hook 只需要检查是否有任何调用表达式以 useIntl 开头。

// 添加 useTranslation hook
const hasTranslateHook = statement
  .getDescendantsOfKind(ts.SyntaxKind.CallExpression)
  .some((node) => node.getText().startsWith("useIntl"));

正如我们所看到的,代码非常相似,如果需要的话可以进一步重构以支持这两种情况。你可以在这里查看完整的 react-intl 实现。

总结

在这个国际化代码库自动化的介绍中,我们介绍了如何将硬编码字符串替换为翻译函数。我们使用 ts-morph 来解析、导航和操作 AST,这些示例应该为如何开始 i18n 转换提供一个总体思路。我们建议尝试Github 示例仓库并查看实现细节。

基于这些基本实现,你可以创建一个 CLI 命令,该命令接受路径(可以是单个文件、文件夹或 glob 模式),然后对给定路径应用这些更改。

我们还计划在 i18n-check 中添加转换,以帮助为你的 react-i18next 和 react-intl 代码库搭建国际化框架。

如果你有问题或更多关于如何扩展 i18n 转换的想法,请在 Bluesky 上给我们发消息或直接在 Github 仓库中提出问题。

链接

Github 仓库
ts-morph
react-i18next
react-intl
i18n-check