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

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

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

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文件地址

(完)

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax