国际化语言包与Excel自动化双向转换方案

一、方案背景

在国际化项目开发过程中,多语言资源(locales语言包)的管理通常要经历下面的流程:

  1. 开发人员通过机翻初步整理好一份语言包
  2. 把语言包转成Excel交付给翻译团队人工翻译
  3. 把人工翻译好的Excel重新转换为语言包且需保证其正确性

这种手动转换的方式存在效率低下、容易出错的问题。因此需要设计一套自动转换方案,实现语言包和Excel的自动化双向转换。

二、方案目标

  1. 实现语言包和Excel的自动化双向转换。
  2. Excel需包含"功能模块"列,方便定位语言包中字段对应页面的具体位置。
  3. 若存在中文语言包,转换成Excel时把中文列放在其它语言列前面,方便翻译对照。
  4. 基于TypeScript的项目,需提供完整的类型支持。

三、技术选型与目录结构设计

3.1 技术选型

  • 核心框架:Vue3 + Vite + TypeScript
  • 国际化方案:vue-i18n
  • 语言包格式:YAML(支持注释功能)
  • 表格处理:xlsx 库
  • 自动化工具:Node.js脚本,脚本可以考虑大模型实现

3.2 目录结构

plaintext 复制代码
├── package.json                  # 项目依赖与脚本配置
├── vite.config.ts                # Vite配置文件
├── build/
│   └── vite-plugin-i18n-types.ts # 自动生成类型声明的Vite插件
├── scripts/
│   └── i18n/
│       ├── yaml2xlsx.js          # YAML转Excel脚本
│       ├── xlsx2yaml.js          # Excel转YAML脚本
│       └── output/
│           ├── translations.xlsx # 生成的Excel文件
│           └── locales/          # 从Excel生成的语言包
├── types/
│   └── i18n.d.ts                 # 自动生成的类型声明文件
└── src/
    ├── i18n/
    │   ├── index.ts              # i18n配置入口
    │   └── locales/
    │       ├── zh-CN.yaml        # 中文语言包
    │       ├── en-US.yaml        # 英文语言包
    │       └── ...               # 其他语言包
    └── ...                       # 其他业务代码

四、核心实现方案

4.1 语言包设计

采用yaml文件管理语言包。

为什么不用更常用的json格式? 脚本在把语言包转换为Excel文件时,需要根据一些标记(如注释)来生成【功能模块】列,而json格式不支持注释。 js或ts也支持注释,但属于特定开发语言的格式,而非数据序列化格式,不够通用。 yaml是支持注释、开发语言无关的数据序列化格式,但对于TypeScript类型不友好,需要额外支持,不过这个问题解决了,后面会提到。所以最终选择了yaml格式。

zh-CN.yaml示例:

yaml 复制代码
# 首页
home:
  # 导航栏
  navbar:
    title: '首页'
# 登录页
login:
  # 表单
  form:
    username: '用户名'
    password: '密码'

en-US.yaml示例:

yaml 复制代码
# 首页
home:
  # 导航栏
  navbar:
    title: 'Home'
# 登录页
login:
  # 表单
  form:
    username: 'Username'
    password: 'Password'

4.2 YAML转Excel实现(yaml2xlsx.js)

js 复制代码
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import yaml from 'yaml'
import XLSX from 'xlsx'
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'

// 获取当前文件的目录路径
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

/**
 * 反转义YAML单引号字符串中的转义序列
 * @param {string} str - 要反转义的字符串
 * @returns {string} 反转义后的字符串
 */
function unescapeSingleQuotes(str) {
  // 将两个连续的单引号转义为一个单引号
  return str.replace(/''/g, "'")
}

/**
 * 解析YAML文件并提取注释路径
 * @param {string} filePath - YAML文件路径
 * @returns {Object} 解析后的数据对象,包含键值对和模块路径
 */
function parseYamlWithComments(filePath) {
  // 读取YAML文件内容
  const content = fs.readFileSync(filePath, 'utf8')
  // 按行分割内容
  const lines = content.split('\n')
  const result = {}
  // 注释栈,用于存储当前层级的注释
  const commentStack = []

  // 当前路径栈和注释路径栈
  let currentPath = []
  let currentCommentPath = []

  // 遍历每一行
  lines.forEach((line, index) => {
    const trimmedLine = line.trim()

    // 如果是注释行
    if (trimmedLine.startsWith('#')) {
      // 提取注释内容(去掉#号并去除前后空格)
      const comment = trimmedLine.substring(1).trim()
      if (comment) {
        // 将注释压入栈中
        commentStack.push(comment)
      }
      return // 注释行不进行后续处理
    }

    // 如果是键值对行(包含冒号且不是注释)
    if (trimmedLine.includes(':') && !trimmedLine.startsWith('#')) {
      // 提取键名
      const key = trimmedLine.split(':')[0].trim()
      // 计算缩进级别(通过行首空格数量)
      const indent = line.match(/^\s*/)[0].length

      // 计算当前层级(假设使用2空格缩进)
      const level = indent / 2

      // 根据层级更新路径栈(移除超出当前层级的部分)
      currentPath = currentPath.slice(0, level)
      currentCommentPath = currentCommentPath.slice(0, level)

      // 将当前键添加到路径栈
      currentPath.push(key)

      // 处理注释路径
      if (commentStack.length > 0) {
        // 如果有注释,将其添加到注释路径栈
        currentCommentPath.push(commentStack.pop())
      } else if (currentCommentPath.length < level && currentCommentPath.length > 0) {
        // 如果没有注释但需要继承父级注释,使用父级注释
        currentCommentPath.push(currentCommentPath[currentCommentPath.length - 1])
      }

      // 检查是否是叶子节点(包含值的节点)
      const valueMatch = trimmedLine.match(/:\s*(.+)$/)
      if (valueMatch && valueMatch[1].trim()) {
        // 提取值并去除前后空格
        let value = valueMatch[1].trim()

        // 去除可能存在的单引号(YAML字符串有时会用引号包裹)
        if (value.startsWith("'") && value.endsWith("'")) {
          value = value.substring(1, value.length - 1)
          // 反转义单引号(将两个单引号转义为一个单引号)
          value = unescapeSingleQuotes(value)
        } else if (value.startsWith('"') && value.endsWith('"')) {
          value = value.substring(1, value.length - 1)
          // 如果需要,也可以处理双引号的转义
        }

        // 构建完整的键路径(用点号连接)
        const fullKey = currentPath.join('.')
        // 构建模块路径(用斜杠连接注释)
        const modulePath = currentCommentPath.join('/')

        // 将结果存储到对象中
        result[fullKey] = {
          value: value,
          module: modulePath,
        }
      }
    }

    // 重置注释栈(注释只对下一行有效)
    if (!trimmedLine.startsWith('#') && trimmedLine !== '') {
      commentStack.length = 0
    }
  })

  return result
}

/**
 * 设置工作表的列宽
 * @param {Object} worksheet - XLSX工作表对象
 */
function setColumnWidths(worksheet) {
  // 定义列宽配置
  const colWidths = [
    { wch: 30 }, // 功能模块列宽:30字符
    { wch: 40 }, // key列宽:40字符
  ]

  // 获取工作表的范围
  const range = XLSX.utils.decode_range(worksheet['!ref'])
  const numCols = range.e.c - range.s.c + 1

  // 为语言列设置固定宽度(从第三列开始)
  for (let i = 2; i < numCols; i++) {
    colWidths.push({ wch: 20 }) // 语言列宽:20字符
  }

  // 将列宽配置应用到工作表
  worksheet['!cols'] = colWidths
}

/**
 * 设置行高
 * @param {Object} worksheet - XLSX工作表对象
 */
function setRowHeights(worksheet) {
  // 获取工作表的范围
  const range = XLSX.utils.decode_range(worksheet['!ref'])

  // 遍历每一行设置行高
  for (let row = range.s.r; row <= range.e.r; row++) {
    // 确保行配置数组存在
    if (!worksheet['!rows']) worksheet['!rows'] = []
    // 设置行高,标题行(第0行)稍高一些
    worksheet['!rows'][row] = { hpt: row === 0 ? 25 : 20 }
  }
}

/**
 * 对语言进行排序,确保zh-CN在最前面
 * @param {Array} languages - 语言代码数组
 * @returns {Array} 排序后的语言数组
 */
