Missing Key 检测与 CI 集成
High Contrast
Dark Mode
Light Mode
Sepia
Forest
3 min read523 words

Missing Key 检测与 CI 集成

核心问题:如何自动发现代码里使用了但翻译文件里没有的 key?如何在 CI 流水线中阻止不完整的翻译合并?


真实场景

开发者新增了 5 个翻译 key,但只在 zh-CN.json 中添加了翻译,忘记了 en-US.jsonja-JP.json。这段代码进入生产后,英文用户看到的是 key 名称字符串而不是翻译文字。通过 CI 检测可以在合并前拦截这类问题。


常见问题类型

graph LR A[翻译问题] --> B[Missing Key\n代码用了但文件没有] A --> C[Unused Key\n文件有但代码不用] A --> D[Untranslated Key\n有 key 但某语言没有翻译] A --> E[Invalid Placeholder\n变量名拼错] A --> F[Duplicate Key\n同 key 重复定义]

工具一:i18next-scanner

从代码中扫描所有使用的翻译 key,与翻译文件对比。

安装

npm install --save-dev i18next-scanner

配置

// i18next-scanner.config.js
module.exports = {
input: [
'src/**/*.{ts,tsx,vue}',
// 排除测试文件
'!src/**/*.test.{ts,tsx}',
'!src/**/*.spec.{ts,tsx}',
],
output: './',
options: {
debug: false,
// 是否移除未使用的 key
removeUnusedKeys: false,
sort: true,
attr: {
list: ['data-i18n'],
patterns: ['%s'],
},
func: {
// 扫描这些函数调用中的字符串
list: ['t', 'i18next.t', '$t', 'useTranslations'],
extensions: ['.ts', '.tsx', '.vue'],
},
lngs: ['zh-CN', 'en-US', 'ja-JP'],
defaultLng: 'zh-CN',
defaultValue: '',     // 缺失时的默认值(空字符串便于发现)
resource: {
loadPath: 'src/locales/{{lng}}/{{ns}}.json',
savePath:  'src/locales/{{lng}}/{{ns}}.json',
jsonIndent: 2,
},
nsSeparator: '.',
keySeparator: '.',
// 翻译函数的命名空间参数
ns: ['common', 'product', 'cart', 'checkout', 'order', 'errors'],
defaultNs: 'common',
// 插值表达式模式
interpolation: {
prefix: '{',
suffix: '}',
},
},
};
# 运行扫描:更新翻译文件(添加缺失的 key,值为空字符串)
npx i18next-scanner
# 仅检查,不写文件(CI 模式)
npx i18next-scanner --config i18next-scanner.config.js

工具二:自定义 Key 完整性检查脚本

// scripts/check-i18n.js
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = path.join(__dirname, '../src/locales');
const BASE_LOCALE = 'zh-CN'; // 基准语言(最完整)
const NAMESPACES = ['common', 'product', 'cart', 'checkout', 'errors'];
let exitCode = 0;
// 扁平化 JSON 对象(嵌套 key → 点号分隔)
function flattenKeys(obj, prefix = '') {
return Object.entries(obj).reduce((acc, [key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(acc, flattenKeys(value, fullKey));
} else {
acc[fullKey] = value;
}
return acc;
}, {});
}
// 加载翻译文件
function loadTranslation(locale, namespace) {
const filePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
if (!fs.existsSync(filePath)) return {};
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
// 获取所有 locale
const locales = fs.readdirSync(LOCALES_DIR).filter(f =>
fs.statSync(path.join(LOCALES_DIR, f)).isDirectory()
);
console.log(`检查 locale: ${locales.join(', ')}\n`);
for (const namespace of NAMESPACES) {
const baseTranslations = flattenKeys(loadTranslation(BASE_LOCALE, namespace));
const baseKeys = new Set(Object.keys(baseTranslations));
for (const locale of locales) {
if (locale === BASE_LOCALE) continue;
const translations = flattenKeys(loadTranslation(locale, namespace));
const localeKeys = new Set(Object.keys(translations));
// 缺失的 key
const missing = [...baseKeys].filter(k => !localeKeys.has(k));
// 多余的 key(在目标语言有,但基准语言没有)
const extra = [...localeKeys].filter(k => !baseKeys.has(k));
// 未翻译的 key(key 存在但值为空字符串)
const untranslated = [...localeKeys].filter(k =>
localeKeys.has(k) && translations[k] === ''
);
if (missing.length > 0 || untranslated.length > 0) {
exitCode = 1;
console.error(`❌ [${locale}/${namespace}]`);
if (missing.length > 0) {
console.error(`   缺失 ${missing.length} 个 key:`);
missing.forEach(k => console.error(`     - ${k}`));
}
if (untranslated.length > 0) {
console.error(`   未翻译 ${untranslated.length} 个 key:`);
untranslated.forEach(k => console.error(`     - ${k}`));
}
} else {
console.log(`✅ [${locale}/${namespace}] 完整 (${localeKeys.size} keys)`);
}
if (extra.length > 0) {
console.warn(`⚠️  [${locale}/${namespace}] 有 ${extra.length} 个多余 key(未在基准语言中定义)`);
extra.forEach(k => console.warn(`     + ${k}`));
}
}
}
// 翻译完成率报告
console.log('\n=== 翻译完成率报告 ===');
for (const locale of locales) {
if (locale === BASE_LOCALE) continue;
let total = 0, translated = 0;
for (const namespace of NAMESPACES) {
const base = flattenKeys(loadTranslation(BASE_LOCALE, namespace));
const trans = flattenKeys(loadTranslation(locale, namespace));
total += Object.keys(base).length;
translated += Object.keys(base).filter(k => trans[k] && trans[k] !== '').length;
}
const rate = total > 0 ? ((translated / total) * 100).toFixed(1) : '0.0';
console.log(`${locale}: ${translated}/${total} (${rate}%)`);
}
process.exit(exitCode);
# 在 package.json 中配置
{
"scripts": {
"i18n:check": "node scripts/check-i18n.js",
"i18n:scan": "i18next-scanner",
"i18n:report": "node scripts/i18n-report.js"
}
}

工具三:i18n-ally VS Code 扩展

除了 CI 之外,本地开发阶段也要能实时发现问题:

// .vscode/settings.json
{
"i18n-ally.localesPaths": ["src/locales"],
"i18n-ally.sourceLanguage": "zh-CN",
// 将未翻译的 key 高亮标红
"i18n-ally.review.enabled": true,
// 显示翻译文字替换代码中的 key
"i18n-ally.annotations": true,
"i18n-ally.annotationMaxLength": 40
}

GitHub Actions CI 集成

翻译检查工作流

# .github/workflows/i18n-check.yml
name: i18n Check
on:
pull_request:
paths:
# 只在相关文件变更时触发
- 'src/**/*.ts'
- 'src/**/*.tsx'
- 'src/**/*.vue'
- 'src/locales/**'
jobs:
check-translations:
name: Check Translation Completeness
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Scan i18n keys
run: npm run i18n:scan
# 扫描后检查翻译文件是否有变化(未提交的新 key)
continue-on-error: false
- name: Check for uncommitted changes after scan
run: |
if git diff --quiet src/locales/; then
echo "✅ 翻译文件与代码同步"
else
echo "❌ 发现未同步的翻译 key!"
echo "代码中使用了以下 key,但翻译文件中尚未添加:"
git diff src/locales/
exit 1
fi
- name: Check translation completeness
run: npm run i18n:check
- name: Generate translation report
if: always()
run: npm run i18n:report > translation-report.txt
- name: Comment PR with translation status
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('translation-report.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## 🌍 翻译完成度报告\n\n\`\`\`\n${report}\n\`\`\``
});

