React 国际化&项目中英文版本切换

469 阅读4分钟

背景

前端国际化,算是前端的基础能力吧。

分享一种快速的对增量代码和历史代码进行国际化处理的方案。

基础方案

国际化配置文件

export const labels = {
    zh: {
        "QUEDING": "确定",
        "QUXIAO": "取消",
    }

    en: {
        "QUEDING": "Confirm",
        "QUXIAO": "Cancel",
    }
}

getIn18Text 根据语言环境获取不同语言文案

export const getIn18Text = (key) => {  
    let text = '';  
    try {
        // 这一步按照实际情况获取,这里假设项目中将语言环境的变量存到 window 的 lang 变量中
        const localLang = window.lang || 'zh';
        text = labels[localLang][key];  
    } catch (e) {    
        console.error('[Error getIn18Text]', e);  
    }  
    return text;
};

const Acomp = () => {
     return (
       <button>{getIn18Text("QUEDING")}</button>
       <button>{getIn18Text("QUXIAO")}</button>
     );
};

上面展示了基本的实现思路,但是如果每次都手动配置中英文的文案,特别麻烦,并且对于存量的代码怎么处理,总不能一个个改吧。

接下来,分享一种自动生成国际化配置文件,并自动替换文案为 getIn18Text("XXXXX") 的脚本。

自动替换脚本

package.json  配置命令

yarn translate_pick -o 10 提取近10次提交的所有 ts|tsx 翻译

yarn translate_pick -h commit_hash  提取 commit_hash 提交的所有 ts|tsx 翻译

"scripts": {
    translate_pick: "node ./run-with-git.js"
}

run-with-git.js

  • 获取git增量修改的文件路径

  • 文案翻译替换

const { run: jscodeshift } = require('jscodeshift/src/Runner');
const colors = require('colors');
const { getGitDiff } = require('./gitread-shell');
const transformPath = require('./ts-transformer.js');
const blackList = [];
const options = {  
    print: false,  
    verbose: 1,
};

const runParse = async filepath => {  
    if (filepath.length === 0) {   
        return;
    }  try {    
        const res = await jscodeshift(transformPath, filepath, options);    
        console.log(colors.bgRed('transform success!'));  
    } catch (err) {    
        console.log(colors.bgYellow(err), '---err');  
    }
};

getGitDiff(/.(ts|tsx)$/, unresolved => {  
    // 获取git增量修改的文件路径
    const paths = unresolved.filter(item => !blackList.includes(item));  

    // 文案翻译替换  
    runParse(paths);
});

读取 git commit 中的增量代码(gitread-shell.js)

const { exec } = require('node:child_process');
const colors = require('colors');
const yargs = require('yargs');

const getGitResult = command =>  new Promise((res, rej) => {    
    // eslint-disable-next-line consistent-return    
    exec(command, (err, stdout) => {      
        if (err) {        
            return rej(err);      
        }      
        res(stdout.trim());    
    });  
});

const getGitDiff = async (regexp, callback) => {  
    // 参数  
    // yarn translate_pick -o 10 提取近10次提交的所有 ts|tsx 翻译  
    // yarn translate_pick -h commit_hash  提取 commit_hash 提交的所有 ts|tsx 翻译  
    const argv = yargs.alias('h', 'head').alias('o', 'order').argv;  
    let preHead;  
    if (argv.head) {    
        // 如果是指定head    
        preHead = argv.head;  
    } else if (argv.order) {    
        preHead = await getGitResult(`git rev-parse HEAD~${argv.order}`);  
    } else {    
        // 默认最新一次    
        preHead = await getGitResult('git rev-parse HEAD~1');  
    }  
    const curHead = await getGitResult('git rev-parse HEAD');  
    
    // 获取所有发生变化的文件  
    const diffFiles = await getGitResult(`git diff --name-only ${curHead} ${preHead}`);  
    const files = diffFiles.split(/\n/);  

    const status = await getGitResult('git status');  
    if (status.includes('nothing to commit, working tree clean')) {    
        // 已经提交本地的代码    
        // 当前目录    
        let curDir = await getGitResult('git rev-parse --git-dir');    
        curDir = curDir.replace('.git', '');    

        // 生成发生变化的文件路径List    
        const unresolved = files.filter(file => regexp.test(file)).map(file => curDir + file);    
        // console.log(unresolved, '----unresolved');    
        if (unresolved.length === 0) {          
            console.log(colors.bgGreen('未发现需要压缩的文件'));      
            return;    
        }    
        // console.log(unresolved, '----unresolved');    
        // 执行替换回调    
        callback(unresolved);  
    } else {    
        console.log(colors.red('尚有代码未提交,请提交后操作!'));  
    }
};