function sortLanguages(languages) {
  return [...languages].sort((a, b) => {
    // zh-CN始终排在最前面
    if (a === 'zh-CN') return -1
    if (b === 'zh-CN') return 1
    // 其他语言按字母顺序排序
    return a.localeCompare(b)
  })
}

/**
 * 将目录下的所有YAML文件转换为XLSX格式
 * @param {string} inputDir - 输入目录路径(包含YAML文件)
 * @param {string} outputFile - 输出文件路径(XLSX文件)
 */
function convertYamlDirToXlsx(inputDir, outputFile) {
  try {
    // 确保输入目录存在
    if (!fs.existsSync(inputDir)) {
      throw new Error(`输入目录不存在: ${inputDir}`)
    }

    // 读取目录中的所有文件
    const files = fs.readdirSync(inputDir)
    // 过滤出YAML文件(支持.yaml和.yml扩展名)
    const yamlFiles = files.filter((file) => file.endsWith('.yaml') || file.endsWith('.yml'))

    // 如果没有找到YAML文件,提示并退出
    if (yamlFiles.length === 0) {
      console.log('未找到YAML文件')
      return
    }

    // 收集所有语言的数据
    const allData = {}
    const languages = []

    // 处理每个YAML文件
    yamlFiles.forEach((file) => {
      // 构建完整的文件路径
      const filePath = path.join(inputDir, file)
      // 从文件名提取语言代码(去掉扩展名)
      const language = path.basename(file, path.extname(file))
      // 将语言代码添加到语言数组
      languages.push(language)

      // 解析YAML文件
      const parsedData = parseYamlWithComments(filePath)

      // 将解析后的数据按key组织
      Object.entries(parsedData).forEach(([key, data]) => {
        // 如果该key尚未存在,初始化数据结构
        if (!allData[key]) {
          allData[key] = {
            module: data.module,
            translations: {},
          }
        }
        // 存储该语言的翻译值
        allData[key].translations[language] = data.value
      })
    })

    // 对语言进行排序,确保zh-CN在最前面
    const sortedLanguages = sortLanguages([...new Set(languages)])

    // 准备XLSX数据
    const worksheetData = []

    // 添加表头行
    const headers = ['功能模块', 'key', ...sortedLanguages]
    worksheetData.push(headers)

    // 添加数据行
    Object.entries(allData).forEach(([key, data]) => {
      // 创建新行:功能模块和key
      const row = [data.module, key]

      // 按排序后的语言顺序添加翻译值
      sortedLanguages.forEach((language) => {
        // 如果该语言有翻译值则添加,否则为空字符串
        row.push(data.translations[language] || '')
      })

      // 将行添加到工作表数据
      worksheetData.push(row)
    })

    // 创建新的workbook和工作表
    const workbook = XLSX.utils.book_new()
    const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)

    // 设置列宽和行高
    setColumnWidths(worksheet)
    setRowHeights(worksheet)

    // 添加样式:标题行加粗并居中对齐
    const headerRange = XLSX.utils.decode_range(worksheet['!ref'])
    for (let col = headerRange.s.c; col <= headerRange.e.c; col++) {
      const cellAddress = XLSX.utils.encode_cell({ r: 0, c: col })
      // 如果单元格存在,设置样式
      if (worksheet[cellAddress]) {
        worksheet[cellAddress].s = {
          font: { bold: true },
          alignment: { vertical: 'center', horizontal: 'center' },
        }
      }
    }

    // 添加worksheet到workbook
    XLSX.utils.book_append_sheet(workbook, worksheet, '翻译数据')

    // 确保输出目录存在
    const outputDir = path.dirname(outputFile)
    if (!fs.existsSync(outputDir)) {
      // 递归创建目录
      fs.mkdirSync(outputDir, { recursive: true })
    }

    // 写入文件
    XLSX.writeFile(workbook, outputFile)

    // 输出成功信息
    console.log(`✅ 转换完成!`)
    console.log(`📁 输入目录: ${inputDir}`)
    console.log(`📄 输出文件: ${outputFile}`)
    console.log(`🌐 处理了 ${yamlFiles.length} 个语言文件: ${sortedLanguages.join(', ')}`)
    console.log(`📊 列顺序: 功能模块, key, ${sortedLanguages.join(', ')}`)
  } catch (error) {
    // 错误处理
    console.error('❌ 转换过程中发生错误:', error.message)
    process.exit(1)
  }
}

