前端实现高效的国际化解决方案

前端打造高效的国际化解决方案与实践(适合中小型项目,末尾附完整代码)

当前脚本主要解决国际化文案统一搜集和维护。

1、开发者无需为每个文案命名key,也无需因文案频繁变更而重复手动调整,减少因文案频繁变更可能会导致开发者改错、漏改等情况;

2、可视化界面可让产品/专业翻译者进行翻译,节省开发者成本。

3、脱离服务端依赖,全套体系前端闭环;

4、一套脚本全项目使用,1小时可提取和翻译一个项目的所有国际化文本。

实现步骤

1、用一个函数如 $t('xxx') 包裹需要国际化的文本(借用AI工具可快速实现);

2、利用脚本为 t('xxx') 生成唯一key,如 t('student.index.[hash]','xxx');

3、搜集所有的key和值放入到对应的国际化json文件中;

4、通过脚本方法将所有国际化json文件上传到obs/oss服务器并维护好当前环境对应的版本;

5、构建可视化的国际化编辑界面,实时读取obs/oss服务器的json文件并有序排列;

6、国际化编辑网页可提供给任意第三方或产品或翻译人员在线翻译,保存时将所有编辑后的文本整理成有序的json文件选择覆盖或新增版本的形式再保存到obs/oss;

7、前端工程通过脚本下载最新保存在obs/oss的json文件并覆盖本地的json;

8、打包发布。

具体实现步骤

1、前端工程中,将所有需要国际化的文案用 $t('xxx') 包裹起来,如:

这一步为了提取脚本文案铺垫,脚本提取文案时 $t()函数是唯一标识,注意考虑文案内有变量场景。

php 复制代码
一定要用 $t() 函数包裹要翻译的文本,如:
$t('登录')、$t('忘记密码')、
$t('一共有{count}条数据',{count:27})等格式。
如果函数不是 $t()函数。 可通过关键词 as 或改脚本内的正则表达式。
关键词 const { translate as $t } = useLocale();

以上步骤可借助trae工具快速实现。告知它将 @/pages/index.tsx 文件(夹)内的所有需要国际化的文本用 $t()函数包裹起来。 --- 目前AI一次处理不了太多文件容易崩

2、通过脚本为 t('xxx') 生成唯一key,如 t('student.index.[hash]','xxx')或 $t('student.index.[hash]','xxx',{count:27})等格式。

这一步处理自动命名键。 通过脚本自动为当前文本生成唯一key,上面key的规则为:文件夹.文件名.[hash],其中 hash的值根据文本生成。

javascript 复制代码
// hash生成函数(也可利用uuid/crypto生成更缜密的唯一字符)
const generateStableHash = (str) => {
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) + hash) + str.charCodeAt(i);
  }
  hash = Math.abs(hash);
  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  for (let i = 0; i < 6; i++) {
    const position = Math.floor(hash / Math.pow(62, i)) % 62;
    result += letters[position];
  }
  return result;
};

3、搜集目录下所有的key和value放入到对应的国际化json文件中(核心);

遍历文件夹下的所有 .ts.tsx... 文件,搜集所有需要翻译的键值对暂存到locales变量中,需注意默认语言(如第一语言为英语即开发中的文案为英文,则将默认文本提取到 en_US.json 文件中)、已提取过的文本(已提取过的不应被覆盖)

