一、方案背景
在国际化项目开发过程中,多语言资源(locales语言包)的管理通常要经历下面的流程:
- 开发人员通过机翻初步整理好一份语言包
- 把语言包转成Excel交付给翻译团队人工翻译
- 把人工翻译好的Excel重新转换为语言包且需保证其正确性
这种手动转换的方式存在效率低下、容易出错的问题。因此需要设计一套自动转换方案,实现语言包和Excel的自动化双向转换。
二、方案目标
- 实现语言包和Excel的自动化双向转换。
- Excel需包含"功能模块"列,方便定位语言包中字段对应页面的具体位置。
- 若存在中文语言包,转换成Excel时把中文列放在其它语言列前面,方便翻译对照。
- 基于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')
五、使用流程说明
- 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 |
- Excel 转 YAML: 执行
pnpm i18n:xlsx2yaml命令,这会把scripts/i18n/output/translation.xlsx文件转换成语言包文件输出到scripts/i18n/output/locales目录下,而不是直接覆盖原语言包文件,这样方便开发者校对生成结果后再覆盖源码,防止可能发生的错误。