// 配置命令行参数
const argv = yargs(hideBin(process.argv))
  .option('input', {
    alias: 'i',
    type: 'string',
    description: 'YAML文件所在目录路径',
    default: './locales',
  })
  .option('output', {
    alias: 'o',
    type: 'string',
    description: '输出的XLSX文件路径',
    default: './output/translations.xlsx',
  })
  .option('config', {
    alias: 'c',
    type: 'string',
    description: '配置文件路径',
  })
  .help() // 添加帮助信息
  .alias('help', 'h') // 设置帮助命令别名
  .version() // 添加版本信息
  .alias('version', 'v').argv // 设置版本命令别名 // 解析命令行参数

/**
 * 主函数
 */
function main() {
  // 如果有配置文件,读取配置文件
  if (argv.config) {
    try {
      // 解析配置文件路径
      const configPath = path.resolve(process.cwd(), argv.config)
      // 读取并解析配置文件
      const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))

      // 合并配置参数(命令行参数优先于配置文件)
      const finalInput = argv.input !== './locales' ? argv.input : config.input || './locales'
      const finalOutput =
        argv.output !== './output/translations.xlsx'
          ? argv.output
          : config.output || './output/translations.xlsx'

      // 执行转换
      convertYamlDirToXlsx(finalInput, finalOutput)
    } catch (error) {
      console.error('❌ 读取配置文件失败:', error.message)
      process.exit(1)
    }
  } else {
    // 直接使用命令行参数
    convertYamlDirToXlsx(argv.input, argv.output)
  }
}

main()

4.3 Excel转YAML实现(xlsx2yaml.js)

js 复制代码
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import XLSX from 'xlsx'
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'

// 获取当前文件的目录路径
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

/**
 * 读取XLSX文件并解析为结构化数据
 * @param {string} filePath - XLSX文件路径
 * @returns {Object} 包含语言数据和注释映射的对象
 */
function parseXlsxFile(filePath) {
  // 读取XLSX文件
  const workbook = XLSX.readFile(filePath)
  // 获取第一个工作表
  const worksheet = workbook.Sheets[workbook.SheetNames[0]]
  // 将工作表数据转换为JSON
  const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 })

  if (jsonData.length < 2) {
    throw new Error('XLSX文件数据不足,至少需要表头和数据行')
  }

  // 提取表头(第一行)
  const headers = jsonData[0]
  // 验证表头结构
  if (headers[0] !== '功能模块' || headers[1] !== 'key') {
    throw new Error('XLSX文件格式不正确,前两列应为"功能模块"和"key"')
  }

  // 提取语言列(从第三列开始)
  const languages = headers.slice(2)

  // 初始化数据结构
  const result = {
    languages: languages,
    data: {},
    comments: {}, // 存储注释映射关系
  }

  // 处理每一行数据(跳过表头)
  for (let i = 1; i < jsonData.length; i++) {
    const row = jsonData[i]
    if (!row || row.length < 3) continue

    const modulePath = row[0] // 功能模块路径
    const key = row[1] // 键路径

    // 为每种语言存储键值对
    languages.forEach((lang, index) => {
      const value = row[2 + index] || ''
      if (!result.data[lang]) {
        result.data[lang] = {}
      }

      // 使用点号分隔的键路径设置嵌套对象
      setNestedValue(result.data[lang], key, value)
    })

    // 处理注释映射
    if (modulePath) {
      // 将功能模块路径与键路径关联
      result.comments[key] = modulePath
    }
  }

  return result
}

/**
 * 根据点号分隔的路径设置嵌套对象的值
 * @param {Object} obj - 目标对象
 * @param {string} path - 点号分隔的路径
 * @param {*} value - 要设置的值
 */
function setNestedValue(obj, path, value) {
  const keys = path.split('.')
  let current = obj

  // 遍历路径,创建嵌套对象
  for (let i = 0; i < keys.length - 1; i++) {
    const key = keys[i]
    if (!current[key] || typeof current[key] !== 'object') {
      current[key] = {}
    }
    current = current[key]
  }

  // 设置最终的值
  current[keys[keys.length - 1]] = value
}

