当前位置: 首页 > news >正文

用Babel脚本实现多语言/国际化

需求:

在目前代码基础上,找出所有翻译词,并达到在任意地方注册多语言全局生效

框架是nextjs + react + ts(脚本实现与框架无关)

基本思路(朴素):

基于key-value对,给所有翻译词套一个方法(t方法)来获取不同语言下的内容

t方法

现有的库

  • next自带的i18n

    通过路由获取语言环境,同时cookie的优先级更高

    支持拼变量(翻译词传入变量)

    注意nextjs是app router还是pages router

  • React-intl

自己写

感觉没那么大需求,不用引入这么重的,直接写个方法然后引入翻译包就行,翻译包的路径可以参考nextjs的i18n

主要工作:

提取翻译词:

对所有需要翻译的文本进行某种形式的包裹。

鉴于已有项目,自动化完成这件事

A. 脚本正则化匹配,比较难匹配全

B. 使用工具如 Babel 插件,自动将匹配的字符串替换为国际化函数调用。包裹完之后是完全我们自己维护或者用现有的库都可以,不冲突。

参考:https://juejin.cn/post/7112972686392836103(他是中文,不过大致思路应该是一样的)

Babel 插件的工作原理

  1. 解析为 AST:Babel 在处理 JavaScript 代码时,会首先将代码解析为抽象语法树(AST)。AST 是代码的结构化表示,允许以编程方式访问和修改代码。
  2. 遍历和转换:Babel 提供了遍历 AST 的 API,允许开发者编写插件来访问和转换 AST 的特定节点。插件可以在遍历过程中修改节点,添加新的节点,或者提取信息。
  3. 重新生成代码:经过插件处理后,Babel 将修改后的 AST 转换回 JavaScript 代码。

