国际化项目,经常要随迭代增加翻译词条,如果手动翻译,很容易造成翻译重复或遗漏,而且词条的替换操作体验也极差,这类机械性工作完全可以交给AI完成。
前置准备
首先需要一个AI服务器,推荐ollama,部署简单。
其次需要一个可视化AST结构的工具,推荐astexplorer.net/
AI翻译
翻译部分比较简单,这里是传入中文语言包,然后输出英文语言包。只要调用ollama的api,传入对应的模型和promot,约定输出结构就行。
js
const axios = require('axios');
/**
* 调用 Ollama 模型实现中译英
* @param {string} chineseText - 要翻译的中文语言包文本
* @returns {Promise<string>} 翻译后的英文语言包文本
*/
async function translateChineseToEnglish(chineseText) {
// Ollama 本地 API 地址(默认端口 11434)
const OLLAMA_API_URL = 'http://localhost:11434/api/generate';
// 构建提示词
const prompt = `
你是一个专业的中英翻译助手,请严格按照以下要求执行:
1. 将中文语言包JSON翻译为应为语言包
2. 语言包结构如下: {"k_abc12345": "是","k_rst12345": "确认"},key为i18n key,value为对应语言
1. 唯一输出内容为英文语言包JSON:示例: {"k_abc12345": "yes","k_rst12345": "confirm"}, key不变,值为中文文本的英文翻译结果;
2. 禁止输出:任何非 JSON 内容(包括思考过程、解释、换行、空格、前缀/后缀);
3. 翻译要求:准确、通顺,符合英文表达习惯。
需要翻译的中文语言包:${chineseText}
`.trim();
try {
// 发送请求到 Ollama API
const response = await axios.post(
OLLAMA_API_URL,
{
model: 'qwen3:8b',
prompt: prompt,
stream: false,
temperature: 0.1,
max_tokens: 1000,
},
{
headers: {
'Content-Type': 'application/json',
},
timeout: 30000, // 超时时间 30 秒
},
);
// 解析响应结果
if (response.data && response.data.response) {
// 清理结果中的多余空格/换行
const translatedText = response.data.response.trim();
return translatedText;
} else {
throw new Error('模型返回结果格式异常');
}
} catch (error) {
// 错误处理
}
}
// 示例使用languageJson:{ k_abc12345: '是', k_lmn67890: 'yes' }
async function translate(languageJson) {
// 要翻译的中文文本
const chineseText = JSON.stringify(languageJson);
try {
console.log('待翻译的中文:', chineseText);
console.log('------------------------');
const englishText = await translateChineseToEnglish(chineseText);
console.log('翻译结果:', englishText);
return JSON.parse(englishText);
} catch (error) {
console.error('翻译出错:', error.message);
}
return {};
}
module.exports = { translate };
AST翻译词条替换
i18n key生成
这里首先需要获取到中文,并生成i18n key,对同一中文生成的key应该一样,可以使用md5摘要算法。
js
const genKey = (zh) => 'k_' + crypto.createHash('md5').update(zh).digest('hex').slice(0, 8);
核心逻辑:AST操作
AST操作相对繁琐,这里用到的工具有babel和recast,recast能保留原文件的内容格式。
js
const fg = require('fast-glob');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');
const traverse = require('@babel/traverse').default;
const { parse: babelParse } = require('@babel/parser');
const recast = require('recast');
const t = require('@babel/types');
const { translate } = require('./translate.cjs');
const excludes = [
/language/,
...
];
/* ────── 配置 ────── */
const SRC_DIR = path.resolve(process.cwd(), 'src');
const LOCALE_FILES = [
path.resolve(SRC_DIR, '自定义路径', 'zh-CN.json'),
path.resolve(SRC_DIR, '自定义路径', 'en-US.json'),
// 可继续添加更多语种
];
const GLOB = ['**/*.{js,jsx,ts,tsx}'];
const CHN_RE =
/[\u3400-\u4dbf\u4e00-\u9fff\u{20000}-\u{2a6df}\u{2a700}-\u{2b73f}\u{2b740}-\u{2b81f}\u{2b820}-\u{2ceaf}\u3000-\u303f\uff00-\uffef]/u;
const KEY_RE = /^k_[0-9a-f]{8}$/;
const I18N_IMPORT = '@/i18n';
/* ────── 工具 ────── */
const dict = Object.create(null);
const buildReactCallWithAlias = (aliasName, key) => t.callExpression(t.identifier(aliasName), [t.stringLiteral(key)]);
const buildCommonCall = (key) =>
t.callExpression(t.memberExpression(t.identifier('i18n'), t.identifier('t')), [t.stringLiteral(key)]);
function hasJSX(ast) {
let yes = false;
traverse(ast, {
JSXElement(p) {
yes = true;
p.stop();
},
JSXFragment(p) {
yes = true;
p.stop();
},
});
return yes;
}
function getFunctionName(fnPath) {
const n = fnPath.node;
if (t.isFunctionDeclaration(n) && n.id) return n.id.name;
if (
(t.isFunctionExpression(n) || t.isArrowFunctionExpression(n)) &&
t.isVariableDeclarator(fnPath.parent) &&
t.isIdentifier(fnPath.parent.id)
)
return fnPath.parent.id.name;
return null;
}
function nameLooksLikeComponentOrHook(name) {
if (!name) return false;
if (name.startsWith('use')) return true;
return /^[A-Z]/.test(name);
}
function functionHasJSX(fnPath) {
let found = false;
fnPath.traverse({
JSXElement(p) {
found = true;
p.stop();
},
JSXFragment(p) {
found = true;
p.stop();
},
});
return found;
}
function isClassLikeFunction(fnPath) {
return !!fnPath.findParent((pp) => pp.isClassBody && pp.isClassBody());
}
function getHookableFunctionScope(p) {
const fn = p.getFunctionParent();
if (!fn) return null;
if (!(fn.isFunctionDeclaration() || fn.isFunctionExpression() || fn.isArrowFunctionExpression())) return null;
if (!fn.get('body').isBlockStatement()) return null;
if (isClassLikeFunction(fn)) return null;
const name = getFunctionName(fn);
if (nameLooksLikeComponentOrHook(name) || functionHasJSX(fn)) return fn;
return null;
}
/* ────── 处理单个文件 ────── */
async function transformFile(absPath) {
const code = await fs.readFile(absPath, 'utf8');
if (!CHN_RE.test(code)) return;
let ast;
try {
ast = recast.parse(code, {
parser: {
parse(src) {
return babelParse(src, {
sourceType: 'unambiguous',
plugins: ['jsx', 'typescript', 'decorators-legacy', 'classProperties'],
tokens: true,
});
},
},
});
} catch (e) {
console.warn('skip parse error:', path.relative(process.cwd(), absPath), e.reasonCode || e.message);
return;
}
const reactLike = hasJSX(ast);
let modified = false;
let injectedUseTranslationSomewhere = false;
let usedI18nCommon = false;
const tAliasMap = new WeakMap();
/* ── 内部工具 ── */
function findExistingTAlias(fnPath) {
let alias = null;
fnPath.traverse({
VariableDeclarator(vp) {
const n = vp.node;
if (!t.isObjectPattern(n.id)) return;
for (const pr of n.id.properties) {
if (t.isObjectProperty(pr) && t.isIdentifier(pr.key, { name: 't' })) {
const local = pr.shorthand ? 't' : t.isIdentifier(pr.value) ? pr.value.name : null;
if (local && t.isCallExpression(n.init) && t.isIdentifier(n.init.callee, { name: 'useTranslation' })) {
alias = local;
vp.stop();
}
}
}
},
});
return alias;
}
function ensureTInFunction(fnPath) {
const cached = tAliasMap.get(fnPath.node);
if (cached) return cached;
const existed = findExistingTAlias(fnPath);
if (existed) {
tAliasMap.set(fnPath.node, existed);
return existed;
}
const aliasId = fnPath.scope.hasBinding('t') ? fnPath.scope.generateUidIdentifier('t') : t.identifier('t');
const decl = t.variableDeclaration('const', [
t.variableDeclarator(
t.objectPattern([t.objectProperty(t.identifier('t'), aliasId, false, aliasId.name === 't')]),
t.callExpression(t.identifier('useTranslation'), []),
),
]);
const bodyPath = fnPath.get('body');
if (!bodyPath.isBlockStatement()) return null;
bodyPath.unshiftContainer('body', decl);
injectedUseTranslationSomewhere = true;
tAliasMap.set(fnPath.node, aliasId.name);
return aliasId.name;
}
function getTAliasForPath(p) {
if (!reactLike) return null;
const fn = getHookableFunctionScope(p);
if (!fn) return null;
return ensureTInFunction(fn);
}
/* ── AST 遍历 ── */
traverse(ast, {
StringLiteral(p) {
const raw = p.node.value;
if (!CHN_RE.test(raw)) return;
if (KEY_RE.test(raw)) return;
if (
isImportSource(p) ||
isRequirePath(p) ||
isDynamicImportArg(p) ||
isObjectKey(p) ||
isI18nKey(p) ||
isInTypePosition(p) ||
isConsoleArg(p) ||
isThrowError(p)
)
return;
const key = genKey(raw);
if (!dict[key]) dict[key] = raw;
const tAlias = getTAliasForPath(p);
const inJsxAttr = p.parentPath.isJSXAttribute() && p.parent.value === p.node;
const call = tAlias ? buildReactCallWithAlias(tAlias, key) : ((usedI18nCommon = true), buildCommonCall(key));
if (inJsxAttr) p.replaceWith(t.jsxExpressionContainer(call));
else p.replaceWith(call);
p.skip();
modified = true;
},
TemplateLiteral(p) {
if (p.node.expressions.length) return;
const cooked = p.node.quasis[0].value.cooked;
if (!cooked || !CHN_RE.test(cooked) || KEY_RE.test(cooked)) return;
if (
isImportSource(p) ||
isRequirePath(p) ||
isDynamicImportArg(p) ||
isObjectKey(p) ||
isInTypePosition(p) ||
isConsoleArg(p)
)
return;
const key = genKey(cooked);
if (!dict[key]) dict[key] = cooked;
const tAlias = getTAliasForPath(p);
p.replaceWith(tAlias ? buildReactCallWithAlias(tAlias, key) : ((usedI18nCommon = true), buildCommonCall(key)));
p.skip();
modified = true;
},
JSXText(p) {
const raw = p.node.value;
if (!CHN_RE.test(raw)) return;
const leading = (raw.match(/^\s+/) || [''])[0];
const trailing = (raw.match(/\s+$/) || [''])[0];
const middle = raw.slice(leading.length, raw.length - trailing.length);
if (!middle || !CHN_RE.test(middle) || KEY_RE.test(middle)) return;
const key = genKey(middle);
if (!dict[key]) dict[key] = middle;
const tAlias = getTAliasForPath(p);
const expr = tAlias ? buildReactCallWithAlias(tAlias, key) : ((usedI18nCommon = true), buildCommonCall(key));
const parts = [];
if (leading) parts.push(t.jsxText(leading));
parts.push(t.jsxExpressionContainer(expr));
if (trailing) parts.push(t.jsxText(trailing));
p.replaceWithMultiple(parts);
p.skip();
modified = true;
},
});
/* ── 补充 import ── */
if (injectedUseTranslationSomewhere) {
ensureUseTranslationImport(ast.program);
modified = true;
}
if (usedI18nCommon) {
let programPath = null;
traverse(ast, {
Program(pp) {
programPath = pp;
pp.stop();
},
});
if (programPath) {
ensureI18nImport(ast.program, programPath);
modified = true;
}
}
/* ── 写回文件 ── */
if (modified) {
const output = recast.print(ast, { quote: 'single' }).code;
await fs.writeFile(absPath, output, 'utf8');
console.log('✔ transformed', path.relative(process.cwd(), absPath));
}
}
/* ────── 同步 locale 文件 ────── */
async function flushDict() {
const keys = Object.keys(dict);
if (!keys.length) {
console.log('✨ 没有新的翻译条目');
return;
}
const oldJsonArr = await Promise.all(
LOCALE_FILES.map(async (f) => {
const text = await fs.readFile(f, 'utf8');
try {
return JSON.parse(text);
} catch (e) {
console.error(e, text);
return {};
}
}),
);
//中文语言包
const toTranslate = {};
const file = LOCALE_FILES[0];
const oldJson = oldJsonArr[0];
const merged = { ...oldJson };
for (const k of keys) {
if (!(k in oldJson)) {
merged[k] = dict[k];
toTranslate[k] = dict[k];
}
}
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, JSON.stringify(merged, null, 2) + '\n', 'utf8');
if (Object.keys(toTranslate).length > 0) {
const translated = await translate(toTranslate);
let toMerged = translated || dict;
for (let i = 1; i < LOCALE_FILES.length; i++) {
const file = LOCALE_FILES[i];
const oldJson = oldJsonArr[i];
const merged = { ...oldJson };
for (const k of keys) if (!(k in oldJson)) merged[k] = toMerged[k] || dict[k];
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, JSON.stringify(merged, null, 2) + '\n', 'utf8');
}
}
const newCount = keys.filter((k) => !(k in oldJsonArr[0])).length;
console.log(
`✨ 已同步到所有 locale 文件:${LOCALE_FILES.map((f) => path.relative(process.cwd(), f)).join(
', ',
)},新增 ${newCount} 条`,
);
}
/* ────── 主入口 ────── */
(async () => {
const cliArgs = process.argv.slice(2);
const targets = cliArgs.length ? cliArgs : ['src'];
const patterns = [];
for (const raw of targets) {
let abs = path.resolve(process.cwd(), raw);
let stat = await fs.stat(abs).catch(() => null);
if (!stat) {
const insideSrc = path.join(SRC_DIR, raw);
abs = insideSrc;
stat = await fs.stat(abs).catch(() => null);
}
if (!stat) {
// 先找目录
const dirMatches = await fg(`**/${raw}`, {
cwd: SRC_DIR,
onlyDirectories: true, // 关键改动: 只返回目录
absolute: true,
suppressErrors: true,
});
if (dirMatches.length) {
abs = dirMatches[0];
stat = await fs.stat(abs);
} else {
const fileMatches = await fg(`**/${raw}`, {
cwd: SRC_DIR,
onlyFiles: true,
absolute: true,
suppressErrors: true,
});
if (fileMatches.length) {
abs = fileMatches[0];
stat = await fs.stat(abs);
}
}
}
if (!stat) {
console.warn(`⚠️ 路径不存在: ${raw}`);
continue;
}
if (stat.isFile()) {
patterns.push(abs);
continue;
}
const relToSrc = path.relative(SRC_DIR, abs).replace(/\\/g, '/');
const prefix = relToSrc ? relToSrc : '.';
for (const ptn of GLOB) {
patterns.push(path.posix.join(prefix, ptn));
}
}
if (!patterns.length) {
console.log('⚠️ 无可扫描文件');
return;
}
const files = await fg(patterns, { cwd: SRC_DIR, absolute: true });
for (const f of files) {
if (excludes.some((item) => item.test(f))) {
continue;
}
await transformFile(f);
}
await flushDict();
})().catch((err) => {
console.error(err);
process.exit(1);
});
对i18n依赖的处理
对未引入i18n相关依赖的文件增加依赖引入
js
function ensureUseTranslationImport(program) {
let hasNamed = false;
for (const n of program.body) {
if (t.isImportDeclaration(n) && n.source.value === 'react-i18next') {
if (n.specifiers.some((s) => t.isImportSpecifier(s) && t.isIdentifier(s.imported, { name: 'useTranslation' })))
hasNamed = true;
}
}
if (hasNamed) return;
program.body.unshift(
t.importDeclaration(
[t.importSpecifier(t.identifier('useTranslation'), t.identifier('useTranslation'))],
t.stringLiteral('react-i18next'),
),
);
}
function ensureI18nImport(program, programPath) {
if (programPath.scope.hasBinding('i18n')) return;
program.body.unshift(
t.importDeclaration([t.importDefaultSpecifier(t.identifier('i18n'))], t.stringLiteral(I18N_IMPORT)),
);
}
需要排除的场景
这里排除了一些场景
- import或require资源中的中文
- 作为i18n key的中文
- 对象的中文key
- ts类型中的中文
- throw error的中文
- console的中文
js
/* ────── 辅助判断函数(与原脚本一致) ────── */
function isImportSource(p) {
return (
(p.parent.type === 'ImportDeclaration' ||
p.parent.type === 'ExportNamedDeclaration' ||
p.parent.type === 'ExportAllDeclaration') &&
p.parent.source === p.node
);
}
function isRequirePath(p) {
return (
p.parent.type === 'CallExpression' &&
p.parent.callee.type === 'Identifier' &&
p.parent.callee.name === 'require' &&
p.parent.arguments[0] === p.node
);
}
function isDynamicImportArg(p) {
return p.parent.type === 'CallExpression' && p.parent.callee.type === 'Import' && p.parent.arguments[0] === p.node;
}
function isI18nKey(p) {
if (p.parent.type === 'CallExpression') {
if (p.parent.callee.type === 'MemberExpression') {
return p.parent.callee.property?.name === 't';
} else if (p.parent.callee.type === 'Identifier') {
return p.parent.callee.name === 't';
}
}
return false;
}
function isObjectKey(p) {
return p.parent.type === 'ObjectProperty' && p.parent.key === p.node && !p.parent.computed;
}
function isInTypePosition(p) {
return !!p.findParent(
(pp) => pp.isTSType() || pp.isTSLiteralType?.() || pp.isTSImportType?.() || pp.isTSTypeAnnotation?.(),
);
}
function isThrowError(p) {
return p.parent.type === 'ThrowStatement' || (p.parent.type === 'NewExpression' && p.parent.callee.name === 'Error');
}
function isConsoleArg(p) {
const parent = p.parent;
if (!parent) return false;
if (parent.type !== 'CallExpression') return false;
const args = parent.arguments || [];
if (args.indexOf(p.node) === -1) return false;
const c = parent.callee;
if (t.isMemberExpression(c) && t.isIdentifier(c.object, { name: 'console' })) {
const prop = t.isIdentifier(c.property) ? c.property.name : t.isStringLiteral(c.property) ? c.property.value : '';
return ['log', 'error', 'warn', 'info', 'debug'].includes(prop);
}
return false;
}