/**
 * 构建注释映射,将注释分配到正确的YAML层级
 * @param {Object} commentsMap - 原始的注释映射
 * @returns {Object} 按YAML层级组织的注释映射
 */
function buildHierarchicalComments(commentsMap) {
  const hierarchicalComments = {}

  // 遍历所有键和对应的注释路径
  Object.entries(commentsMap).forEach(([key, commentPath]) => {
    const keyParts = key.split('.')
    const commentParts = commentPath.split('/')

    // 为每个层级构建注释
    let currentPath = ''
    for (let i = 0; i < keyParts.length; i++) {
      // 构建当前路径
      currentPath = currentPath ? `${currentPath}.${keyParts[i]}` : keyParts[i]

      // 如果当前层级有对应的注释部分,则分配注释
      if (i < commentParts.length) {
        // 确保每个路径只分配一次注释(取第一个出现的)
        if (!hierarchicalComments[currentPath]) {
          hierarchicalComments[currentPath] = commentParts[i]
        }
      }
    }
  })

  return hierarchicalComments
}

/**
 * 转义字符串中的单引号,用于YAML单引号字符串
 * @param {string} str - 要转义的字符串
 * @returns {string} 转义后的字符串
 */
function escapeSingleQuotes(str) {
  // 将单引号转义为两个单引号
  return str.replace(/'/g, "''")
}

/**
 * 格式化YAML值,确保字符串使用单引号包裹并正确处理转义
 * @param {*} value - 要格式化的值
 * @returns {string} 格式化后的YAML值表示
 */
function formatYamlValue(value) {
  if (value === null || value === undefined) {
    return "''" // 空值用空字符串表示
  }

  if (typeof value === 'boolean') {
    return value.toString() // 布尔值直接输出
  }

  if (typeof value === 'number') {
    return value.toString() // 数字直接输出
  }

  if (typeof value === 'string') {
    // 空字符串用空字符串表示
    if (value === '') {
      return "''"
    }

    // 转义字符串中的单引号
    const escapedValue = escapeSingleQuotes(value)

    // 检查是否需要引号(包含特殊字符)
    const needsQuotes =
      /[:{}\[\],&*#?|<>=!%@`]/.test(value) ||
      value.trim() !== value ||
      value.includes('\n') ||
      value.includes('\t')

    if (needsQuotes) {
      return `'${escapedValue}'` // 需要引号的情况
    } else {
      // 简单字符串可以不加引号,但为了统一风格,我们仍然使用单引号
      return `'${escapedValue}'`
    }
  }

  // 其他类型(如对象、数组)不应该出现在这里
  return `'${escapeSingleQuotes(String(value))}'`
}

/**
 * 将对象转换为YAML格式字符串,并添加注释
 * @param {Object} data - 要转换的数据对象
 * @param {Object} commentsMap - 注释映射关系
 * @returns {string} YAML格式的字符串
 */
function objectToYamlWithComments(data, commentsMap) {
  // 构建层级化的注释映射
  const hierarchicalComments = buildHierarchicalComments(commentsMap)

  let yamlContent = ''
  let indentLevel = 0

  /**
   * 递归处理对象,生成带注释的YAML
   * @param {Object} obj - 当前处理的对象
   * @param {string} currentPath - 当前路径
   */
  function processObject(obj, currentPath = '') {
    const keys = Object.keys(obj)

    keys.forEach((key) => {
      const value = obj[key]
      const newPath = currentPath ? `${currentPath}.${key}` : key
      const indent = '  '.repeat(indentLevel)

      // 检查当前路径是否有注释
      if (hierarchicalComments[newPath]) {
        // 添加注释(缩进与当前层级相同)
        yamlContent += `${indent}# ${hierarchicalComments[newPath]}\n`
      }

      if (typeof value === 'object' && value !== null) {
        // 如果是嵌套对象,添加键并递归处理
        yamlContent += `${indent}${key}:\n`
        indentLevel++
        processObject(value, newPath)
        indentLevel--
      } else {
        // 如果是叶子节点,格式化值并添加键值对
        const formattedValue = formatYamlValue(value)
        yamlContent += `${indent}${key}: ${formattedValue}\n`
      }
    })
  }

  // 开始处理根对象
  processObject(data)
  return yamlContent
}

/**
 * 从XLSX文件生成YAML语言包
 * @param {string} inputFile - 输入的XLSX文件路径
 * @param {string} outputDir - 输出的YAML文件目录
 */
function convertXlsxToYaml(inputFile, outputDir) {
  try {
    // 确保输入文件存在
    if (!fs.existsSync(inputFile)) {
      throw new Error(`输入文件不存在: ${inputFile}`)
    }

    // 解析XLSX文件
    const parsedData = parseXlsxFile(inputFile)
    const { languages, data, comments } = parsedData

    console.log(`📊 检测到 ${languages.length} 种语言: ${languages.join(', ')}`)
    console.log(`📝 处理了 ${Object.keys(data[languages[0]] || {}).length} 个键值对`)

    // 确保输出目录存在
    if (!fs.existsSync(outputDir)) {
      fs.mkdirSync(outputDir, { recursive: true })
    }

    // 为每种语言生成YAML文件
    languages.forEach((lang) => {
      if (!data[lang]) {
        console.warn(`⚠️  语言 ${lang} 没有数据,跳过生成`)
        return
      }

      // 生成带注释的YAML内容
      const yamlContent = objectToYamlWithComments(data[lang], comments)

      // 构建输出文件路径
      const outputFile = path.join(outputDir, `${lang}.yaml`)

      // 写入文件
      fs.writeFileSync(outputFile, yamlContent, 'utf8')

      console.log(`✅ 生成 ${lang}.yaml`)
    })

    console.log(`🎉 转换完成!`)
    console.log(`📁 输出目录: ${outputDir}`)
  } catch (error) {
    console.error('❌ 转换过程中发生错误:', error.message)
    process.exit(1)
  }
}

// 配置命令行参数
const argv = yargs(hideBin(process.argv))
  .option('input', {
    alias: 'i',
    type: 'string',
    description: '输入的XLSX文件路径',
    default: './translations.xlsx',
  })
  .option('output', {
    alias: 'o',
    type: 'string',
    description: '输出的YAML文件目录',
    default: './locales',
  })
  .option('config', {
    alias: 'c',
    type: 'string',
    description: '配置文件路径',
  })
  .help()
  .alias('help', 'h')
  .version()
  .alias('version', 'v').argv

/**
 * 主函数
 */
function main() {
  // 如果有配置文件,读取配置文件
  if (argv.config) {
    try {
      const configPath = path.resolve(process.cwd(), argv.config)
      const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))

      // 合并配置参数
      const finalInput =
        argv.input !== './translations.xlsx' ? argv.input : config.input || './translations.xlsx'
      const finalOutput = argv.output !== './locales' ? argv.output : config.output || './locales'

      convertXlsxToYaml(finalInput, finalOutput)
    } catch (error) {
      console.error('❌ 读取配置文件失败:', error.message)
      process.exit(1)
    }
  } else {
    // 直接使用命令行参数
    convertXlsxToYaml(argv.input, argv.output)
  }
}

