背景
前端国际化,算是前端的基础能力吧。
分享一种快速的对增量代码和历史代码进行国际化处理的方案。
基础方案
国际化配置文件
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 的能力对替换的文案进行翻译。