快速将项目支持国际化(1-快速生成zh-CN.ts)

215 阅读4分钟

背景

某天接到需求,需要将会议室系统(pc、移动端)支持国际化;项目从前期就没有规划国际化,因此项目内的所有中文都直接编码在代码块内

TODO

需要将代码内的中文解耦到 src/locales/zh-CN.ts 里面

问题

工作量大、容易出错

方案

1、到一个代码文件内复制中文,到 zh-CN.ts 创建对应的映射

2、比如如下代码, 手动复制如下文本 组件名已经存在,请重新输入

if (fs.existsSync(dir)) {
    return {
      code: 50002,
      msg: '组件名已经存在,请重新输入'
    }
  }

3、手动创建对象的key value、自己想英文名字

export default { "pages.SpeedSchedule.repeatInput": "组件名已经存在,请重新输入" };

存在问题:一个文件内存在大量的中文文字,单个操作繁琐,工作量大、容易出错、起英文名字困难

思考优化方案:为了解决以上问题,进行一步步优化

1、如何解决大量、单个操作繁琐的问题?

可以在vscode内以文件夹维度进行【正则】搜索中文并且全部复制出来;然后批量再二次处理

image.png

粘贴如下然后统一处理,这样将单个操作统一批量操作,如果你的vscode的快捷键用的6的话很快就能够处理完,这样能够提高我们的效率。

再次思考? 虽然这种方式能够简化我们的操作,但是毕竟我们是程序员,肯定不会止步于此,我们还要去解决 工作量大容易出错起英文名字困难 问题。

我们最终想要的效果是:通过脚本、指定文件路径、自动生成 src/locales/zh-CN.ts 那么我们需要解决哪些问题?

1、通过NodeJS的fs模块,读取文件 2、处理文件,将中文全部提取出来、并且拿到我们想要的格式;并且要注意 key 是需要翻译的,可能我们还需要一个将中文转变英文的库;

读取文件


  fs.readFile('./src/pages/SpeedSchedule/index.tsx', 'utf-8', (err, data) => {
    if (err) {
      console.error(err);
      return;
    }
  });

在一块代码块中输出连续的中文片段,可以使用正则表达式中的分组功能来实现

let pattern = /([\u4e00-\u9fa5]+)/g;
let match = data.match(pattern);

image.png

deleteComment 删除注释的中文

const deleteComment = (code) => {
  // 删除注释
  let pattern = /\/\/.*/g;
  let result = code.replace(pattern, '');
  return result;
};

image.png

