# 痛点背景
先说说机票项目中遇到的困境
目前机票项目分为 H5 和PC两端,团队在维护多语言时主要通过在线 Excel 进行管理
- 一个Excel文件,H5和PC各自占一个sheet页
- 每次更新语言,需要先导出Excel,然后手动跑脚本生成语言文件,在拷贝到项目中
听起来还凑合,但随着项目规模的扩大,问题逐渐显现
- Key命名混乱
- 有的首字母大写,有的小驼峰、大驼峰混用
- 没有统一规则,难以模块化管理。
- 不支持模块化
- 目前已有数千条Key
- 查找、修改、维护都非常痛苦
- 更新流程繁琐
- 需要手动进入脚本目录,用node跑脚本
- 生成后在手动复制到项目中
下面是一个实际的Excel片段,可以感受一下当时的混乱程度
用原node脚本生成的语言文件如图
这样的场景下,每次迭代多语言文件更新都像噩梦一样。 尤其是我们很多翻译是通过AI 机翻生成,后续频繁修改的成本极高。
然而,机票项目的代码量太大、历史包袱太重,短期内几乎不可能彻底改造。
# 新项目 新机会
机票项目虽然不能动,但在我们启动酒店业务新项目时,我决定不能再重蹈覆辙。
因此,在酒店项目中,我从零搭建了这套更高效的 i18n 管理方案。
目标很简单:
- 统一key规则,支持模块化,模块与内容用.隔开,内容之间用下划线隔开
- 自动化生成多语言JSON文件,集成到项目内,不在需要查找转化脚本的位置
- 一条命令搞定更新,不需要手动拷贝
于是,我在项目中新增了一个script目录,并编写了excel-to-json.js脚本,在package.json中添加如下命令
{
"scripts": {
"i18n:excel-to-json": "node scripts/excel-to-json.js"
}
}
以后,只需要运行下面一行命令,就能完成所有工作:
npm run i18n:excel-to-json
这样,我们就可以快速、高效地管理多语言文件,减少了手动操作的成本,提高了开发效率。
# 脚本实现
核心逻辑是:
从Excel读取内容-> 转换位JSON -> 输出到项目I18N目录
完整代码如下:
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import XLSX from 'xlsx'
/**
* 语言映射表:Excel 表头 -> 标准语言码
*/
const languageMap = {
'English': 'en',
'简中': 'zh-CN',
'Chinese (Traditional)': 'zh-TW',
'Korean': 'ko',
'Spanish': 'es',
'German Edited': 'de',
'Italian': 'it',
'Norwegian': 'no',
'French': 'fr',
'Arabic': 'ar',
'Thailandese': 'th',
'Malay': 'ms',
}
// 读取 Excel 文件
function readExcel(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`❌ Excel 文件未找到: ${filePath}`)
}
const workbook = XLSX.readFile(filePath)
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet)
}
/**
* 清空输出目录
*/
function clearOutputDir(dirPath) {
if (fs.existsSync(dirPath)) {
fs.readdirSync(dirPath).forEach(file => fs.unlinkSync(path.join(dirPath, file)))
console.log(`🧹 已清空目录: ${dirPath}`)
} else {
fs.mkdirSync(dirPath, { recursive: true })
console.log(`📂 创建目录: ${dirPath}`)
}
}
/**
* 生成 JSON 文件
*/
function generateLocales(rows, outputDir) {
const locales = {}
rows.forEach(row => {
const key = row.Key
if (!key) return
// 遍历语言列
Object.entries(languageMap).forEach(([columnName, langCode]) => {
if (!locales[langCode]) locales[langCode] = {}
const value = row[columnName] || ''
const keys = key.split('.')
let current = locales[langCode]
keys.forEach((k, idx) => {
if (idx === keys.length - 1) {
current[k] = value
} else {
current[k] = current[k] || {}
current = current[k]
}
})
})
})
// 输出文件
Object.entries(locales).forEach(([lang, data]) => {
const filePath = path.join(outputDir, `${lang}.json`)
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
console.log(`✅ 生成文件: ${filePath}`)
})
}
/**
* 检测缺失翻译
*/
function detectMissingTranslations(rows) {
const missing = []
rows.forEach(row => {
const key = row.Key
if (!key) return
Object.entries(languageMap).forEach(([columnName, langCode]) => {
const value = row[columnName]
if (!value?.trim()) {
missing.push({ key, lang: langCode })
}
})
})
return missing
}
function logMissingTranslations(missingList) {
if (missingList.length === 0) {
console.log('\n🎉 所有 key 的翻译完整!')
return
}
console.warn('\n⚠️ 以下 key 缺少翻译:')
missingList.forEach(item => {
console.warn(` - key: "${item.key}" 缺少语言: ${item.lang}`)
})
}
function main() {
const desktopPath = path.join(os.homedir(), 'Desktop', 'hotel多语言.xlsx')
const outputDir = path.resolve('src/i18n/locales')
const rows = readExcel(desktopPath)
clearOutputDir(outputDir)
generateLocales(rows, outputDir)
logMissingTranslations(detectMissingTranslations(rows))
}
main()