
Vue3 国际化工具链vue-i18n 的消息提取与翻译工作流自动化一、翻译文案散落在一百个文件里——国际化进程中的管理灾难Vue3 项目做国际化的第一步通常很简单装vue-i18n配置 locale 文件用$t()替换硬编码文本。但当项目规模和技术债同时增长时真正的问题才开始浮现翻译文案分散在几十个组件的$t()调用和独立的 locale JSON 文件中没有单一的真实来源。新增文案后需要手动把中文 key 复制到英文翻译文件中——这个过程全凭记忆漏翻是常态。翻译人员在 i18n 的嵌套 JSON 结构里查找待翻译的 key体验极其反人类。部分文案最终根本没有对应的翻译页面上显示的是原始中文 key 或被 fallback 覆盖。这些问题本质上不是 vue-i18n 的缺陷而是缺少一条完整的工具链——从代码中自动提取待翻译消息、生成标准化的翻译工作文件、以及翻译完成后的自动化合并与校验。二、i18n 工具链的架构提取、翻译、合并三阶段graph LR A[Vue 组件源码] -- B[消息提取] C[已有 locale JSON] -- B B -- D[生成待翻译清单br/JSON/YAML/CSV] D -- E[翻译人员/平台] E -- F[返回已翻译文件] F -- G[自动合并] A -- G G -- H[构建时校验] H --|缺失| I[CI 报错阻断] H --|通过| J[打包部署] style B fill:#e1f5fe style G fill:#e1f5fe工具链分为三个阶段消息提取扫描所有.vue、.ts、.js文件中的$t(xxx)、t(xxx)、i18n.t(xxx)调用提取出 key与已有的 locale 文件对比找出缺失的翻译。翻译分发将待翻译的消息以对翻译人员友好的格式CSV 是最简单的选择——任何翻译人员都能用 Excel 打开输出。自动合并翻译完成的 CSV 被解析后自动合并到各 locale 的 JSON 文件中完成翻译闭环。三、消息提取与翻译工作流的工程实现AST 级别的消息提取脚本// scripts/i18n-extract.js — 从源码中提取所有 i18n key const fs require(fs); const path require(path); const { parse } require(vue/compiler-sfc); const parser require(babel/parser); const traverse require(babel/traverse).default; class I18nExtractor { constructor(rootDir) { this.rootDir rootDir; this.keys new Set(); } extract() { this.walkDir(this.rootDir); return Array.from(this.keys).sort(); } walkDir(dir) { const entries fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath path.join(dir, entry.name); if (entry.isDirectory()) { // 跳过 node_modules 和 dist if (entry.name ! node_modules entry.name ! dist) { this.walkDir(fullPath); } } else if (entry.isFile()) { if (entry.name.endsWith(.vue)) { this.extractFromVue(fullPath); } else if (entry.name.endsWith(.ts) || entry.name.endsWith(.js)) { this.extractFromScript(fullPath); } } } } extractFromVue(filePath) { const content fs.readFileSync(filePath, utf-8); const { descriptor } parse(content); // 解析 template 中的 $t() if (descriptor.template) { const templateContent descriptor.template.content; const regex /\$t\([]([^])[]\)/g; let match; while ((match regex.exec(templateContent)) ! null) { this.keys.add(match[1]); } } // 解析 script 中的 t() if (descriptor.script || descriptor.scriptSetup) { const scriptContent (descriptor.script || descriptor.scriptSetup).content; this.extractFromScriptContent(scriptContent); } } extractFromScript(filePath) { const content fs.readFileSync(filePath, utf-8); this.extractFromScriptContent(content); } extractFromScriptContent(content) { try { const ast parser.parse(content, { sourceType: module, plugins: [typescript, jsx], }); traverse(ast, { CallExpression(nodePath) { const { callee, arguments: args } nodePath.node; const firstArg args[0]; // 匹配 t(...) 或 $t(...) 或 i18n.t(...) let isI18nCall false; if (callee.type Identifier (callee.name t || callee.name $t)) { isI18nCall true; } else if ( callee.type MemberExpression callee.object.type Identifier callee.object.name i18n callee.property.name t ) { isI18nCall true; } if (isI18nCall firstArg firstArg.type StringLiteral) { this.keys.add(firstArg.value); } }, }); } catch (e) { // Babel 解析失败时跳过如不完整的 TypeScript 语法 console.warn(Parse warning: ${e.message}); } } }翻译缺失检测与 CSV 导出// scripts/i18n-diff.js — 对比源码 key 与已有翻译生成待翻译清单 const fs require(fs); const path require(path); class I18nDiffGenerator { constructor(localesDir, sourceKeys) { this.localesDir localesDir; this.sourceKeys sourceKeys; } generate() { const locales this.loadLocales(); const report {}; for (const [locale, messages] of Object.entries(locales)) { const missing this.sourceKeys.filter(key !(key in messages)); const unused Object.keys(messages).filter(key !this.sourceKeys.includes(key)); report[locale] { missing, unused, total: Object.keys(messages).length }; if (missing.length 0) { console.log(${locale}: 缺失 ${missing.length} 个翻译); } if (unused.length 0) { console.log(${locale}: ${unused.length} 个未使用的 key可能是死代码); } } return report; } loadLocales() { const locales {}; const files fs.readdirSync(this.localesDir).filter(f f.endsWith(.json)); for (const file of files) { const locale path.basename(file, .json); const content fs.readFileSync(path.join(this.localesDir, file), utf-8); locales[locale] JSON.parse(content); } return locales; } exportCSV(outputPath, report) { const rows [[Key, ...Object.keys(report)]]; for (const key of this.sourceKeys) { const row [key]; for (const locale of Object.keys(report)) { const locales this.loadLocales(); row.push(locales[locale][key] || ); } rows.push(row); } const csv rows.map(row row.map(cell ${String(cell).replace(//g, )}).join(,) ).join(\n); fs.writeFileSync(outputPath, \uFEFF csv, utf-8); console.log(CSV 已导出: ${outputPath}); } }翻译文件自动合并// scripts/i18n-merge.js — 从 CSV 合并翻译到 locale JSON const fs require(fs); const path require(path); const csvParse require(csv-parse/sync); class I18nMerger { merge(csvPath, localesDir) { const content fs.readFileSync(csvPath, utf-8); const records csvParse.parse(content, { columns: true, skip_empty_lines: true, }); const localeFiles {}; for (const record of records) { const key record[Key]; if (!key) continue; for (const [header, value] of Object.entries(record)) { if (header Key || !value) continue; if (!localeFiles[header]) { localeFiles[header] JSON.parse( fs.readFileSync(path.join(localesDir, ${header}.json), utf-8) ); } // 使用嵌套 key 如 nav.home.title 设置值 this.setNestedValue(localeFiles[header], key, value); } } // 写回 JSON 文件 for (const [locale, data] of Object.entries(localeFiles)) { const filePath path.join(localesDir, ${locale}.json); fs.writeFileSync(filePath, JSON.stringify(data, null, 2) \n, utf-8); console.log(Updated: ${filePath}); } } setNestedValue(obj, keyPath, value) { const parts keyPath.split(.); let current obj; for (let i 0; i parts.length - 1; i) { if (!current[parts[i]]) current[parts[i]] {}; current current[parts[i]]; } current[parts[parts.length - 1]] value; } }四、工具链的边界嵌套 key、复数与 ICU 消息格式这套提取脚本基于简单的正则和 AST 遍历有几个无法覆盖的场景动态 key$t(error. code)这种运行时拼接的 key静态分析无法提取。这类 key 需要用注释标记或改用枚举字典让提取器能找到所有可能的值。复数与格式化$tc(apples, count)涉及复数规则。提取时只能提取 key翻译文件中的复数形式one、few、many需要翻译人员手动填写。ICU 消息格式更复杂的国际化场景如{count, plural, 0 {No apples} one {# apple} other {# apples}}超出了简单 key-value 翻译的范围。如果项目需要 ICU 支持建议升级到 vue-i18n 的 Composition API 模式并引入专门的 ICU 提取工具。对于大多数中小型 Vue3 项目上述工具链已经覆盖 95% 的国际化需求。动态 key 的数量通常有限手动管理即可不值得为 5% 的边缘场景引入重型 ICU 工具链。五、总结国际化的痛点不在 vue-i18n 本身而在翻译文案的管理流程。消息提取、翻译分发、自动合并——这三步串起来才是一条完整的工具链。落地方案先跑一遍提取脚本看看项目中有多少硬编码文本和未被翻译的 key。将这些缺失翻译输出为 CSV 交给翻译人员处理完成后用合并脚本回写到 JSON。最后在 CI 中加一步提取脚本输出缺失翻译数非零就打断构建。这一套下来不超过 300 行脚本但能让国际化管理从全靠记忆力变成全靠自动化。