问题优化,我们发现 【数据加载失败】【请刷新重试】2个片段被分别了,正确的应该是 [数据加载失败,请刷新重试。优化代码如下:

// let pattern = /([\u4e00-\u9fa5]+)/g;

let pattern = /([\u4e00-\u9fa5]+(?=[^,\u4e00-\u9fa5])|[\u4e00-\u9fa5]+,?[\u4e00-\u9fa5]*)/g;

image.png

去除重复

let uniqueData = [...new Set(data)];

image.png

好了,当我们拿到如下数据的时候,要构建我们的目标数据结构

构建我们的数据结构的时候,key 需要是英语,所以我们需要一个库来转义 pinyin-pro.cn/

{
    "ShuJuJiaZaiShiBaiQingShuaXinZhongShi": "数据加载失败,请刷新重试",
    "BuNengXuanZeJinTianZhiQianDeRiQi": "不能选择今天之前的日期",
    "QingShaoHou": "请稍候",
    "XuanZeRongNaRenShu": "选择容纳人数",
    "BuXianRenShu": "不限人数",
    "Ren": "人",
    "HuiYiYaoQiu": "会议要求",
    "YiZhanYong": "已占用",
    "KongXian": "空闲",
    "YiXuanZe": "已选择"
}

但是我们要考虑一个问题,防止重复,因此我们加一下路径

obj[`SpeedSchedule.${getPinyin(element)}`] = element;

{
  'SpeedSchedule.ShuJuJiaZaiShiBaiQingShuaXinZhongShi': '数据加载失败,请刷新重试',
  'SpeedSchedule.BuNengXuanZeJinTianZhiQianDeRiQi': '不能选择今天之前的日期',
  'SpeedSchedule.QingShaoHou': '请稍候',
  'SpeedSchedule.XuanZeRongNaRenShu': '选择容纳人数',
  'SpeedSchedule.BuXianRenShu': '不限人数',
  'SpeedSchedule.Ren': '人',
  'SpeedSchedule.HuiYiYaoQiu': '会议要求',
  'SpeedSchedule.YiZhanYong': '已占用',
  'SpeedSchedule.KongXian': '空闲',
  'SpeedSchedule.YiXuanZe': '已选择'
}

然后把它追加到 zh-CN.ts 对象上

// 读取现有的 zh-CN.ts
  fs.readFile('./src/locales/zh-CN.ts', 'utf-8', (err, data) => {
    if (err) {
      console.error(err);
      return;
    }

    let objRegex = /export default (\{[\s\S]*\})/;
    let match = data.match(objRegex);

    if (match) {
      let objString = match[1];
      let fileObj = eval(`(${objString})`);
      fileObj = { ...fileObj, ...obj };
      let newObjString = JSON.stringify(fileObj);
      data = data.replace(objRegex, `export default ${newObjString}`);
    }

    console.log('faith=============zh-CN3333333333333333333', data);
    fs.writeFileSync('./src/locales/zh-CN1.ts', data);
  });

添加功能,目前我将将一个文件夹的所有 .tsx 后缀的文件都生成 zh-CN.ts 对象 ,遍历一个路径下的所有以 .tsx 为后缀的文件

// 遍历一个路径下的所有以 .tsx 为后缀的文件
function findTSXFiles(dir) {
  let results = [];

  const files = fs.readdirSync(dir);
  for (const file of files) {
    const filePath = path.join(dir, file);
    const stat = fs.statSync(filePath);
    if (stat && stat.isDirectory()) {
      results = results.concat(findTSXFiles(filePath));
    } else if (path.extname(file) === '.tsx') {
      results.push(filePath);
    }
  }
  return results;
}

然后我们拿到每一个文件的路径之后,在循环调用我们刚才 readAndWrite方法追加到 zh-CN.ts

当前我们路径是写死在代码中的,我们需要通过控制台输入我们想要生成的路径

最终效果

export default { 'AuthRouter.YongHuRenZhengShiBai': '用户认证失败', 'AuthRouter.RenZheng': '认证', 'AuthRouter.QiYeWeiXinTiaoZhuanDaoRenZhengYeMian': '企业微信跳转到认证页面', 'AuthRouter.YongHuRenZhengChuCuo': '用户认证出错', 'AuthRouter.RenZhengZhong': '认证中', 'Loading.tsx.JiaZaiZhong': '加载中', 'components.AppointmentHelp.QingKongZhuYi': '清空注意', 'components.AppointmentHelp.QieHuanRiQiJiangQingKongYiXuanHuiYiShi': '切换日期将清空已选会议室', 'components.AppointmentHelp.ShiFouJiXu': '是否继续', 'components.AppointmentHelp.QueRen': '确认', 'components.AppointmentHelp.QuXiao': '取消', 'components.AppointmentHelp.QieHuanShiJianJiangQingKongYiXuanHuiYiShi': '切换时间将清空已选会议室', 'components.AppointmentHelp.ChaoChuHuiYiZuiDa': '超出会议最大', 'components.AppointmentHelp.FenZhongShiChang': '分钟时长', 'components.AppointmentHelp.SuoXuanShiJianBuNengXiaoYuDangQianShiJian': '所选时间不能小于当前时间', 'components.AppointmentHelp.YiDaDaoKeXuanHuiYiShiZuiDaShuLiang': '已达到可选会议室最大数量', 'components.AppointmentHelp.NinYiJingXuanZeGuoGaiHuiYiShi': '您已经选择过该会议室', 'components.AppointmentHelp.HuiYiShiZaiSuoXuanShiJianDuanNeiCunZaiShiJianChongTu': '会议室在所选时间段内存在时间冲突', 'components.AppointmentHelp.QingXuanZeCanHuiRen': '请选择参会人', 'components.AppointmentHelp.QingXuanZeHuiYiShiJian': '请选择会议时间', 'components.AppointmentHelp.SuoXuanShiDuanNeiCunZaiBeiZhanYongDeHuiYiShi': '所选时段内存在被占用的会议室', 'components.AppointmentHelp.XuanZeBanGongLou': '选择办公楼', 'components.AppointmentHelp.XuanZeHuiYiShi': '选择会议室', 'components.AppointmentHelp.BuXianRenShu': '不限人数', 'components.AppointmentHelp.Ren': '人', 'components.AppointmentHelp.HuiYiYuYueXiaoZhuShou': '会议预约小助手', 'components.AppointmentHelp.QingShaoHou': '请稍候', 'components.AppointmentHelp.CanHuiRen': '参会人', 'components.AppointmentHelp.PiLiangTianJia': '批量添加', 'components.AppointmentHelp.CanHuiRenXiaoBiaoTi': '参会人小标题', 'components.AppointmentHelp.ShuRuXingMingHuoYouXiang': '输入姓名或邮箱', 'components.AppointmentHelp.CanHuiRenXingXiang': '参会人行项', 'components.AppointmentHelp.BiXuCanHui': '必需参会', 'components.AppointmentHelp.XuanZeCanHui': '选择参会', 'components.AppointmentHelp.HuiYiShiXiaoBiaoTi': '会议室小标题', 'components.AppointmentHelp.HuiYiShi': '会议室', 'components.AppointmentHelp.HuiYiShiShaiXuanQi': '会议室筛选器', 'components.AppointmentHelp.HuiYiShiYiXuanZeXiang': '会议室已选择项', 'components.AppointmentHelp.HuiYiShiDaiXuanZeXiang': '会议室待选择项', 'components.AppointmentHelp.YouCeBuFen': '右侧部分', 'components.AppointmentHelp.RiQiQieHuanZhong': '日期切换中', 'components.AppointmentHelp.YouCeBiaoGeBuFen': '右侧表格部分', 'components.AppointmentHelp.HuiYiShiXiaoBiaoTiDuiYing': '会议室小标题对应', 'components.AppointmentHelp.KongXing': '空行', 'components.AppointmentInfo.BaoQian': '抱歉', 'components.AppointmentInfo.TuPianWuFaXianShi': '图片无法显示', 'components.AppointmentInfo.QingLianXiXiangGuanRenYuan': '请联系相关人员', 'components.AppointmentInfo.QueRenYaoQuXiaoBenCiHuiYiMa': '确认要取消本次会议吗', 'components.AppointmentInfo.QueRenYaoQuXiaoSuoYouShiDuanDeZheGeHuiYiMa': '确认要取消所有时段的这个会议吗', 'components.AppointmentInfo.QueRenYaoQuXiaoZheGeHuiYiMa': '确认要取消这个会议吗', 'components.AppointmentInfo.ShiFouQuXiao': '是否取消', 'components.AppointmentInfo.DeHuiYi': '的会议', 'components.AppointmentInfo.HuiYiXiangQing': '会议详情', 'components.AppointmentInfo.ZaiCiYuYue': '再次预约', 'components.AppointmentInfo.BianJi': '编辑', 'components.AppointmentInfo.QuXiaoHuiYi': '取消会议', 'components.AppointmentInfo.HuiFu': '回复', 'components.AppointmentInfo.JieShou': '接受', 'components.AppointmentInfo.ZanDing': '暂定', 'components.AppointmentInfo.JuJue': '拒绝', 'components.AppointmentInfo.ZuZhiRen': '组织人', 'components.AppointmentInfo.ZuZhiZhe': '组织者', 'components.AppointmentInfo.BiXuCanHuiRen': '必需参会人', 'components.AppointmentInfo.ShouQi': '收起', 'components.AppointmentInfo.ZhanKai': '展开', 'components.AppointmentInfo.KeXuanCanHuiRen': '可选参会人', 'components.AppointmentInfo.CanHuiRenJieShouJuJueQingKuang': '参会人接受拒绝情况', 'components.AppointmentInfo.WuXiangYing': '无响应', 'components.AppointmentInfo.HuiYiShiJian': '会议时间', 'components.AppointmentInfo.HuiYiShi': '会议室', 'components.AppointmentInfo.Ren': '人', 'components.AppointmentInfo.WeiZhiZhiYin': '位置指引', 'components.AppointmentInfo.HuiYi': '会议', 'components.AppointmentInfo.HuiYiMiMa': '会议密码', 'components.AppointmentInfo.ChongFu': '重复', 'components.AppointmentInfo.HuiYiTiXing': '会议提醒', 'components.AppointmentInfo.MiaoShu': '描述', 'components.AppointmentMeeting.Wu': '五', 'components.AppointmentMeeting.ShiJianFaShengShi': '事件发生时', 'components.AppointmentMeeting.FenZhongQian': '分钟前', 'components.AppointmentMeeting.XiaoShiQian': '小时前', 'components.AppointmentMeeting.TianQian': '天前', 'components.AppointmentMeeting.YiZhouQian': '一周前', 'components.AppointmentMeeting.ShiJianRiQi': '事件日期',
.............

主要代码

{
  "private": true,
  "version": "1.0.9",
  "type": "module",
  "scripts": {
    "generate_locales": "node ./.generate_locales/index.js",
  },
  "dependencies": {

    "inquirer": "^9.1.4",
    "pinyin-pro": "^3.13.0",
   
  }
}

.generate_locales/index.js

import fs from 'fs';
import inquirer from 'inquirer';
import path from 'path';
import pkg from 'pinyin-pro';
const { pinyin } = pkg;

const questions = [
  {
    name: 'filePath',
    message: 'Please input the you want filePath:',
  },
];

const prompt = () => {
  return new Promise((resolve) => {
    inquirer.prompt(questions).then((res) => {
      resolve(res);
    });
  });
};

function mustInluceComponent(res) {
  console.log(`❌❌❌ ${res.msg}`);
}

const firstWordBig = (word) => {
  if (!word) return;
  let firstLetter = word[0];
  let uppercaseFirstLetter = firstLetter.toUpperCase();
  let newWord = uppercaseFirstLetter + word.slice(1);
  return newWord;
};

const getPinyin = (text) => {
  let englishText = pinyin(text.replace(/,/g, ''), { toneType: 'none' });
  let resultzPinyin = '';
  englishText.split(' ').forEach((word) => {
    resultzPinyin += firstWordBig(word) || '';
  });
  return resultzPinyin;
};

async function createComponent() {
  let res = await verificationInput();
  if (res?.code && res?.code === 50001) {
    console.log('非法输入');
    return;
  }
  readFile(res);
  // codeFc[res.code](res);
}

const deleteComment = (code) => {
  // 删除注释
  let pattern = /\/\/.*/g;
  let result = code.replace(pattern, '');
  return result;
};

const extractChinese = (data) => {
  let pattern = /([\u4e00-\u9fa5]+(?=[^,\u4e00-\u9fa5])|[\u4e00-\u9fa5]+,?[\u4e00-\u9fa5]*)/g;
  let match = data.match(pattern);
  return match;
};

// 遍历一个路径下的所有以 .tsx 为后缀的文件
function findTSXFiles(dir) {
  let results = [];

  const files = fs.readdirSync(dir);
  for (const file of files) {
    const filePath = path.join(dir, file);
    const stat = fs.statSync(filePath);
    if (stat && stat.isDirectory()) {
      results = results.concat(findTSXFiles(filePath));
    } else if (path.extname(file) === '.tsx') {
      results.push(filePath);
    }
  }
  return results;
}

const buildDataStructure = (uniqueData, localesPreName) => {
  let obj = {};
  uniqueData.forEach((element) => {
    obj[`${localesPreName}.${getPinyin(element)}`] = element;
  });
  // 读取现有的 zh-CN.ts
  let data = fs.readFileSync('./src/locales/zh-CN.ts', 'utf-8');

  let objRegex = /export default (\{[\s\S]*\})/;
  let match = data.match(objRegex);

  if (match) {
    let objString = match[1];
    let fileObj = eval(`(${objString})`);
    fileObj = { ...fileObj, ...obj };
    let newObjString = JSON.stringify(fileObj);
    data = data.replace(objRegex, `export default ${newObjString}`);
  }
  fs.writeFileSync('./src/locales/zh-CN.ts', data);
};

const readFile = (filePath) => {
  try {
    fs.statSync(filePath);
    console.log('progress: 20%, 路径存在');

    // 读取路径下所有 .tsx为后缀的文件路径
    const allTSXFiles = findTSXFiles(filePath);
    console.log(allTSXFiles);
    allTSXFiles.forEach((element) => {
      readAndWrite(element);
    });
    console.log('👏👏👏success! 👏👏👏');
  } catch (error) {
    console.log('路径不存在,请检查', error);
  }
};

const readAndWrite = (file) => {
  // 读取文件
  let data = fs.readFileSync(file, 'utf-8');

  let localesPreName = file
    .replace('/index.tsx', '')
    .replace(/\//g, '.')
    .replace(/src\.\./g, '')
    .replace(/src\./g, '');

  data = deleteComment(data);
  data = extractChinese(data);
  let uniqueData = [...new Set(data)];
  buildDataStructure(uniqueData, localesPreName);
};

async function verificationInput() {
  let res = await prompt();
  const { filePath } = res;
  if (!filePath) {
    return {
      code: 50001,
      msg: 'You must include filePath',
    };
  }

  // const dir = `./src/components`;
  console.log('faith=============filePathfilePath', filePath);
  const dir = filePath;

  return dir;
}

createComponent();