Babel 插件基本思路

  1. 创建一个 Babel 插件
    1. Babel 插件通常是一个 JavaScript 函数,接收 Babel 的 babel 对象作为参数。
    2. 使用 babel.traverse API 来遍历 AST。
  2. 识别和提取文本节点
    1. 在遍历过程中,识别出可能包含硬编码文本的节点类型,比如 StringLiteralTemplateLiteral
    2. 过滤掉不需要翻译的文本(例如,变量名、非用户界面文本等)。( 添加忽略注释?
    3. 注意编译时和运行时,(变量可能要占位符替换,因为只有在运行时才会有
  3. 自动替换为翻译函数并输出提取词
    1. 将识别出的硬编码文本替换为国际化函数调用,比如 t('key')
    2. 为每个文本生成唯一的翻译键(key),并维护一个外部文件来存储这些键与文本的映射。

匹配Babel的AST树节点的记录:(记得排除node_modules)

  • JSXText:
    都是

  • StringLiteral:

    (有一部分是需要翻译的,类似写在文件最前面的key-value的const文本)
    对于一些有键值的静态字面量,(通常是自己写的map),父节点是ObjectProperty,通过查看兄弟节点的Indentifier类型来获取key的名称,提取 text/ label/ description 的value值
    但其实容易多提取导致报错,最后代码实现的时候这部分没有提取

nextjs用webpack打包比较慢,导致babel插件在开发的过程中体验不会特别好,加载新页面的时候会插入跑插件这一步(当然npm run build然后npm run start 页面不会卡顿,更新记得删.next 文件夹缓存)

考虑一样的思路改成用babel脚本(与框架无关),遍历节点的时候和babel插件的代码是一样的

目前脚本做的事情是,将jsx节点自动套t方法和将所有套了t方法的放到翻译包里,没有翻译的词放到整体的最前面,最后再把代码库里的代码用路径下的prettierrc文件定义的格式进行格式化

同时注意翻译词写入可能会有编码问题导致key匹配不上,再检查一下

参考代码:

// 存储待翻译的文本
const wordsToTranslate = new Set();

function isAlreadyWrappedWithT(node) {
  if (
    node &&
    node.type === 'CallExpression' &&
    node.callee &&
    node.callee.name === 't'
  ) {
    return true;
  }
  return false;
}

// 根据根目录的 prettierrc 格式化单个文件
async function formatFile(filePath, srcDir, outputDir) {
  try {
    console.log(`格式化文件: ${filePath}`);

    // 读取文件内容
    const code = fs.readFileSync(filePath, 'utf-8');

    // 读取 prettier 配置
    const prettierConfig = await prettier.resolveConfig(process.cwd(), {
      editorconfig: true,
      config: path.join(process.cwd(), '.prettierrc'),
    });

    // 使用 prettier 格式化代码
    const formattedCode = await prettier.format(code, {
      ...prettierConfig,
      filepath: filePath,
    });
    // 计算输出路径
    const relativePath = path.relative(srcDir, filePath);
    const outputPath = path.join(outputDir, relativePath);

    // 确保输出目录存在
    ensureDirectoryExists(path.dirname(outputPath));

    // 写入格式化后的代码
    fs.writeFileSync(outputPath, formattedCode, 'utf-8');
  } catch (error) {
    console.error(`格式化文件 ${filePath} 时出错:`, error);
  }
}

// 处理单个文件(借助 Babel 的 AST 给代码添加 i18n)
function processFile(filePath, srcDir, outputDir) {
  try {
    console.log(`处理文件: ${filePath}`);
    const code = fs.readFileSync(filePath, 'utf-8');

    // 解析代码生成 AST,支持 TypeScript 和 JSX
    const ast = parse(code, {
      sourceType: 'module',
      plugins: ['typescript', 'jsx'],
    });

    // 创建初始state对象
    const state = {
      filename: filePath,
    };

    // 遍历和修改 AST
    traverse(
      ast,
      {
        Program(path) {
          let hasI18nImport = false;

          path.traverse({
            ImportDeclaration(childPath) {
              childPath.traverse({
                StringLiteral(stringPath) {
                  if (stringPath.node.value === '@/utils/i18') {
                    hasI18nImport = true;
                  }
                },
              });
            },
          });

          if (
            !hasI18nImport &&
            !filePath.includes('/node_modules/')
          ) {
            const importDeclaration = t.importDeclaration(
              [t.importSpecifier(t.identifier('t'), t.identifier('t'))],
              t.stringLiteral('@/utils/i18n'),
            );

            path.unshiftContainer('body', importDeclaration);
          }
        },
        JSXText(path) {
          const transValue = path.node.value
            .replace(/\s+/g, ' ')
            .replace(/\\n/g, ' ')
            .trim();
          if (
            /.*[a-zA-Z\p{P}].*/.test(transValue) &&
            filePath.includes('/src/') &&
            !filePath.includes('/node_modules/') &&
            !isAlreadyWrappedWithT(path.parentPath.node)
          ) {
            path.replaceWith(
              t.jSXExpressionContainer(
                t.callExpression(t.identifier('t'), [
                  t.stringLiteral(transValue),
                ]),
              ),
            );
            wordsToTranslate.add(transValue);
          }
        },
        StringLiteral(path) {
          const transValue = path.node.value
            .replace(/\s+/g, ' ')
            .replace(/\\n/g, ' ')
            .trim();
          if (
            /.*[a-zA-Z\p{P}].*/.test(transValue) &&
            filePath.includes('/src/') &&
            !filePath.includes('/node_modules/')
          ) {
	            if (path.parentPath.node.type === 'CallExpression') {
	              if (
	                t.isCallExpression(path.parentPath.node) &&
	                path.parentPath.node.callee.name === 't'
	              ) {
	                wordsToTranslate.add(transValue);
	              }
	            }
            }
          }
        },
      },
      state,
    );

    // 生成新代码
    const output = generate(ast, {}, code);

    // 计算输出文件路径
    const relativePath = path.relative(srcDir, filePath);
    const outputPath = path.join(outputDir, relativePath);

    // 确保输出目录存在
    ensureDirectoryExists(path.dirname(outputPath));

    // 写入修改后的代码
    fs.writeFileSync(outputPath, output.code, 'utf-8');
  } catch (error) {
    console.error(`处理文件 ${filePath} 时出错:`, error);
  }
}

function post() {
  if (wordsToTranslate.size > 0) {
    SUPPORTED_LOCALES.forEach((locale) => {
      // 设置文件输出路径
      const outputDir = path.join(process.cwd(), 'public', 'locales', locale);
      if (!fs.existsSync(outputDir)) {
        fs.mkdirSync(outputDir, { recursive: true });
      }
      const outputPath = path.join(outputDir, 'common.json');

      // 读取现有翻译
      let existingWords = {};
      if (fs.existsSync(outputPath)) {
        existingWords = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
      }

      // 处理新增词
      const newWords = Array.from(wordsToTranslate)
        .filter((word) => !Object.values(existingWords).includes(word))
        .sort()
        .reduce((acc, word) => {
          const key = word;
          acc[key] = locale === 'en' ? word : '';
          return acc;
        }, {});

      // 合并所有词
      const allWords = { ...newWords, ...existingWords };

      // 分离空值和非空值
      const emptyEntries = [];
      const nonEmptyEntries = [];

      Object.entries(allWords).forEach(([key, value]) => {
        if (!value || value.trim() === '') {
          emptyEntries.push([key, value]);
        } else {
          nonEmptyEntries.push([key, value]);
        }
      });

      // 分别对空值和非空值按key排序
      emptyEntries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
      nonEmptyEntries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));

      // 合并排序后的结果,空值在前
      const sortedWords = [...emptyEntries, ...nonEmptyEntries].reduce(
        (acc, [key, value]) => {
          acc[key] = value;
          return acc;
        },
        {},
      );

      // 写入文件
      fs.writeFileSync(
        outputPath,
        JSON.stringify(sortedWords, null, 2),
        'utf8',
      );
    });
  }
}