module.exports = {  getGitDiff,};

文案翻译替换(ts-transformer.js)

const jscodeshift = require('jscodeshift');
const pinyin = require('tiny-pinyin');
const process = require('process');

// 现有文案翻译
const OriginZh = require('./zh.json');

const currentJson = { ...OriginZh };

// 匹配中文正则
const pattern = new RegExp('[\u4E00-\u9FA5]+');
// 表情
const pattern1 = /\[([\u4E00-\u9FA5]{1,4})\]/;
// 注释
const patternComment = /(\*\*)|(\/\/)/;
// 忽略的注释
const ignoreSign = 'translate-ignore';

const translatedZh = {};
const translatedEn = {};
const untranslated = [];

function collectTranslated(info) {
  const { key, zh = '', en = '', noresult = false } = info;
  translatedZh[key] = zh;
  translatedEn[key] = en;
  untranslated.push(zh);
}

let options = null;
function translate(text) {
  const result = String(text).trim().replace(/,/gi, ',');
  const entry = Object.entries(currentJson).find(entry => entry[1] === result);
  if (entry) {
    return entry[0];
  }
  let key = pinyin.convertToPinyin(result.replace(/\r|\n/gi, '').substring(0, 7));
  if (currentJson[key]) {
    key += '1010';
  }
  collectTranslated({
    key,
    zh: result,
    en: result,
    noresult: true,
  });
  return key;
}

let needChange = false;
const replaceCode = text => {
  needChange = true;
  const key = translate(text);
  return `getIn18Text('${key}')`;
};
const importText = "import { getIn18Text } from '@/util';";

const getBaseExpression = text => jscodeshift(replaceCode(text)).getAST()[0].node;
const expressionWithBigBrackets = text => jscodeshift(`{${replaceCode(text)}}`).getAST()[0].node; // 大括号

const transform = (fileInfo, api, optionsConf) => {
  options = optionsConf;
  needChange = false;
  const ast = api.jscodeshift(fileInfo.source);
  let hasTranslateText = false; // 是否存在需要翻译的中文
  let hasImport = false; // 是否引入了统一的方法 getIn18Text

  ast.find(jscodeshift.JSXText).forEach(path => {
    // JSXText: JSX语法中的文本节点,即React组件中的纯文本内容
    const text = path.node.value;
    if (text && text.length > 0 && pattern.test(text) && !pattern1.test(text)) {
      // 存在中文并且不是表情符号
      hasTranslateText = true;
      // 替换
      const pureText = text.replace(/\r|\n/gi, '');
      jscodeshift(path).replaceWith(
        jscodeshift.identifier(`{${replaceCode(pureText)}}`)
      );
    }
  });

  ast.find(jscodeshift.StringLiteral).forEach(path => {
    // StringLiteral:JavaScript代码中的字符串字面量
    const parentType = path.parent.node.type;
    const text = path.node.value;
    if (text && text.length > 0 && pattern.test(text) && !pattern1.test(text) && !patternComment.test(text) && path.parent) {
      // 存在中文并且不是表情符号和注释
      hasTranslateText = true;
      if (parentType === 'VariableDeclarator') {
        // VariableDeclarator: 用于表示变量声明节点,例如 const foo = '你好';
        if (path.parent.parent && path.parent.parent.node.comments) {
          const comments = path.parent.parent.node.comments;
          let needTransform = true;
          comments.forEach(item => {
            if (item && item.value && item.value.includes(ignoreSign)) {
              needTransform = false;
            }
          });
          if (needTransform) {
            path.replace(getBaseExpression(text));
          }
        } else {
          path.replace(getBaseExpression(text));
        }
      } else if (parentType === 'ObjectProperty') {
        // ObjectProperty: 用于表示对象属性节点,例如 { key: '可以' }
        if (path.parent.node.value === path.node) {
          path.replace(getBaseExpression(text));
        }
      } else if (parentType === 'JSXAttribute') {
        // JSXAttribute: 用于表示JSX元素中的属性节点,例如 <div testArr="太棒了"></div>
        path.replace(expressionWithBigBrackets(text));
      } else if (parentType === 'ConditionalExpression') {
        // ConditionalExpression: 用于表示条件表达式节点,例如 condition ? '正确' : "错误"
        path.replace(getBaseExpression(text));
      } else if (parentType === 'TemplateLiteral') {
        // TemplateLiteral: 用于表示模板字符串节点,例如 Hello, ${大王}!
        path.replace(getBaseExpression(text));
      } else if (parentType === 'ArrayExpression') {
        // ArrayExpression: 用于表示数组节点,例如 ['第一', '第二']
        path.replace(getBaseExpression(text));
      } else if (parentType === 'ReturnStatement') {
        // ReturnStatement: 用于表示返回语句节点,例如 return '全部';
        path.replace(getBaseExpression(text));
      } else if (parentType === 'CallExpression') {
        // CallExpression: 用于表示函数调用表达式节点,例如 func(param1, param2)
        const fnName = path.parent.node.callee.name || '';
        if (path.parent.node.callee.type === 'Identifier') {
          path.replace(getBaseExpression(text));
        } else if (path.parent.node.callee.type === 'MemberExpression' && path.parent.node.callee.object.name !== 'console') {
          path.replace(getBaseExpression(text));
        }
      } else if (parentType === 'BinaryExpression') {
        // BinaryExpression: 用于表示二元表达式节点,例如 a + b
        path.replace(getBaseExpression(text));
      }
    }
  });

  ast.find(jscodeshift.Identifier).forEach(path => {
    // Identifier: 用于表示标识符节点,标识符通常用来表示变量名、函数名等,是程序中使用的命名实体
    const parentType = path.parent.node.type;
    const fullText = path.node.name;
    if (fullText.includes('getIn18Text') && parentType === jscodeshift.ImportSpecifier.name) {
      // 如果存在声明
      hasImport = true;
    }
  });

  // 没有引入方法并且存在未翻译的文案
  if (!hasImport && hasTranslateText) {
    jscodeshift(ast.find(jscodeshift.Declaration).at(0).get()).insertBefore(importText);
  }

  if (needChange) {
    return ast.toSource();
  }
  return fileInfo.source;
};

