实现一个多语言工具

446 阅读5分钟

前言

最近在为一个项目做多语言,但是项目设计之初没有考虑多语言,因此每个文案一个个替换什么的简直痛苦面具。于是考虑通过 node 脚本的形式,来为项目进行迁移

多语言方案

我们实现的多语言,大多数情况下都是提供一个 intl 函数,然后每个文案的地方写成类似下面这种形式

// 多语言函数定义部分
import zhCN from "./zhCN.json";
import enUS from "./enUS.json";
import getLanguage from "./getLanguage";

const languageMap: Record<string, Record<string, string>> = {
  zhCN,
  enUS,
};

function intl(str: string) {
  const targetLanguage = languageMap[getLanguage()];
  return targetLanguage[str] || str;
}

// 消费部分

<span>{intl("要翻译的文案")}</span>;

确定工具功能

基于以上的部分,我们大概需要以下一些功能

  • 递归遍历所有满足条件的文件
  • 将所有的文案转换成类似intl('要翻译的文案')的形式
  • 对项目中所有的翻译的文案收集,翻译,写入对应 json 文件里

实现部分

递归遍历所有满足条件的文件

这部分比较简单,递归遍历即可,而且网上类似代码一大堆

import fs from "fs";
import nodePath from "path";

const dfsFile = (
  path: string,
  extensions: string[],
  callback: (props: string) => void
) => {
  const data = fs.readdirSync(path);

  data.forEach((item) => {
    const newPath = `${path}/${item}`;
    const stat = fs.statSync(newPath);
    if (stat.isFile()) {
      const extname = nodePath.extname(item);
      if (extensions.includes(extname)) {
        callback(newPath);
      }
      return;
    }
    if (stat.isDirectory()) {
      dfsFile(newPath, extensions, callback);
    }
  });
};

export default dfsFile;

将所有的文案转换成类似intl('要翻译的文案')的形式

  • 这部分实现要稍微复杂一点,需要用到一点 ast 的工具,另外对于如何确定是否是需要翻译的文案的问题,我认为所有的中文都属于需要翻译的,所以判断字符里是否含有中文即可

用到的 ast 相关的工具

判断是否是中文

function isChinese(temp: string) {
  const re = /[\u4E00-\u9FA5]+/;
  if (re.test(temp)) return true;
  return false;
}

读取文件,并转换为 ast

这里我们主要是通过@babel/parser来将我们的代码转成 ast,然后利用@babel/traverse来遍历 ast 的节点,在遍历的过程中改变节点的属性,或者对节点进行更改,替换,我们可以借助@babel/types生成一些新的节点,最后将处理好的 ast,使用@babel/generator将 ast 转换回我们的代码,最后将代码重新写回到对应的文件里即可

parser 阶段

const ast = parser.parse(code, {
  sourceType: "module",
  plugins: ["jsx", "typescript"],
});

遍历以及处理 ast 的节点

  1. 确定有哪些节点需要处理,这里我们需要处理的字符串,有两种可能,一种是普通字符串,一种是 jsx 的文案,分别是以下两种的:
    • StringLiteral
    • JSXText

也会你会疑惑是如何确定节点的,这里强烈推荐这个网站:astexplorer.net ,非常好用,你选中内容,右边回自动高亮对应的 ast 节点,而且它支持的语言也非常广,有以下这么多:
image.png

  1. 根据 ast 节点来进行改写
  • 不包含中文的,不处理
  • 父节点是 ImportDeclaration 的,不处理,

image.png

  1. 如果是 jsx 的属性,比如
    image.png
    那我们要转成这样子
    image.png
  2. 如果已经注入完成的,即父节点是 CallExpression,且其 callee 的 name 是我们指定的函数名,这种情况不处理。因为使用的时候,一般会做增量的注入
    image.png
  3. JSXText 节点
    image.png
    要转成这样子:
    image.png

如何转换

