用Babel脚本实现多语言/国际化
需求:
在目前代码基础上,找出所有翻译词,并达到在任意地方注册多语言全局生效
框架是nextjs + react + ts(脚本实现与框架无关)
基本思路(朴素):
基于key-value对,给所有翻译词套一个方法(t方法)来获取不同语言下的内容
t方法
现有的库
-
next自带的i18n
通过路由获取语言环境,同时cookie的优先级更高
支持拼变量(翻译词传入变量)
注意nextjs是app router还是pages router
-
React-intl
自己写
感觉没那么大需求,不用引入这么重的,直接写个方法然后引入翻译包就行,翻译包的路径可以参考nextjs的i18n
主要工作:
提取翻译词:
对所有需要翻译的文本进行某种形式的包裹。
鉴于已有项目,自动化完成这件事
A. 脚本正则化匹配,比较难匹配全
B. 使用工具如 Babel 插件,自动将匹配的字符串替换为国际化函数调用。包裹完之后是完全我们自己维护或者用现有的库都可以,不冲突。
参考:https://juejin.cn/post/7112972686392836103(他是中文,不过大致思路应该是一样的)
Babel 插件的工作原理
- 解析为 AST:Babel 在处理 JavaScript 代码时,会首先将代码解析为抽象语法树(AST)。AST 是代码的结构化表示,允许以编程方式访问和修改代码。
- 遍历和转换:Babel 提供了遍历 AST 的 API,允许开发者编写插件来访问和转换 AST 的特定节点。插件可以在遍历过程中修改节点,添加新的节点,或者提取信息。
- 重新生成代码:经过插件处理后,Babel 将修改后的 AST 转换回 JavaScript 代码。
Babel 插件基本思路
- 创建一个 Babel 插件:
- Babel 插件通常是一个 JavaScript 函数,接收 Babel 的
babel
对象作为参数。 - 使用
babel.traverse
API 来遍历 AST。
- Babel 插件通常是一个 JavaScript 函数,接收 Babel 的
- 识别和提取文本节点:
- 在遍历过程中,识别出可能包含硬编码文本的节点类型,比如
StringLiteral
或TemplateLiteral
。 - 过滤掉不需要翻译的文本(例如,变量名、非用户界面文本等)。( 添加忽略注释?
- 注意编译时和运行时,(变量可能要占位符替换,因为只有在运行时才会有
- 在遍历过程中,识别出可能包含硬编码文本的节点类型,比如
- 自动替换为翻译函数并输出提取词:
- 将识别出的硬编码文本替换为国际化函数调用,比如
t('key')
。 - 为每个文本生成唯一的翻译键(key),并维护一个外部文件来存储这些键与文本的映射。
- 将识别出的硬编码文本替换为国际化函数调用,比如
匹配Babel的AST树节点的记录:(记得排除node_modules)
-
JSXText:
都是 -
StringLiteral:
(有一部分是需要翻译的,类似写在文件最前面的key-value的const文本)
对于一些有键值的静态字面量,(通常是自己写的map),父节点是ObjectProperty,通过查看兄弟节点的Indentifier类型来获取key的名称,提取 text/ label/ description 的value值
但其实容易多提取导致报错,最后代码实现的时候这部分没有提取
nextjs用webpack打包比较慢,导致babel插件在开发的过程中体验不会特别好,加载新页面的时候会插入跑插件这一步(当然npm run build然后npm run start 页面不会卡顿,更新记得删.next 文件夹缓存)
考虑一样的思路改成用babel脚本(与框架无关),遍历节点的时候和babel插件的代码是一样的
目前脚本做的事情是,将jsx节点自动套t方法和将所有套了t方法的放到翻译包里,没有翻译的词放到整体的最前面,最后再把代码库里的代码用路径下的prettierrc文件定义的格式进行格式化
同时注意翻译词写入可能会有编码问题导致key匹配不上,再检查一下
参考代码:
// 存储待翻译的文本
const wordsToTranslate = new Set();
function isAlreadyWrappedWithT(node) {
if (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.name === 't'
) {
return true;
}
return false;
}
// 根据根目录的 prettierrc 格式化单个文件
async function formatFile(filePath, srcDir, outputDir) {
try {
console.log(`格式化文件: ${filePath}`);
// 读取文件内容
const code = fs.readFileSync(filePath, 'utf-8');
// 读取 prettier 配置
const prettierConfig = await prettier.resolveConfig(process.cwd(), {
editorconfig: true,
config: path.join(process.cwd(), '.prettierrc'),
});
// 使用 prettier 格式化代码
const formattedCode = await prettier.format(code, {
...prettierConfig,
filepath: filePath,
});
// 计算输出路径
const relativePath = path.relative(srcDir, filePath);
const outputPath = path.join(outputDir, relativePath);
// 确保输出目录存在
ensureDirectoryExists(path.dirname(outputPath));
// 写入格式化后的代码
fs.writeFileSync(outputPath, formattedCode, 'utf-8');
} catch (error) {
console.error(`格式化文件 ${filePath} 时出错:`, error);
}
}
// 处理单个文件(借助 Babel 的 AST 给代码添加 i18n)
function processFile(filePath, srcDir, outputDir) {
try {
console.log(`处理文件: ${filePath}`);
const code = fs.readFileSync(filePath, 'utf-8');
// 解析代码生成 AST,支持 TypeScript 和 JSX
const ast = parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
});
// 创建初始state对象
const state = {
filename: filePath,
};
// 遍历和修改 AST
traverse(
ast,
{
Program(path) {
let hasI18nImport = false;
path.traverse({
ImportDeclaration(childPath) {
childPath.traverse({
StringLiteral(stringPath) {
if (stringPath.node.value === '@/utils/i18') {
hasI18nImport = true;
}
},
});
},
});
if (
!hasI18nImport &&
!filePath.includes('/node_modules/')
) {
const importDeclaration = t.importDeclaration(
[t.importSpecifier(t.identifier('t'), t.identifier('t'))],
t.stringLiteral('@/utils/i18n'),
);
path.unshiftContainer('body', importDeclaration);
}
},
JSXText(path) {
const transValue = path.node.value
.replace(/\s+/g, ' ')
.replace(/\\n/g, ' ')
.trim();
if (
/.*[a-zA-Z\p{P}].*/.test(transValue) &&
filePath.includes('/src/') &&
!filePath.includes('/node_modules/') &&
!isAlreadyWrappedWithT(path.parentPath.node)
) {
path.replaceWith(
t.jSXExpressionContainer(
t.callExpression(t.identifier('t'), [
t.stringLiteral(transValue),
]),
),
);
wordsToTranslate.add(transValue);
}
},
StringLiteral(path) {
const transValue = path.node.value
.replace(/\s+/g, ' ')
.replace(/\\n/g, ' ')
.trim();
if (
/.*[a-zA-Z\p{P}].*/.test(transValue) &&
filePath.includes('/src/') &&
!filePath.includes('/node_modules/')
) {
if (path.parentPath.node.type === 'CallExpression') {
if (
t.isCallExpression(path.parentPath.node) &&
path.parentPath.node.callee.name === 't'
) {
wordsToTranslate.add(transValue);
}
}
}
}
},
},
state,
);
// 生成新代码
const output = generate(ast, {}, code);
// 计算输出文件路径
const relativePath = path.relative(srcDir, filePath);
const outputPath = path.join(outputDir, relativePath);
// 确保输出目录存在
ensureDirectoryExists(path.dirname(outputPath));
// 写入修改后的代码
fs.writeFileSync(outputPath, output.code, 'utf-8');
} catch (error) {
console.error(`处理文件 ${filePath} 时出错:`, error);
}
}
function post() {
if (wordsToTranslate.size > 0) {
SUPPORTED_LOCALES.forEach((locale) => {
// 设置文件输出路径
const outputDir = path.join(process.cwd(), 'public', 'locales', locale);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputPath = path.join(outputDir, 'common.json');
// 读取现有翻译
let existingWords = {};
if (fs.existsSync(outputPath)) {
existingWords = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
}
// 处理新增词
const newWords = Array.from(wordsToTranslate)
.filter((word) => !Object.values(existingWords).includes(word))
.sort()
.reduce((acc, word) => {
const key = word;
acc[key] = locale === 'en' ? word : '';
return acc;
}, {});
// 合并所有词
const allWords = { ...newWords, ...existingWords };
// 分离空值和非空值
const emptyEntries = [];
const nonEmptyEntries = [];
Object.entries(allWords).forEach(([key, value]) => {
if (!value || value.trim() === '') {
emptyEntries.push([key, value]);
} else {
nonEmptyEntries.push([key, value]);
}
});
// 分别对空值和非空值按key排序
emptyEntries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
nonEmptyEntries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
// 合并排序后的结果,空值在前
const sortedWords = [...emptyEntries, ...nonEmptyEntries].reduce(
(acc, [key, value]) => {
acc[key] = value;
return acc;
},
{},
);
// 写入文件
fs.writeFileSync(
outputPath,
JSON.stringify(sortedWords, null, 2),
'utf8',
);
});
}
}
// 主函数
function main() {
const srcDir = path.join(__dirname, '../src');
const outputDir = path.join(__dirname, '../src');
// 确保输出目录存在
ensureDirectoryExists(outputDir);
const files = getAllFiles(srcDir);
files.forEach((file) => {
processFile(file, srcDir, outputDir);
formatFile(file, srcDir, outputDir);
});
post();
}
main();
维护翻译词:
开发一个简易的翻译平台,所有人可以维护提取词和翻译包 key-value形式。具体形式还得再看看,key尽量参照国际化标准
达成两个目的:a.每个人把翻译提交 b.修改可以应用到项目里(导出json)
不确定能不能让llm写
有现成的平台