main()

4.4 脚本命令配置(package.json)

脚本支持通过-i-o参数自定义输入输出路径。

json 复制代码
{​
  "scripts": {​
    "i18n:yaml2xlsx": "node ./scripts/i18n/yaml2xlsx.js -i src/i18n/locales -o scripts/i18n/output/translation.xlsx",​
    "i18n:xlsx2yaml": "node ./scripts/i18n/xlsx2yaml.js -i scripts/i18n/output/translation.xlsx -o scripts/i18n/output/locales"​
  }​
}

4.5 类型自动生成方案

ts默认是不支持导入yaml文件的,需要添加模块声明:

ts 复制代码
declare module '*.yaml' {
  const content: Record<string, any>
  export default content
}

这样就支持导入yaml文件了,但导入的所有ts类型都是Record<string, any>,使用vue-i18n时也没有类型提示:

ts 复制代码
// 无类型提示:login.?
t('login.form.username')

期望的目标是:导入的yaml语言包是类型安全的,使用时可以享受类型提示。手动根据语言包yaml文件生成ts类型声明文件是重复低效的,yaml是一个结构化的文件,根据yaml文件自动生成ts类型声明文件是可行的。方案可以参考unplugin-vue-components,编写一个vite插件,根据语言包yaml文件自动生成ts类型声明文件。