javascript 复制代码
// 针对单个文件文本搜集处理
const processFile = (filePath, locales, existingLocales, defaultLang) => {
  let fileContent = fs.readFileSync(filePath, 'utf-8');

  // 修改正则表达式以正确匹配未提取的文案和已提取的文案
  const regexes = [
    // 匹配未提取的文案: $t('文本') 或 $t('文本', '参数') 或 $t('文本', {参数})
    // 但不匹配已经有标准格式key的情况 (任何包含两个点的key,如module.file.hash)
    /\$t\(\s*'(?!.*\..*\.[a-zA-Z0-9]{6})([^']*)'\s*(?:,\s*(?:'([^']*)'|\{[^}]+\}))?\s*\)/g,

    // 匹配已提取的文案: $t('module.file.hash', '文本')
    /\$t\('([\w\-]+\.[\w\-]+\.[a-zA-Z0-9]{6})',\s*'([^']+)'\)/g,

    // 匹配已提取的带参数的文案: $t('module.file.hash', '文本', {参数})
    /\$t\('([\w\-]+\.[\w\-]+\.[a-zA-Z0-9]{6})',\s*'([^']+)',\s*\{([^}]+)\}\)/g
  ];

  regexes.forEach((regex, regIndex) => {
    let match;
    while ((match = regex.exec(fileContent)) !== null) {
      let originalTexts = [];
      let params = '';
      let key = '';
      switch (regexes.indexOf(regex)) {
        case 0:
          originalTexts.push(match[1]);
          key = `${path.basename(path.dirname(filePath))}.${path.basename(filePath, path.extname(filePath))}.${generatePinyinKey(originalTexts.join(''))}`;

          // 检查是否已存在相同的key,已存在则跳过
          if (existingLocales.zh_CN[key]) {
            console.log(`Skipping already processed text with key: ${key}`);
            continue;
          }

          // 检查是否已存在相同的文本内容(通过遍历现有的locales)
          let textExists = false;
          const textToCheck = originalTexts.join('');
          Object.entries(existingLocales.zh_CN).forEach(([existingKey, existingText]) => {
            if (existingText === textToCheck) {
              console.log(`Skipping already processed text: "${textToCheck}" with existing key: ${existingKey}`);
              textExists = true;
              // 使用已存在的key替换
              key = existingKey;
            }
          });

          if (textExists) {
            // 如果文本已存在,使用已有的key进行替换
            const newText = `$t('${key}', '${originalTexts.join("', '")}'${params ? `, {${params}}` : ''})`;
            fileContent = fileContent.replace(match[0], newText);
            continue;
          }

          // 处理有参数场景 $t('xxx',{chat:1})
          let minRegex = /\$t\('.*?',\s*\{([^{}]+)\}\)/;
          let minMatch = minRegex.exec(match[0]);
          if (minMatch?.length > 1) {
            params = minMatch[1];
          }

          const newText = `$t('${key}', '${originalTexts.join("', '")}'${params ? `, {${params}}` : ''})`;

          fileContent = fileContent.replace(match[0], newText);
          locales.zh_CN[key] = originalTexts.join('');
          break;
        case 1:
          key = match[1];
          originalTexts.push(match[2]);
          // 已提取的文案不需要替换,只需记录
          if (!locales.zh_CN[key]) {
            locales.zh_CN[key] = originalTexts.join('');
          }
          break;
        case 2: 
          key = match[1];
          originalTexts.push(match[2]);
          params = match[3];
          // 已提取的文案不需要替换,只需记录
          if (!locales.zh_CN[key]) {
            locales.zh_CN[key] = originalTexts.join('');
          }
          break;
      }

      if (key === '') {
        key = `${path.basename(path.dirname(filePath))}.${path.basename(filePath, path.extname(filePath))}.${generatePinyinKey(originalTexts.join(''))}`;
      }

    
      ['zh_CN', 'en_US', 'zh_HK'].forEach(item => {
        if (item === defaultLang) {
          locales[defaultLang][key] = originalTexts.join('');
        } else {
          locales[item][key] = '';
        }
      });
    }
  });

  
  fs.writeFileSync(filePath, fileContent, 'utf-8');
};

3.1 将搜集到的locales写入/src/locales/文件夹下的zh_CN.jsonen_US.jsonzh_HK.json文件中。

注意合并规则:若新拿到的value为空,则使用旧值。合并新旧key和值后直接写入到json文件中。

javascript 复制代码
const generateLocaleFiles = (locales, existingLocales) => {
  const localeDir = path.join(__dirname, 'src/locales');

  if (!fs.existsSync(localeDir)) {
    fs.mkdirSync(localeDir);
  }

  // 合并语言文件
  const mergeWithPriority = (existing, newLocales) => {
    const result = { ...existing };

    Object.keys(newLocales).forEach(key => {
      // 如果新的值非空,则优先使用新的值,否则保留旧的值
      if (newLocales[key] && newLocales[key].trim() !== '') {
        result[key] = newLocales[key];
      } else if (existing[key] && existing[key].trim() !== '') {
        result[key] = existing[key];
      } else {
        result[key] = '';
      }
    });

    return result;
  };

  ['zh_CN', 'en_US', 'zh_HK'].forEach(lang => {
    const filePath = path.join(localeDir, `${lang}.json`);

    // 合并现有的 locales 和新的 locales
    const mergedLocales = mergeWithPriority(existingLocales[lang], locales[lang]);

    const sortedLocales = Object.keys(mergedLocales)
      .sort()
      .reduce((acc, key) => {
        acc[key] = mergedLocales[key];
        return acc;
      }, {});

    fs.writeFileSync(filePath, JSON.stringify(sortedLocales, null, 2), 'utf-8');
  });
};

