🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

忍了一年多,我終於對i18n下手了

image

前言

大家好,我是奈德麗。

過去一年,我主要參與國際機票業務的開發工作,因此每天都要和多語言(i18n)打交道。熟悉我的朋友都知道,我這個人比較「惜力」(並不是,實際上只是忍不下去了),對於重複笨拙的工作非常抵觸,於是,我開始思考如何優化團隊的多語言管理模式。

痛點背景

先說說我們在機票專案中遇到的困境。

目前機票專案分為 H5 和 PC 兩端,團隊在維護多語言時主要透過線上 Excel進行管理:

  • 一個 Excel 檔案,H5 和 PC 各自占一個 sheet 頁;
  • 每次更新語言,需要先匯出 Excel,然後手動跑腳本生成語言檔,再拷貝到專案中。

聽起來還算湊合,但隨著專案規模的擴大,問題逐漸顯現:

  1. Key 命名混亂

    • 有的首字母大寫,有的小駝峰、大駝峰混用;
    • 沒有統一規則,難以模組化管理。
  2. 不支援模組化

    • 目前已有數千條 key
    • 查找、修改、維護都非常痛苦。
  3. 更新流程繁瑣

    • 需要手動進入腳本目錄,用 node 跑腳本;
    • 生成後再手動複製到專案中。

下面是一個實際的 Excel 片段,可以感受一下當時的混亂程度:

image

用原node腳本生成的語言檔如圖

image

在這樣的場景下,每次迭代多語言檔更新都像噩夢一樣
尤其是我們很多翻譯是透過AI 機翻生成,後續頻繁修改的成本極高。

然而,機票專案的程式碼量太大、歷史包袱太重,短期內幾乎不可能徹底改造

image

新專案,新機會

機票專案雖然不能動,但在我們啟動飯店業務新專案時,我決定不能再重蹈覆轍。
因此,在飯店專案中,我從零搭建了這套更高效的 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

這是生成後的多語言檔和內容
image

現在的工作流大幅簡化:

操作 舊流程 新流程
運行腳本 手動找腳本路徑 pnpm i18n:excel-to-json
檔生成位置 生成後手動拷貝 自動輸出到專案
檢測缺失翻譯 自動提示
key 命名管理 無統一規則 模組化、規範化

這套機制目前在飯店專案中運行良好,團隊反饋也很積極。

總結

這次改造讓我最大的感觸是:

舊專案難以推翻重來,但新專案一定要趁早做好架構設計。

透過這次優化,我們不僅解決了多語言維護的痛點,還提升了團隊整體開發效率。
而這套方案在未來如果機票專案有機會重構,也可以直接平滑遷移過去。


原文出處:https://juejin.cn/post/7553105607417053194


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝11   💬9   ❤️3
503
🥈
我愛JS
📝3   💬8   ❤️7
175
🥉
AppleLily
📝1   💬4   ❤️1
66
#4
xxuan
💬1  
3
#5
💬1  
3
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付