翻译完成度阈值检查

// scripts/i18n-threshold.js
// 设置最低翻译完成度门槛(低于此值 CI 失败)
const THRESHOLDS = {
'en-US': 100,  // 英文必须 100% 完整
'ja-JP': 80,   // 日文至少 80%
'ko-KR': 60,   // 韩文至少 60%(新增语言,建设中)
};
// ... 计算各语言完成率,与阈值比较
for (const [locale, threshold] of Object.entries(THRESHOLDS)) {
const rate = calculateCompletionRate(locale);
if (rate < threshold) {
console.error(`❌ ${locale} 翻译完成率 ${rate}% 低于要求的 ${threshold}%`);
process.exit(1);
}
}

Pre-commit Hook 本地检查

# .husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# 只检查当前变更的翻译文件
CHANGED_LOCALE_FILES=$(git diff --cached --name-only | grep 'src/locales/')
if [ -n "$CHANGED_LOCALE_FILES" ]; then
echo "检测到翻译文件变更,运行完整性检查..."
npm run i18n:check
if [ $? -ne 0 ]; then
echo "❌ 翻译完整性检查失败,请修复后再提交"
exit 1
fi
fi
// package.json
{
"scripts": {
"prepare": "husky install"
},
"lint-staged": {
"src/locales/**/*.json": ["npm run i18n:check"]
}
}

处理动态 Key(无法静态分析)

有些 key 在运行时拼接,静态扫描无法发现:

// ❌ 动态拼接(扫描工具无法发现)
const key = `status.${orderStatus}`;
t(key);
// ✅ 解决方案一:穷举所有可能值(类型安全)
const STATUS_KEYS = {
pending: 'status.pending',
processing: 'status.processing',
shipped: 'status.shipped',
delivered: 'status.delivered',
cancelled: 'status.cancelled',
} as const;
t(STATUS_KEYS[orderStatus]);
// ✅ 解决方案二:在 key 前加特殊注释(告诉扫描工具包含这些 key)
// i18n-scanner-include: status.pending, status.processing, status.shipped
const dynamicKey = `status.${orderStatus}`;
t(dynamicKey);

常见问题

Q:翻译文件很多,CI 检查太慢怎么办?

A:只检查本次 PR 变更的相关文件。使用 git diff --name-only origin/main...HEAD 获取变更文件列表,只对变更的 namespace 运行检查。

Q:如何处理第三方库的翻译 key?

A:第三方 UI 库(如 Ant Design、Element Plus)有自己的翻译机制,单独引入不需要纳入项目翻译检查范围。

Q:能自动补全缺失的翻译吗?

A:可以集成机器翻译 API,自动填充缺失翻译为草稿状态,供人工审查。详见第 06 章。


进入第 06 章:翻译文件管理好了,接下来设计翻译工作流——在人工翻译、机器翻译和 TMS 平台之间找到适合你团队的方案。

人工翻译工作流设计