前端打造高效的国际化解决方案与实践(适合中小型项目,末尾附完整代码)
当前脚本主要解决国际化文案统一搜集和维护。
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.json
、en_US.json
、zh_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-tools
、react-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
的形式实时加载到项目内,但这一定要慎重考虑。