// 主函数
function main() {
  const srcDir = path.join(__dirname, '../src');
  const outputDir = path.join(__dirname, '../src');

  // 确保输出目录存在
  ensureDirectoryExists(outputDir);

  const files = getAllFiles(srcDir);

  files.forEach((file) => {
    processFile(file, srcDir, outputDir);
    formatFile(file, srcDir, outputDir);
  });

  post();
}

main();

维护翻译词:

开发一个简易的翻译平台,所有人可以维护提取词和翻译包 key-value形式。具体形式还得再看看,key尽量参照国际化标准
达成两个目的:a.每个人把翻译提交 b.修改可以应用到项目里(导出json)
不确定能不能让llm写

有现成的平台

相关文章:

  • Ubuntu更改内核
  • 【AI】Ollama+OpenWebUI+llama3本地部署保姆级教程,没有连接互联网一样可以使用AI大模型!!!
  • ARM架构与编程(基于STM32F103)ARM单片机启动流程与MAP文件解析
  • RabbitMQ——消息发送的双重保障机制
  • Java【多线程】(2)线程属性与线程安全
  • vue2+ele-ui实践
  • Python的循环和条件判断 笔记250303
  • Spring Security简介与使用
  • 大模型辅助火狐浏览器插件开发:网页保存至本地及 GitHub 仓库
  • UNION 和 UNION ALL 的区别:深入解析 SQL 中的合并操作
  • 电源测试系统有哪些可以利用AI工具的科技??
  • Leetcode 54: 螺旋矩阵
  • smolagents学习笔记系列(番外二)Agent+Ollama分析本地图像与文件
  • Linux系列:如何用 C#调用 C方法造成内存泄露
  • 基于Qt的登陆界面设计及记住密码,简易计算器设计
  • Linux网络_应用层自定义协议与序列化_守护进程
  • 二、QT和驱动模块实现智能家居-----5、通过QT控制LED
  • Python 课堂点名桌面小程序
  • spark 虚拟机基本命令(2)
  • 深入解析Java虚拟机(JVM)的核心组成
  • 为什么选择做汉服网站/5118站长网站
  • 浙江网站建设实验心得/中国seo关键词优化工具
  • 搜索引擎成功案例分析/武汉seo排名优化
  • 如何注册域名?成本多少/seo网站制作优化
  • 网页版微信二维码怎么扫/齐三seo顾问
  • 网站推广团队/网站推广软件哪个最好