vite-plugin-i18n-types.ts

ts 复制代码
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { resolve, dirname } from 'path'
import { parse } from 'yaml'
import type { Plugin } from 'vite'

export interface I18nTypePluginOptions {
  input: string | string[]
  output: string
  typeName?: string
  watch?: boolean
  build?: boolean
  // 模块声明的路径,如 '@/i18n/locales'
  modulePath?: string
}

function objectToTypeString(obj: any, indent = 2, level = 1): string {
  const spaces = ' '.repeat(indent * level)
  const entries = Object.entries(obj)

  if (entries.length === 0) return 'Record<string, any>'

  const result: string[] = []

  for (const [key, value] of entries) {
    if (typeof value === 'object' && value !== null) {
      const nestedType = objectToTypeString(value, indent, level + 1)
      result.push(`${spaces}${key}: ${nestedType}`)
    } else {
      result.push(`${spaces}${key}: string`)
    }
  }

  return `{
${result.join('\n')}
${' '.repeat(indent * (level - 1))}}`
}

export default function i18nTypePlugin(options: I18nTypePluginOptions): Plugin {
  const {
    input = 'src/i18n/locales/zh-CN.yaml',
    output = 'types/i18n.d.ts',
    typeName = 'I18nMessageSchema',
    watch = true,
    build = true,
    modulePath = '@/i18n/locales',
  } = options

  const inputPaths = Array.isArray(input) ? input : [input]
  const resolvedInputPaths = inputPaths.map((path) => resolve(process.cwd(), path))
  const resolvedOutputPath = resolve(process.cwd(), output)

  function generateTypes() {
    try {
      // 读取第一个输入文件(假设所有语言文件有相同的结构)
      const inputPath = resolvedInputPaths[0]
      if (!existsSync(inputPath)) {
        console.warn(`⚠️  File not found: ${inputPath}`)
        return
      }

      const yamlContent = readFileSync(inputPath, 'utf-8')
      const data = parse(yamlContent)
      const typeDefinition = objectToTypeString(data, 2)

      // 构建完整的类型声明内容
      let content = `declare type ${typeName} = ${typeDefinition}\n`

      // 添加模块声明
      if (modulePath) {
        content += `
declare module '${modulePath}/*.yaml' {
  const content: ${typeName}
  export default content
}

declare module '${modulePath}/*.yml' {
  const content: ${typeName}
  export default content
}
`
      }

      // 确保输出目录存在
      const outputDir = dirname(resolvedOutputPath)
      if (!existsSync(outputDir)) {
        mkdirSync(outputDir, { recursive: true })
      }

      writeFileSync(resolvedOutputPath, content, 'utf-8')
      console.log(`✅ Generated i18n types: ${resolvedOutputPath}`)
    } catch (error) {
      console.error('❌ Failed to generate i18n types:', error)
    }
  }

  return {
    name: 'vite-plugin-i18n-types',

    buildStart() {
      if (build) {
        generateTypes()
      }
    },

    configureServer(server) {
      if (watch) {
        // 首次生成
        setTimeout(() => generateTypes(), 100)

        // 监听所有输入文件
        resolvedInputPaths.forEach((inputPath) => {
          if (existsSync(inputPath)) {
            server.watcher.add(inputPath)
            console.log(`👀 Watching: ${inputPath}`)
          }
        })

        // 监听文件变化
        server.watcher.on('change', (changedPath) => {
          if (resolvedInputPaths.some((path) => resolve(path) === resolve(changedPath))) {
            console.log(`📄 i18n file changed: ${changedPath}`)
            generateTypes()

            // 发送 HMR 更新
            server.ws.send({
              type: 'full-reload',
              path: '*',
            })
          }
        })
      }
    },

    buildEnd() {
      if (build && !watch) {
        generateTypes()
      }
    },
  }
}

4.6 Vite配置集成

vite.config.ts