4、将国际化json文件上传到obs/oss服务器

注意文件版本差异,可手动调整上传的文件版本,版本相同则覆盖obs文件,不存在则创建新版本

javascript 复制代码
// 上传翻译文件到OBS云服务器
const uploadToObs = async (version) => {
  const handler = new ObsHandler(); // 实例化ObsHandler
  if (await handler.init()) {  // 等待初始化OBS客户端
    await handler.handleFiles(version, true); // 根据版本号执行上传文件到OBS
  }
};

5、实时读取obs/oss服务器的json文件,构建可视化的国际化编辑界面

将 4 中上传的json全部读取,排列成可视化的编辑界面,方便用户/产品在线编辑国际化文案,编辑保存时选择是否新增版本或覆盖当前版本

6、前端下载最新保存在obs/oss的json文件并覆盖本地项目中的json;

javascript 复制代码
// 从OBS下载翻译文件并覆盖本地文件
const downloadFromObs = async (version) => {
  const handler = new ObsHandler();
  if (await handler.init()) {
    await handler.handleFiles(version, false);
  }
};

7、构建发布。将项目中的 locales/*.json文件编译打包到dist文件中。

文案的提取与使用

通过入口文件,可拦截不同命令,达到不同的使用效果。通过以下函数可得出基础能力: a: 一键去重。针对key的重复或去除无效的键值对; b: 一键翻译拓展。(需接入第三方翻译软件的API,将翻译后的结果匹配填充到value中) c: 一键上传。 将本地的所有翻译好的 .json文件根据当前版本上传到 obs中; d: 一键下载。 将可视化界面编辑后保存到obs的所有json文件下载并覆盖本地json文件; e: 其次则是执行翻译,支持2个入参。第一个入参为被执行的文件路径/文件夹,第二个入参为系统默认提取的语言,当前默认提取语言为 en_US 可手动修改。

快捷入口配置

package.json文件中配置快捷命令,方便使用;将 translate.js文件直接放在项目根目录下,通过node执行,node translate.js后添加对应参数,如 node translate.js upload 1.0.0 标识上传所有json文件,且文件版本号为 1.0.0

javascript 复制代码
// package.json
  "scripts": {
    "start": "vite",
    "build": "vite build",
    ...
    "lang": "node translate.js",
    "lang:upload": "node translate.js upload 1.0.0",
    "lang:cover": "node translate.js download 1.0.0",
    "lang:clear": "node translate.js clear"
  },
javascript 复制代码
// translate.js 入口文件
const main = () => {
  const args = process.argv.slice(2);

  // 检查是否有clear参数
  if (args.includes('clear')) {
    clearInvalidKeys();
    return;
  }

  // 检查是否有translate参数
  if (args.includes('translate')) {
    translateLocales();
    return;
  }

  // 检查是否有upload参数
  const uploadIndex = args.indexOf('upload');
  if (uploadIndex !== -1) {
    // 获取版本号参数
    const version = args[uploadIndex + 1];
    if (!version) {
      console.error('错误:使用upload命令时必须提供版本号参数');
      console.log('用法示例: node language.js upload 1.0.0');
      return;
    }
    uploadToObs(version);
    return;
  }

  // 检查是否有download参数
  const downloadIndex = args.indexOf('download');
  if (downloadIndex !== -1) {
    // 获取版本号参数
    const version = args[downloadIndex + 1];
    if (!version) {
      console.error('错误:使用download命令时必须提供版本号参数');
      console.log('用法示例: node language.js download 1.0.0');
      return;
    }
    downloadFromObs(version);
    return;
  }

  const inputPath = args[0] || 'src'; // 用户输入路径或默认 src 目录
  const defaultLang = args[1] || 'en_US'; // 设定默认语言
  const extensions = ['.js', '.ts', '.tsx']; // 要处理的文件后缀
  const localeDir = path.join(__dirname, 'src/locales');

  // 加载现有的 locales 文件内容
  const existingLocales = loadExistingLocales(localeDir);

  const locales = {
    zh_CN: {},
    en_US: {},
    zh_HK: {},
  };

  let files = [];
  const stats = fs.statSync(inputPath);

  if (stats.isDirectory()) {
    files = traverseDirectory(inputPath, extensions);
  } else if (extensions.includes(path.extname(inputPath))) {
    files.push(inputPath);
  }

  files.forEach(file => processFile(file, locales, existingLocales, defaultLang));
  generateLocaleFiles(locales, existingLocales);

  console.log('翻译文案处理完成,语言文件已生成!');
};

项目目录

plaintext 复制代码
ai-tools/
├── src/
├──────pages/
│      ├── ...
│      └── ...
├──────locales/     
│      ├── en_US.json
│      ├── zh_CN.json
│      └── zh_HK.json
├──────App.tsx
├──────main.tsx
├── translate.js
├── package.json
├── .eslintrc.js
└── README.md

obs目录

lang文件夹下区分项目ai-toolsreact-demo,项目下分版本 如1.0.2,每个版本下保存所有翻译的json文件

后续拓展

1、去重。有部分重复文案在不同的文件中,可整理并对他们去重,如在搜集和生成key时,检索是否存在完全相同的value值,有则拿匹配值的key填充即可。

2、命名规则可自定义,如 项目名.文件夹.文件名.[hash],或 文件夹.[hash],hash可根据各自规则自定义,比如自动中文拼音,自动英文字母缩写

javascript 复制代码
const generatePinyinKey = text => {
  const isEnglishText = /^[a-zA-Z\s.,?!:;'"-]+$/.test(text);
  const chineseText = text.match(/[\u4e00-\u9fa5]/g);
  // 英文字符串hash规则
  if (isEnglishText) {
    if (!/\s/.test(text)) {
      return text.toLowerCase();
    }
    const words = text.split(/\s+/);
    const firstLetters = words.map(word => word.charAt(0).toLowerCase());
    return firstLetters.join('');
  }
  // 中文字符串hash规则
  if (chineseText) {
    const chars = chineseText.slice(0, 4).join('');
    return pinyin(chars, { style: pinyin.STYLE_NORMAL }).flat().join('');
  }

  return '';
};

3、匹配的正则表达式需要优化下,比如:t('')仅匹配了单引号,应该需要支持双引号匹配的:t("");

4、一键自动翻译。将提取的文本,通过接入第三方API的方式实现一键翻译文本;

5、如果有足够的资源,可让服务端研发接口,无需将翻译资源保存在obs中。并增加单个文案的锁定能力、修改人、修改时间、修改记录等等。锁定该文案后,不能被自动化脚本或API修改,需解锁后操作。

6、将translate.js脚本封装到私有库或单独脚本工具,与每个项目构建同一连接,不会因为工具代码逻辑变更而导致所有项目需要更新。

7、obs保存增加环境区分,如研发环境,测试环境,生产环境等。其次,可将每个 .json资源开启cdn,前端可通过cdn地址打包最新版本(或从接口拿版本)到项目中,也可以绕过前端编译,cdn资源更改后及时生效,.json文件通过前端工程 import的形式实时加载到项目内,但这一定要慎重考虑。


附:完整translate.js文件地址

(完)

相关推荐
明似水2 分钟前
Flutter 弹窗队列管理:支持优先级的线程安全通用弹窗队列系统
javascript·安全·flutter
小小小小宇23 分钟前
前端国际化看这一篇就够了
前端
大G哥27 分钟前
PHP标签+注释+html混写+变量
android·开发语言·前端·html·php
whoarethenext28 分钟前
html初识
前端·html
小小小小宇41 分钟前
一个功能相对完善的前端 Emoji
前端
m0_6278275242 分钟前
vue中 vue.config.js反向代理
前端
Java&Develop43 分钟前
onloyoffice历史版本功能实现,版本恢复功能,编辑器功能实现 springboot+vue2
前端·spring boot·编辑器
白泽talk1 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务
摆烂工程师1 小时前
全网最详细的5分钟快速申请一个国际 “edu教育邮箱” 的保姆级教程!
前端·后端·程序员
HhhDreamof_1 小时前
云贝餐饮 最新 V3 独立连锁版 全开源 多端源码 VUE 可二开
前端·vue.js·开源