process.on('exit', () => {
  // 写入文件
  const translatedZhFile = `./${Date.now() + ''}zh.json`;
  const translatedEnFile = `./${Date.now() + ''}en.json`;
  const untranslatedFile = `./${Date.now() + ''}un-resolve.csv`;
  fs.writeFileSync(translatedZhFile, JSON.stringify(translatedZh), 'utf-8');
  fs.writeFileSync(translatedEnFile, JSON.stringify(translatedEn), 'utf-8');
  fs.appendFileSync(untranslatedFile, untranslated.join('\n'), 'utf-8');
});

module.exports = transform;
module.exports.parser = 'tsx';

google-translate 翻译替换的文案

"scripts": {
    translate: "node ./google-translate.js"
}
// google-translate.js

const translate = require('@iamtraction/google-translate');
const fs = require('fs');
const path = require('path');
const colors = require('colors');

const googleTranslate = async text => {  
    try {    
        const res = await translate(text, {      from: 'zh-cn',      to: 'en',    });    
        return res.text;  
    } catch (err) {    
        console.log(`翻译出错:${text}`);  
    }  
    return text;
};

const json2csv = (zhPath, enPath) => {  
    let result = 'key,zh,en\n';  
    const Zhjson = require(zhPath);  
    const EnJson = require(enPath);  
    Object.keys(Zhjson).forEach(item => {    
        const zhItem = Zhjson[item].replaceAll(',', ',');    
        const enItem = EnJson[item].replaceAll(',', ',');    
        result += `${item},${zhItem},${enItem}\n`;  
    });  
    const curPath = path.join(process.cwd(), './result.csv');  
    fs.writeFileSync(curPath, result, 'utf-8');  
    console.log(colors.bgRed(`翻译结果在${curPath}, 请交给产品进行check`));
};

const runTranslate = filePath => {  
    try {    
        const zhJson = require(filePath);    
        const promises = [];    
        Object.keys(zhJson).forEach(item => {      
            promises.push(        
                (async () => {          
                    const text = await googleTranslate(zhJson[item]);          
                    zhJson[item] = text;        
                })()
            );    
        });    

        Promise.all(promises).then(res => {      
            fs.writeFileSync(filePath, JSON.stringify(zhJson), 'utf-8');      
            json2csv(path.join(process.cwd(), './zh.json'), path.join(process.cwd(), './en.json'));    
        });  
    } catch (err) {    
        console.log(err);  
    }
};

runTranslate(path.join(process.cwd(), './en.json'));

module.exports = {  
    googleTranslate,  
   runTranslate,
};

总结

利用 jscodeshift 的重构能力,将最近几次 git 提交中的增量的 tsx | ts 文件的中文文案进行识别替换。

利用 @iamtraction/google-translate 的能力对替换的文案进行翻译。