ts 复制代码
import { defineConfig } from 'vite'
import i18nTypePlugin from './build/vite-plugin-i18n-types'

export default defineConfig({
  plugins: [
    i18nTypePlugin({
      // 使用哪个语言包为基准用来生成.d.ts文件
      input: 'src/i18n/locales/zh-CN.yaml',
      // 用于生成.d.ts文件中的declare module '@/i18n/locales/*.yaml'语句
      modulePath: '@/i18n/locales',
      // .d.ts文件生成位置
      output: 'types/i18n.d.ts',
      // .d.ts文件中的MessageSchema类型的名称
      typeName: 'I18nMessageSchema',
      watch: true,
      build: true,
    }),
  ],
})

自动生成types/i18n.d.ts文件

ts 复制代码
declare type I18nMessageSchema {
  home: {
    navbar: {
      title: string
    }
  }
  login: {
    form: {
      username: string
      password: string
    }
  }
}

declare module '@/i18n/locales/*.yaml' {
  const content: I18nMessageSchema
  export default content
}

declare module '@/i18n/locales/*.yml' {
  const content: I18nMessageSchema
  export default content
}

上面的declare module语句不仅保证了引入的语言包yaml文件的类型安全,而且只限定了@/i18n/locales目录下的文件,不影响其它目录下的文件引入。

需要配合tsconfig.json:

json 复制代码
{
  "include": ["types/**/*"],
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

4.7 i18n配置实现

src/i18n/index.ts

ts 复制代码
import { createI18n, useI18n } from 'vue-i18n'
// zhCN和enUS都是I18nMessageSchema类型​
import zhCN from '@/i18n/locales/zh-CN.yaml'
import enUS from '@/i18n/locales/en-US.yaml'
export type LangCodes = 'zh-CN' | 'en-US'
const messages = {
  'zh-CN': zhCN,
  'en-US': enUS,
}
const i18n = createI18n<[I18nMessageSchema], LangCodes, false>({
  legacy: false,
  locale: 'zh-CN', // 默认中文​
  fallbackLocale: 'zh-CN',
  messages,
})
export default i18n
export const useGlobalI18n = () =>
  useI18n<{ message: I18nMessageSchema }>({
    useScope: 'global',
  })

在其它文件中使用:

ts 复制代码
const { t } = useGlobalI18n()
// 享受完整的类型提示​
t('home.navbar.title')

五、使用流程说明

  1. YAML 转 Excel:执行pnpm i18n:yaml2xlsx命令,这会扫描src/i18n/locales目录下的所有yaml文件,把它们转换成excel文件输出到scripts/i18n/output/translation.xlsx,语言包转换后的结果示例:
功能模块 key zh-CN en-US
首页/导航栏 home.navbar.title 首页 Home
登录页/表单 login.form.username 用户名 Username
登录页/表单 login.form.password 密码 Password
  1. Excel 转 YAML: 执行pnpm i18n:xlsx2yaml命令,这会把scripts/i18n/output/translation.xlsx文件转换成语言包文件输出到scripts/i18n/output/locales目录下,而不是直接覆盖原语言包文件,这样方便开发者校对生成结果后再覆盖源码,防止可能发生的错误。
相关推荐
bm90dA2 小时前
前端小记:Vue3引入mockjs开发
前端
渔_2 小时前
SCSS 实战指南:从基础到进阶,让 CSS 编写效率翻倍
前端
Syron2 小时前
为什么微应用不需要配置 try_files?
前端
前端老宋Running2 小时前
别再写 API 路由了:Server Actions 才是全栈 React 的终极形态
前端·react.js·架构
王小酱2 小时前
Cursor 的 Debug模式的核心理念和使用流程
前端·cursor
前端老宋Running2 小时前
跟“白屏”说拜拜:用 Next.js 把 React 搬到服务器上,Google 爬虫都要喊一声“真香”
前端·react.js·架构
玉宇夕落2 小时前
深入理解 React 与 JSX:从组件到 UI 构建
前端·react.js
jun_不见2 小时前
面试官:你能说下订阅发布模式么,怎么在VUE项目中实现一个类似eventBus的事件总线呢
前端·javascript·面试
How_doyou_do2 小时前
前端动画的多种实现方式
前端