稀土掘金技术社区

忍了一年多,我终于对i18n下手了

前言

大家好,我是奈德丽。

过去一年,我主要参与「国际机票业务」的开发工作,因此每天都要和多语言(i18n)打交道。熟悉我的朋友都知道,我这个人比较“惜力”(并不是,实际上只是忍不下去了),对于「重复笨拙的工作非常抵触」,于是,我开始思考如何优化团队的多语言管理模式。

痛点背景

先说说我们在「机票项目」中遇到的困境。

目前机票项目分为 H5 和 PC 两端,团队在维护多语言时主要通过「在线 Excel」进行管理:

  • 一个 Excel 文件,H5 和 PC 各自占一个 sheet 页;
  • 每次更新语言,需要先导出 Excel,然后手动跑脚本生成语言文件,再拷贝到项目中。

听起来还算凑合,但随着项目规模的扩大,问题逐渐显现:

  1. 「Key 命名混乱」

  • 有的首字母大写,有的小驼峰、大驼峰混用;
  • 没有统一规则,难以模块化管理。
  • 「不支持模块化」

    • 目前已有「数千条 key」;
    • 查找、修改、维护都非常痛苦。
  • 「更新流程繁琐」

    • 需要手动进入脚本目录,用 node 跑脚本;
    • 生成后再「手动复制」到项目中。

    下面是一个实际的 Excel 片段,可以感受一下当时的混乱程度:

    image.png

    用原node脚本生成的语言文件如图

    image.png

    在这样的场景下,「每次迭代多语言文件更新都像噩梦一样」。
    尤其是我们很多翻译是通过「AI 机翻」生成,后续频繁修改的成本极高。

    然而,机票项目的代码量太大、历史包袱太重,短期内「几乎不可能彻底改造」。

    image.png

    新项目,新机会

    机票项目虽然不能动,但在我们启动「酒店业务新项目」时,我决定不能再重蹈覆辙。
    因此,在酒店项目中,我从零搭建了这套更高效的 i18n 管理方案。

    目标很简单:

    1. 「统一 key 规则」,支持模块化,模块与内容间用.隔开,内容之间用下划线隔开;
    2. 「自动化生成」多语言 JSON 文件,集成到项目内,不再需要查找转化脚本的位置;
    3. 「一条命令搞定」更新,不需要手动拷贝。

    于是,我在项目中新增了一个 scripts 目录,并编写了一个 excel-to-json.js 脚本。
    在 package.json 中添加如下命令:

    {  "scripts": {    "i18n:excel-to-json": "node scripts/excel-to-json.js"  }}

    以后,只需要运行下面一行命令,就能完成所有工作:

    pnpm 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()

    成果展示

    这是在线语言原文档

    image.png

    这是生成后的多语言文件和内容image.png

    现在的工作流大幅简化:

    操作
    旧流程
    新流程
    运行脚本
    手动找脚本路径
    pnpm i18n:excel-to-json
    文件生成位置
    生成后手动拷贝
    自动输出到项目
    检测缺失翻译
    无
    自动提示
    key 命名管理
    无统一规则
    模块化、规范化

    这套机制目前在「酒店项目」中运行良好,团队反馈也很积极。

    总结

    这次改造让我最大的感触是:

    ❝

    「旧项目难以推翻重来,但新项目一定要趁早做好架构设计。」

    ❞

    通过这次优化,我们不仅解决了多语言维护的痛点,还提升了团队整体开发效率。
    而这套方案在未来如果机票项目有机会重构,也可以直接平滑迁移过去。

    图片
    点击"阅读原文"了解详情~