traverse为我们提供了遍历 ast 的简易方式,它是以深度搜索优先的方式遍历节点的,每种节点它都提供两个回调,一个是 enter,即刚进入该节点时执行,一个是 exit,离开该节点时执行,而它注入的 path,通过这里的代码我们其实可以看到它的具体实现,我们主要会用到以下几种:
以 is 开头的系列的判断节点类型的方案,如 isJSXAttribute,isCallExpression 等
替换节点:replaceWith
插入节点:insertBefore
另外我们还要用到@babel/types,来为我们生成一些新的节点,使用非常简单,直接 t.xxx()即可
实际的转换代码:

  const isAddImport = {
    notImported: true,
    needImport: false,
  };

  traverse(ast, {
    StringLiteral: {
      enter(path) {
        // 如果不是中文
        if (!isChinese(path.node.value)) {
          return;
        }

        const { parentPath } = path;

        // 如果是导入语句
        if (parentPath.isImportDeclaration()) {
          return;
        }

        // 如果是已经注入函数完成
        if (
          parentPath.isCallExpression() &&
          parentPath.node.callee.type === "Identifier" &&
          parentPath.node.callee.name === intlFunName
        ) {
          return;
        }

        isAddImport.needImport = true;
        // 如果是jsx的属性
        if (parentPath.isJSXAttribute()) {
          path.replaceWith(
            t.jsxExpressionContainer(
              t.callExpression(t.identifier(intlFunName), [
                t.stringLiteral(path.node.value),
              ])
            )
          );
          return;
        }

        // 其他字符串情况
        path.replaceWith(
          t.callExpression(t.identifier(intlFunName), [
            t.stringLiteral(path.node.value),
          ])
        );
      },
    },
    JSXText: {
      enter(path) {
        // 如果不是中文
        if (!isChinese(path.node.value)) {
          return;
        }
        isAddImport.needImport = true;
        // 其他字符串情况
        path.replaceWith(
          t.jsxExpressionContainer(
            t.callExpression(t.identifier(intlFunName), [
              t.stringLiteral(path.node.value.trim()),
            ])
          )
        );
      },
    },

    ImportDefaultSpecifier: {
      enter(path) {
        if (path.node.local.name === intlFunName) {
          isAddImport.notImported = false;
        }
      },
    },
    Program: {
      exit(path) {
        if (isAddImport.needImport && isAddImport.notImported) {
          path
            .get("body")[0]
            ?.insertBefore(
              t.importDeclaration(
                [t.importDefaultSpecifier(t.identifier(intlFunName))],
                t.stringLiteral(intlFunPath)
              )
            );
        }
      },
    },
  });
};

这里还增加处理了增加对应 import 语句,来自动引用对应的注入函数
ps: 在使用 rollup 打包traverse时,有个坑,就是你要写成这样,这个时模块兼容的问题,具体可以看相关的issues

import _traverse from "@babel/traverse";

// babel的模块兼容性问题,详见https://github.com/babel/babel/issues/13855
// @ts-ignore
const traverse: typeof _traverse = _traverse.default;

export default traverse;

重写源文件

这个就非常简单了,将处理好后的 ast 节点,传入给@babel/generator,然后调用就好了,不过这里有个坑就是一定要配置 jsescOption.minimal 属性为 true,不然你就会发行写入的是文案变成了 utf-8 表示
代码如下:

const output = generate(ast, {
  jsescOption: {
    // 处理字符串会被转成utf-8编码的表示的问题
    minimal: true,
  },
});

fs.writeFileSync(filePath, output.code, { encoding: "utf-8" });

翻译部分

这部分就比较简单了,方案大概是这样子的:

收集

还是通过 ast 来收集,通过判断 StringLiteral 的父节点的类型,callee 的 name 属性来判断是否是需要翻译的文案,是的话就收集起来
代码如下:

const pendI18nWords = new Set<string>();

dfsFile(inputDir, extname, (filePath) => {
  const code = fs.readFileSync(filePath, { encoding: "utf-8" });

  const ast = parser.parse(code, {
    sourceType: "module",
    plugins: ["jsx", "typescript"],
  });

  traverse(ast, {
    StringLiteral: {
      enter(path) {
        const { parentPath } = path;

        if (
          parentPath.isCallExpression() &&
          parentPath.node.callee.type === "Identifier" &&
          parentPath.node.callee.name === intlFunName
        ) {
          pendI18nWords.add(path.node.value);
        }
      },
    },
  });
});

翻译

我们还要根据用户配置的翻译函数,来调用该函数,代码如下:

// eslint-disable-next-line
const request = require(nodePath.resolve(localFunDir, `request`));
const translatedWords = await request([...pendI18nWords], targetLanguage);

[...pendI18nWords].forEach((item, index) => {
  translatedMap[item] = translatedWords[index];
});

fs.writeFileSync(
  nodePath.resolve(localFunDir, `${targetLanguage}.json`),
  JSON.stringify(translatedMap)
);

我们可能还过滤掉已经被翻译的内容,这部分代码如下:

let translatedMap: Record<string, string> = {};

// 不忽略缓存,就完全重新翻译
if (!ignoreCache) {
  try {
    translatedMap = JSON.parse(
      fs.readFileSync(nodePath.resolve(localFunDir, `${targetLanguage}.json`), {
        encoding: "utf-8",
      })
    );
  } catch (e) {
    translatedMap = {};
  }
}

Object.keys(translatedMap).forEach((item) => {
  // 去除已翻译过的词
  if (pendI18nWords.has(item)) {
    pendI18nWords.delete(item);
  }
});

最后

这样,我们的一个多语言工具就基本写完了,当然这只是最初的版本,后续肯定还需要再优化
源代码地址:github.com/fengluoX/i1…