返回笔记首页

5.2 国际化方案 - 企业级多语言解决方案

主题配置

简历描述模板

版本一:注重架构设计

plain
负责构建企业级国际化系统,支持中英日韩等8种语言动态切换。设计了基于词条分组的
翻译管理机制,配合后端接口实现语言包按需加载,首屏加载体积减少65%。针对不同地
区实现了日期、货币、数字的差异化格式处理,解决了阿拉伯语RTL布局的兼容问题。搭
建翻译管理后台,支持运营人员在线编辑词条并实时预览效果,翻译效率提升40%。

版本二:强调性能优化

plain
主导多语言系统架构设计,通过词条懒加载和缓存策略将语言包加载时间从2.3s优化至
0.4s。实现了基于URL参数的语言自动检测机制,结合浏览器语言偏好和IP地理位置智能
推荐,新用户语言匹配准确率达92%。处理了复杂的日期时区转换和货币汇率计算逻辑,
支持实时汇率更新。开发可视化翻译工具,非技术人员可直接管理1200+词条内容。

版本三:突出工程化能力

plain
设计并实现前端国际化完整方案,支持8种语言无缝切换,覆盖Web、H5、小程序三端。
建立词条命名规范和分包策略,核心词条200个打入首包,业务词条按路由懒加载。针对
阿拉伯语环境实现CSS-in-JS的RTL自动镜像方案,处理了Flex布局、定位、边距等20+
场景。搭建翻译管理平台,集成谷歌翻译API实现半自动翻译,人工审核通过率78%。

SOP 标准回答

Q1: 你们的国际化方案是怎么设计的?

标准回答

plain
我们的国际化方案主要分三层来做:

基础层用的是Vue I18n库,但我们在它基础上做了一些改造。原本的做法是把所有翻译文
件打包进去,但我们项目翻译词条有1200多个,全部打进去会让首屏包体积增加200多KB。
所以我做了词条分组,把登录注册这些高频词条大概200个作为核心包,跟主包一起加载,
其他的按业务模块拆分,比如商品详情页的翻译词条只在进入商品详情时才加载。

中间层处理的是格式化问题。不同国家对日期、货币、数字的展示习惯不一样,比如美国
用逗号分隔千位,印度是按百分位分隔。我们封装了一套格式化工具,根据当前语言环境
自动选择对应的格式化规则,日期用的是Day.js的locale功能,货币是自己写的format
函数结合实时汇率接口。

上层是管理平台,这个是我后来搭的,因为产品经理老是找我改翻译,一天能找好几次。
我就做了个后台,他们自己能在线编辑词条,编辑完可以实时预览效果,确认无误再发布。
这个后台还接入了谷歌翻译API,新增词条时可以先机器翻译打底,再人工校对,效率提
升很明显。

追问应对

  • "为什么选择Vue I18n?" → 生态成熟、API设计合理、支持插值和复数形式,符合团队技术栈
  • "语言包按需加载怎么实现?" → 用Webpack的动态import配合路由懒加载,讲具体代码
  • "翻译管理平台有什么特色功能?" → 版本管理、批量导入导出、缺失词条检测、使用率统计

Q2: RTL布局适配遇到过什么问题?

标准回答

plain
RTL适配确实踩了不少坑,主要集中在三个方面:

第一个是布局镜像问题。阿拉伯语是从右往左读的,整个页面布局要反过来。一开始我们
用CSS的direction属性来做,但发现有些场景不生效,特别是Flex布局。后来改成逻辑属
性,把margin-left改成margin-inline-start,padding-right改成padding-inline-end,
这样在RTL环境下会自动反向。对于一些复杂布局,我写了一个CSS-in-JS的辅助函数,能
自动计算RTL下的样式值。

第二个是图标和图片。有些图标是有方向性的,比如箭头、返回按钮,在RTL下需要水平翻
转。我给Image组件加了个rtlFlip属性,标记为true的图片会在RTL环境自动transform:
scaleX(-1)。但文字图片不能翻,所以要区分对待。

第三个是动画。我们有个侧边栏从左侧滑入的动画,在RTL下应该从右侧滑入。一开始用
translateX(-100%)写死了方向,后来改成用CSS变量,根据当前语言方向动态设置起始位
置。还有一些第三方组件库不支持RTL,我们只能fork一份源码自己改。

整个适配过程大概花了两周,测试也挺费劲的,因为国内没有阿拉伯语环境,我是在浏览
器装了个插件临时模拟的,后来上线前专门让在迪拜的同事帮忙测了一遍。

Q3: 语言切换的性能是怎么优化的?

标准回答

plain
性能优化主要做了四件事:

第一是词条分包,这个刚才提过了。我统计了一下各个页面词条的使用频率,把使用率超
过50%的词条提取成核心包,大概200多个,体积50KB左右,跟主包一起加载。其他的按业
务模块分成十几个子包,首次进入对应页面时才加载,利用Webpack的魔法注释给每个包
命名,方便调试。

第二是缓存策略。语言包加载后会存到LocalStorage,下次直接读缓存。但要处理更新问
题,我在每个语言包里加了version字段,每次发布翻译更新时版本号+1,前端对比版本号
决定是用缓存还是重新请求。还有个细节是缓存的键名带了语言标识,比如zh-CN_v1.2.0,
避免切换语言时读错缓存。

第三是预加载。用户在当前语言环境浏览时,我会在空闲时间预加载其他常用语言的核心
包,用的是requestIdleCallback。这样用户真正切换语言时,核心词条已经在缓存里了,
切换速度很快。根据统计数据,80%的用户只用中英两种语言,所以主要预加载这两个。

第四是切换动画优化。切换语言时会有短暂的白屏,因为要等新词条加载完页面才能渲染。
我加了个全局的Loading遮罩,并且用Suspense包裹需要等待的组件,这样切换过程有个
过渡效果,体验好很多。实测切换时间从之前的2秒多降到了400毫秒左右。

难点与亮点分析

难点一:动态词条加载的时机控制

问题背景: 词条按需加载虽然减小了首屏体积,但会带来新问题:用户快速切换页面时,词条还没加载完页面就显示了,会出现闪烁或者显示key值的情况。

解决思路

  1. 在路由守卫中预加载词条
  2. 使用Suspense组件处理异步加载状态
  3. 为关键词条设置fallback显示
  4. 实现词条加载的优先级队列

技术实现

javascript
// composables/useI18nLoader.js
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'

export function useI18nLoader() {
  const { locale } = useI18n()
  const route = useRoute()
  const loading = ref(false)
  const loadedModules = new Set()

  // 词条优先级配置
  const priorityMap = {
    home: 10,
    product: 8,
    order: 9,
    user: 7
  }

  // 加载队列
  const loadQueue = []
  let isProcessing = false

  // 按优先级加载词条
  async function loadMessages(module, priority = 5) {
    // 避免重复加载
    const cacheKey = `${locale.value}_${module}`
    if (loadedModules.has(cacheKey)) return

    loadQueue.push({ module, priority, locale: locale.value })
    loadQueue.sort((a, b) => b.priority - a.priority)

    if (!isProcessing) {
      processQueue()
    }
  }

  // 处理加载队列
  async function processQueue() {
    if (loadQueue.length === 0) {
      isProcessing = false
      return
    }

    isProcessing = true
    const { module, locale: targetLocale } = loadQueue.shift()
    const cacheKey = `${targetLocale}_${module}`

    try {
      loading.value = true

      // 先检查缓存
      const cached = localStorage.getItem(cacheKey)
      if (cached) {
        const { version, messages } = JSON.parse(cached)

        // 验证版本号
        const currentVersion = await fetchVersion(module, targetLocale)
        if (version === currentVersion) {
          mergeMessages(targetLocale, messages)
          loadedModules.add(cacheKey)
          loading.value = false
          processQueue() // 继续处理队列
          return
        }
      }

      // 动态导入词条
      const messages = await import(
        /* webpackChunkName: "locale-[request]" */
        `@/locales/${targetLocale}/${module}.json`
      )

      // 合并到i18n实例
      mergeMessages(targetLocale, messages.default)

      // 更新缓存
      const version = await fetchVersion(module, targetLocale)
      localStorage.setItem(cacheKey, JSON.stringify({
        version,
        messages: messages.default,
        timestamp: Date.now()
      }))

      loadedModules.add(cacheKey)
    } catch (error) {
      console.error(`Failed to load ${module} for ${targetLocale}:`, error)
    } finally {
      loading.value = false
      processQueue() // 继续处理队列
    }
  }

  // 合并词条到i18n
  function mergeMessages(locale, messages) {
    const i18n = useI18n()
    const existingMessages = i18n.getLocaleMessage(locale)
    i18n.setLocaleMessage(locale, {
      ...existingMessages,
      ...messages
    })
  }

  // 获取词条版本号
  async function fetchVersion(module, locale) {
    try {
      const res = await fetch(`/api/i18n/version?module=${module}&locale=${locale}`)
      const data = await res.json()
      return data.version
    } catch {
      return '1.0.0'
    }
  }

  // 监听路由变化,预加载词条
  watch(() => route.name, (newRoute) => {
    if (newRoute && priorityMap[newRoute]) {
      loadMessages(newRoute, priorityMap[newRoute])
    }
  }, { immediate: true })

  // 预加载常用语言
  function preloadCommonLocales() {
    const commonLocales = ['en-US', 'zh-CN']
    const currentLocale = locale.value

    requestIdleCallback(() => {
      commonLocales.forEach(targetLocale => {
        if (targetLocale !== currentLocale) {
          // 预加载核心词条
          loadMessages('common', 10)
        }
      })
    })
  }

  return {
    loading,
    loadMessages,
    preloadCommonLocales
  }
}

路由守卫集成

javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useI18n } from 'vue-i18n'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    // ... 路由配置
  ]
})

router.beforeEach(async (to, from, next) => {
  const { locale } = useI18n()
  const module = to.meta.i18nModule || to.name

  if (module) {
    try {
      // 预加载当前路由的词条
      await loadMessages(module, locale.value)
      next()
    } catch (error) {
      console.error('词条加载失败:', error)
      next() // 即使失败也继续导航
    }
  } else {
    next()
  }
})

export default router

难点二:复杂格式化场景处理

问题背景: 不同国家的数字、日期、货币格式差异很大,简单的字符串替换无法满足需求。比如:

  • 印度数字按百分位分隔:1,00,00,000
  • 阿拉伯数字用东阿拉伯数字:١٢٣٤٥
  • 日期顺序:中国2024年12月15日 vs 美国12/15/2024 vs 欧洲15/12/2024

解决方案

javascript
// utils/formatter.js
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/en'
import 'dayjs/locale/ar'
import 'dayjs/locale/hi'

// 货币格式化配置
const currencyConfig = {
  'zh-CN': {
    symbol: '¥',
    position: 'before',
    decimal: 2,
    separator: ',',
    groupSize: 3
  },
  'en-US': {
    symbol: '$',
    position: 'before',
    decimal: 2,
    separator: ',',
    groupSize: 3
  },
  'hi-IN': {
    symbol: '₹',
    position: 'before',
    decimal: 2,
    separator: ',',
    groupSize: [3, 2] // 印度特殊规则:最后3位一组,其余2位一组
  },
  'ar-SA': {
    symbol: '﷼',
    position: 'after',
    decimal: 2,
    separator: '،', // 阿拉伯逗号
    groupSize: 3,
    useArabicDigits: true
  }
}

// 数字转阿拉伯数字
function toArabicDigits(num) {
  const arabicDigits = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩']
  return String(num).replace(/\d/g, d => arabicDigits[d])
}

// 格式化货币
export function formatCurrency(amount, locale = 'zh-CN', currency = 'CNY') {
  const config = currencyConfig[locale] || currencyConfig['zh-CN']

  // 处理小数
  let numStr = Number(amount).toFixed(config.decimal)
  let [integer, decimal] = numStr.split('.')

  // 处理千分位
  if (Array.isArray(config.groupSize)) {
    // 印度特殊规则
    const [last, ...rest] = config.groupSize
    let result = integer.slice(-last)
    integer = integer.slice(0, -last)

    const groupSize = rest[0] || 2
    while (integer.length > 0) {
      const chunk = integer.slice(-groupSize)
      result = chunk + config.separator + result
      integer = integer.slice(0, -groupSize)
    }
    integer = result
  } else {
    // 标准千分位
    integer = integer.replace(/\B(?=(\d{3})+(?!\d))/g, config.separator)
  }

  // 拼接小数
  let formatted = decimal ? `${integer}.${decimal}` : integer

  // 转换为阿拉伯数字
  if (config.useArabicDigits) {
    formatted = toArabicDigits(formatted)
  }

  // 添加货币符号
  if (config.position === 'before') {
    return `${config.symbol}${formatted}`
  } else {
    return `${formatted}${config.symbol}`
  }
}

// 格式化日期
export function formatDate(date, format, locale = 'zh-CN') {
  // 设置locale
  const localeMap = {
    'zh-CN': 'zh-cn',
    'en-US': 'en',
    'ar-SA': 'ar',
    'hi-IN': 'hi'
  }

  const dayjsLocale = localeMap[locale] || 'zh-cn'
  const formatted = dayjs(date).locale(dayjsLocale).format(format)

  // 阿拉伯语需要转换数字
  if (locale === 'ar-SA') {
    return toArabicDigits(formatted)
  }

  return formatted
}

// 格式化数字
export function formatNumber(num, locale = 'zh-CN', options = {}) {
  const {
    decimal = 0,
    useGrouping = true,
    signDisplay = 'auto'
  } = options

  // 使用Intl.NumberFormat
  const formatter = new Intl.NumberFormat(locale, {
    minimumFractionDigits: decimal,
    maximumFractionDigits: decimal,
    useGrouping,
    signDisplay
  })

  let formatted = formatter.format(num)

  // 阿拉伯语转换数字
  if (locale === 'ar-SA') {
    formatted = toArabicDigits(formatted)
  }

  return formatted
}

// 格式化百分比
export function formatPercent(num, locale = 'zh-CN', decimal = 2) {
  const formatted = formatNumber(num * 100, locale, { decimal })
  return `${formatted}%`
}

// 相对时间格式化
export function formatRelativeTime(date, locale = 'zh-CN') {
  const now = dayjs()
  const target = dayjs(date)
  const diff = now.diff(target, 'second')

  const localeStrings = {
    'zh-CN': {
      justNow: '刚刚',
      minutesAgo: '分钟前',
      hoursAgo: '小时前',
      daysAgo: '天前',
      monthsAgo: '个月前',
      yearsAgo: '年前'
    },
    'en-US': {
      justNow: 'Just now',
      minutesAgo: ' minutes ago',
      hoursAgo: ' hours ago',
      daysAgo: ' days ago',
      monthsAgo: ' months ago',
      yearsAgo: ' years ago'
    }
  }

  const strings = localeStrings[locale] || localeStrings['zh-CN']

  if (diff < 60) return strings.justNow
  if (diff < 3600) return Math.floor(diff / 60) + strings.minutesAgo
  if (diff < 86400) return Math.floor(diff / 3600) + strings.hoursAgo
  if (diff < 2592000) return Math.floor(diff / 86400) + strings.daysAgo
  if (diff < 31536000) return Math.floor(diff / 2592000) + strings.monthsAgo
  return Math.floor(diff / 31536000) + strings.yearsAgo
}

使用示例

vue
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCurrency, formatDate, formatNumber } from '@/utils/formatter'

const { locale } = useI18n()

const price = 1234567.89
const orderDate = '2024-12-15T10:30:00'
const quantity = 1234

const formattedPrice = computed(() => {
  return formatCurrency(price, locale.value)
})

const formattedDate = computed(() => {
  return formatDate(orderDate, 'LL', locale.value)
})

const formattedQty = computed(() => {
  return formatNumber(quantity, locale.value, { useGrouping: true })
})
</script>

<template>
  <div class="order-info">
    <div class="price">{{ formattedPrice }}</div>
    <div class="date">{{ formattedDate }}</div>
    <div class="qty">{{ formattedQty }}</div>
  </div>
</template>

亮点一:翻译管理后台

设计思路: 为了让运营人员能够独立管理翻译词条,我开发了一个可视化的翻译管理后台,主要功能包括:

  1. 在线编辑词条,支持批量导入导出
  2. 实时预览效果,避免发布后才发现问题
  3. 词条使用统计,识别冗余和缺失词条
  4. 版本管理和回滚
  5. 集成机器翻译API辅助

核心实现

vue
<!-- views/TranslationManager.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatDate } from '@/utils/formatter'

// 翻译数据
const translations = ref([])
const currentLocale = ref('zh-CN')
const searchKeyword = ref('')
const selectedModule = ref('all')
const editingKey = ref(null)
const editingValue = ref('')

// 支持的语言列表
const locales = [
  { code: 'zh-CN', name: '简体中文', flag: '🇨🇳' },
  { code: 'en-US', name: 'English', flag: '🇺🇸' },
  { code: 'ja-JP', name: '日本語', flag: '🇯🇵' },
  { code: 'ko-KR', name: '한국어', flag: '🇰🇷' },
  { code: 'ar-SA', name: 'العربية', flag: '🇸🇦' }
]

// 模块列表
const modules = ref([
  { value: 'all', label: '全部' },
  { value: 'common', label: '通用' },
  { value: 'product', label: '商品' },
  { value: 'order', label: '订单' },
  { value: 'user', label: '用户' }
])

// 过滤后的翻译列表
const filteredTranslations = computed(() => {
  return translations.value.filter(item => {
    const matchKeyword = !searchKeyword.value ||
      item.key.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
      item.translations[currentLocale.value]?.toLowerCase().includes(searchKeyword.value.toLowerCase())

    const matchModule = selectedModule.value === 'all' || item.module === selectedModule.value

    return matchKeyword && matchModule
  })
})

// 统计信息
const statistics = computed(() => {
  const total = translations.value.length
  const translated = translations.value.filter(item =>
    item.translations[currentLocale.value]
  ).length
  const percentage = total > 0 ? ((translated / total) * 100).toFixed(1) : 0

  return {
    total,
    translated,
    percentage,
    missing: total - translated
  }
})

// 加载翻译数据
async function loadTranslations() {
  try {
    const res = await fetch('/api/translations')
    const data = await res.json()
    translations.value = data.translations
  } catch (error) {
    ElMessage.error('加载翻译数据失败')
  }
}

// 编辑词条
function editTranslation(item) {
  editingKey.value = item.key
  editingValue.value = item.translations[currentLocale.value] || ''
}

// 保存编辑
async function saveTranslation(item) {
  try {
    await fetch('/api/translations', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        key: item.key,
        locale: currentLocale.value,
        value: editingValue.value
      })
    })

    item.translations[currentLocale.value] = editingValue.value
    item.updatedAt = new Date().toISOString()
    editingKey.value = null
    ElMessage.success('保存成功')
  } catch (error) {
    ElMessage.error('保存失败')
  }
}

// 取消编辑
function cancelEdit() {
  editingKey.value = null
  editingValue.value = ''
}

// 机器翻译
async function autoTranslate(item) {
  try {
    const sourceText = item.translations['zh-CN']
    if (!sourceText) {
      ElMessage.warning('请先填写中文翻译')
      return
    }

    const res = await fetch('/api/translate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: sourceText,
        from: 'zh-CN',
        to: currentLocale.value
      })
    })

    const data = await res.json()
    editingValue.value = data.translated
    ElMessage.success('机器翻译完成,请检查后保存')
  } catch (error) {
    ElMessage.error('翻译失败')
  }
}

// 批量导出
function exportTranslations() {
  const data = translations.value.reduce((acc, item) => {
    acc[item.key] = item.translations[currentLocale.value] || ''
    return acc
  }, {})

  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = `translations_${currentLocale.value}_${Date.now()}.json`
  link.click()
  URL.revokeObjectURL(url)
}

// 批量导入
function importTranslations(event) {
  const file = event.target.files[0]
  if (!file) return

  const reader = new FileReader()
  reader.onload = async (e) => {
    try {
      const imported = JSON.parse(e.target.result)

      await ElMessageBox.confirm(
        `将导入 ${Object.keys(imported).length} 个词条,是否继续?`,
        '确认导入',
        { type: 'warning' }
      )

      await fetch('/api/translations/batch', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          locale: currentLocale.value,
          translations: imported
        })
      })

      await loadTranslations()
      ElMessage.success('导入成功')
    } catch (error) {
      if (error !== 'cancel') {
        ElMessage.error('导入失败')
      }
    }
  }
  reader.readAsText(file)
}

// 缺失词条检测
async function detectMissing() {
  const missing = translations.value.filter(item =>
    !item.translations[currentLocale.value]
  )

  if (missing.length === 0) {
    ElMessage.success('所有词条已翻译完成')
    return
  }

  try {
    await ElMessageBox.confirm(
      `检测到 ${missing.length} 个缺失词条,是否批量机器翻译?`,
      '缺失词条',
      { type: 'warning' }
    )

    // 批量翻译
    for (const item of missing) {
      await autoTranslate(item)
      await saveTranslation(item)
    }

    ElMessage.success('批量翻译完成')
  } catch (error) {
    if (error !== 'cancel') {
      ElMessage.error('批量翻译失败')
    }
  }
}

// 实时预览
const previewVisible = ref(false)
const previewContent = ref('')

function showPreview(text) {
  previewContent.value = text
  previewVisible.ref = true
}

onMounted(() => {
  loadTranslations()
})
</script>

<template>
  <div class="translation-manager">
    <!-- 顶部工具栏 -->
    <div class="toolbar">
      <div class="left-actions">
        <el-select v-model="currentLocale" placeholder="选择语言">
          <el-option
            v-for="locale in locales"
            :key="locale.code"
            :label="`${locale.flag} ${locale.name}`"
            :value="locale.code"
          />
        </el-select>

        <el-select v-model="selectedModule" placeholder="选择模块">
          <el-option
            v-for="module in modules"
            :key="module.value"
            :label="module.label"
            :value="module.value"
          />
        </el-select>

        <el-input
          v-model="searchKeyword"
          placeholder="搜索词条或翻译内容"
          clearable
          style="width: 300px"
        >
          <template #prefix>
            <i class="el-icon-search"></i>
          </template>
        </el-input>
      </div>

      <div class="right-actions">
        <el-button @click="detectMissing">检测缺失</el-button>
        <el-button @click="exportTranslations">导出</el-button>
        <el-upload
          :show-file-list="false"
          :auto-upload="false"
          accept=".json"
          @change="importTranslations"
        >
          <el-button>导入</el-button>
        </el-upload>
      </div>
    </div>

    <!-- 统计信息 -->
    <div class="statistics">
      <div class="stat-item">
        <div class="label">总词条数</div>
        <div class="value">{{ statistics.total }}</div>
      </div>
      <div class="stat-item">
        <div class="label">已翻译</div>
        <div class="value success">{{ statistics.translated }}</div>
      </div>
      <div class="stat-item">
        <div class="label">未翻译</div>
        <div class="value warning">{{ statistics.missing }}</div>
      </div>
      <div class="stat-item">
        <div class="label">完成度</div>
        <div class="value">{{ statistics.percentage }}%</div>
      </div>
    </div>

    <!-- 翻译列表 -->
    <div class="translation-list">
      <el-table :data="filteredTranslations" stripe>
        <el-table-column prop="key" label="词条Key" width="200" />
        <el-table-column prop="module" label="模块" width="100" />
        <el-table-column label="翻译内容" min-width="300">
          <template #default="{ row }">
            <div v-if="editingKey === row.key" class="editing-cell">
              <el-input
                v-model="editingValue"
                type="textarea"
                :rows="2"
                placeholder="请输入翻译内容"
              />
              <div class="edit-actions">
                <el-button size="small" type="primary" @click="saveTranslation(row)">
                  保存
                </el-button>
                <el-button size="small" @click="cancelEdit">取消</el-button>
                <el-button size="small" @click="autoTranslate(row)">
                  机器翻译
                </el-button>
              </div>
            </div>
            <div v-else class="translation-content">
              <span :class="{ missing: !row.translations[currentLocale] }">
                {{ row.translations[currentLocale] || '未翻译' }}
              </span>
            </div>
          </template>
        </el-table-column>
        <el-table-column label="更新时间" width="180">
          <template #default="{ row }">
            {{ row.updatedAt ? formatDate(row.updatedAt, 'YYYY-MM-DD HH:mm', 'zh-CN') : '-' }}
          </template>
        </el-table-column>
        <el-table-column label="操作" width="200">
          <template #default="{ row }">
            <el-button
              v-if="editingKey !== row.key"
              type="text"
              @click="editTranslation(row)"
            >
              编辑
            </el-button>
            <el-button
              type="text"
              @click="showPreview(row.translations[currentLocale])"
            >
              预览
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>

    <!-- 预览对话框 -->
    <el-dialog v-model="previewVisible" title="预览效果" width="600px">
      <div class="preview-content" :dir="currentLocale === 'ar-SA' ? 'rtl' : 'ltr'">
        {{ previewContent }}
      </div>
    </el-dialog>
  </div>
</template>

<style scoped>
.translation-manager {
  padding: 20px;
}

.toolbar {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
}

.left-actions,
.right-actions {
  display: flex;
  gap: 10px;
}

.statistics {
  display: flex;
  gap: 20px;
  margin-bottom: 20px;
  padding: 20px;
  background: #f5f7fa;
  border-radius: 8px;
}

.stat-item {
  flex: 1;
  text-align: center;
}

.stat-item .label {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.stat-item .value {
  font-size: 24px;
  font-weight: bold;
  color: #303133;
}

.stat-item .value.success {
  color: #67c23a;
}

.stat-item .value.warning {
  color: #e6a23c;
}

.editing-cell {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.edit-actions {
  display: flex;
  gap: 8px;
}

.translation-content .missing {
  color: #f56c6c;
  font-style: italic;
}

.preview-content {
  padding: 20px;
  background: #f5f7fa;
  border-radius: 4px;
  min-height: 100px;
  line-height: 1.6;
}
</style>

完整技术实现

1. i18n 配置与初始化

javascript
// i18n/index.js
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.json'

// 默认加载核心词条
const messages = {
  'zh-CN': zhCN
}

// 从localStorage读取用户语言偏好
function getDefaultLocale() {
  const saved = localStorage.getItem('user-locale')
  if (saved) return saved

  // 检测浏览器语言
  const browserLang = navigator.language || navigator.userLanguage

  // 支持的语言列表
  const supportedLocales = ['zh-CN', 'en-US', 'ja-JP', 'ko-KR', 'ar-SA']

  // 精确匹配
  if (supportedLocales.includes(browserLang)) {
    return browserLang
  }

  // 模糊匹配(如 zh-TW 匹配到 zh-CN)
  const langPrefix = browserLang.split('-')[0]
  const matched = supportedLocales.find(locale => locale.startsWith(langPrefix))

  return matched || 'zh-CN'
}

const i18n = createI18n({
  legacy: false, // 使用 Composition API 模式
  locale: getDefaultLocale(),
  fallbackLocale: 'zh-CN',
  messages,
  globalInjection: true, // 全局注入 $t 方法

  // 缺失翻译处理
  missing: (locale, key) => {
    console.warn(`[i18n] Missing translation for key: ${key} in locale: ${locale}`)
    // 上报到监控系统
    reportMissingTranslation(locale, key)
    return key
  },

  // 数字格式化
  numberFormats: {
    'zh-CN': {
      currency: {
        style: 'currency',
        currency: 'CNY'
      }
    },
    'en-US': {
      currency: {
        style: 'currency',
        currency: 'USD'
      }
    }
  },

  // 日期时间格式化
  datetimeFormats: {
    'zh-CN': {
      short: {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit'
      },
      long: {
        year: 'numeric',
        month: 'long',
        day: 'numeric',
        weekday: 'long'
      }
    },
    'en-US': {
      short: {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit'
      },
      long: {
        year: 'numeric',
        month: 'long',
        day: 'numeric',
        weekday: 'long'
      }
    }
  }
})

// 上报缺失翻译
function reportMissingTranslation(locale, key) {
  // 实际项目中可以对接监控平台
  if (process.env.NODE_ENV === 'production') {
    // fetch('/api/log/missing-translation', { ... })
  }
}

// 动态加载语言包
export async function loadLocaleMessages(locale) {
  try {
    const messages = await import(`./locales/${locale}.json`)
    i18n.global.setLocaleMessage(locale, messages.default)
    return messages.default
  } catch (error) {
    console.error(`Failed to load locale: ${locale}`, error)
    throw error
  }
}

// 切换语言
export async function changeLocale(locale) {
  // 如果语言包未加载,先加载
  if (!i18n.global.availableLocales.includes(locale)) {
    await loadLocaleMessages(locale)
  }

  i18n.global.locale.value = locale
  localStorage.setItem('user-locale', locale)
  document.documentElement.lang = locale

  // 切换dayjs locale
  import(`dayjs/locale/${locale.toLowerCase()}`).then(() => {
    // dayjs locale loaded
  })
}

export default i18n

2. 语言切换组件

vue
<!-- components/LocaleSwitcher.vue -->
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { changeLocale } from '@/i18n'

const { locale, t } = useI18n()
const loading = ref(false)

const locales = [
  {
    code: 'zh-CN',
    name: '简体中文',
    flag: '🇨🇳',
    dir: 'ltr'
  },
  {
    code: 'en-US',
    name: 'English',
    flag: '🇺🇸',
    dir: 'ltr'
  },
  {
    code: 'ja-JP',
    name: '日本語',
    flag: '🇯🇵',
    dir: 'ltr'
  },
  {
    code: 'ko-KR',
    name: '한국어',
    flag: '🇰🇷',
    dir: 'ltr'
  },
  {
    code: 'ar-SA',
    name: 'العربية',
    flag: '🇸🇦',
    dir: 'rtl'
  }
]

const currentLocale = computed(() => {
  return locales.find(l => l.code === locale.value)
})

async function handleChange(newLocale) {
  if (newLocale === locale.value) return

  loading.value = true

  try {
    await changeLocale(newLocale)

    // 切换文档方向
    const direction = locales.find(l => l.code === newLocale)?.dir || 'ltr'
    document.documentElement.dir = direction

    // 刷新页面(如果需要)
    // location.reload()
  } catch (error) {
    console.error('切换语言失败:', error)
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="locale-switcher">
    <el-dropdown @command="handleChange" :disabled="loading">
      <span class="trigger">
        <span class="flag">{{ currentLocale?.flag }}</span>
        <span class="name">{{ currentLocale?.name }}</span>
        <i class="el-icon-arrow-down"></i>
      </span>

      <template #dropdown>
        <el-dropdown-menu>
          <el-dropdown-item
            v-for="item in locales"
            :key="item.code"
            :command="item.code"
            :disabled="item.code === locale"
          >
            <span class="menu-item">
              <span class="flag">{{ item.flag }}</span>
              <span class="name">{{ item.name }}</span>
              <i v-if="item.code === locale" class="el-icon-check"></i>
            </span>
          </el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>

    <div v-if="loading" class="loading-overlay">
      <el-loading :text="t('common.switching')" />
    </div>
  </div>
</template>

<style scoped>
.locale-switcher {
  position: relative;
}

.trigger {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  cursor: pointer;
  user-select: none;
  transition: all 0.3s;
}

.trigger:hover {
  background: #f5f7fa;
  border-radius: 4px;
}

.flag {
  font-size: 18px;
}

.name {
  font-size: 14px;
  color: #606266;
}

.menu-item {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 120px;
}

.menu-item .el-icon-check {
  margin-left: auto;
  color: #409eff;
}

.loading-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.9);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}
</style>

3. RTL布局适配

javascript
// composables/useRTL.js
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'

export function useRTL() {
  const { locale } = useI18n()

  const isRTL = computed(() => {
    return ['ar-SA', 'he-IL', 'fa-IR'].includes(locale.value)
  })

  const direction = computed(() => isRTL.value ? 'rtl' : 'ltr')

  // 监听语言变化,更新文档方向
  watch(locale, () => {
    document.documentElement.dir = direction.value
  }, { immediate: true })

  // 样式计算工具
  function getMargin(value) {
    if (!isRTL.value) return { marginLeft: value }
    return { marginRight: value }
  }

  function getPadding(value) {
    if (!isRTL.value) return { paddingLeft: value }
    return { paddingRight: value }
  }

  function getPosition(value) {
    if (!isRTL.value) return { left: value }
    return { right: value }
  }

  function getTransform(x) {
    return `translateX(${isRTL.value ? -x : x}px)`
  }

  return {
    isRTL,
    direction,
    getMargin,
    getPadding,
    getPosition,
    getTransform
  }
}

RTL样式示例

vue
<script setup>
import { useRTL } from '@/composables/useRTL'

const { isRTL, getMargin, getTransform } = useRTL()
</script>

<template>
  <div class="sidebar" :class="{ rtl: isRTL }">
    <div class="menu-item" :style="getMargin('20px')">
      菜单项
    </div>
  </div>
</template>

<style scoped>
.sidebar {
  position: fixed;
  left: 0;
  top: 0;
  width: 200px;
  transition: transform 0.3s;
}

.sidebar.rtl {
  left: auto;
  right: 0;
}

/* 使用逻辑属性 */
.menu-item {
  padding-inline-start: 20px; /* LTR下是padding-left,RTL下是padding-right */
  margin-inline-end: 10px;
  border-inline-start: 2px solid #409eff;
}
</style>

真实项目经验

经验一:语言包版本管理

在实际项目中,翻译词条会频繁更新,如何保证用户使用的是最新版本是个问题。

解决方案

  1. 每个语言包文件加version字段
  2. 前端缓存时记录版本号
  3. 每次启动时对比版本,不一致则更新
  4. 服务端提供版本查询接口
javascript
// 版本管理示例
const LOCALE_VERSION_KEY = 'locale_versions'

async function checkLocaleVersion(locale) {
  try {
    // 获取本地缓存的版本
    const cachedVersions = JSON.parse(
      localStorage.getItem(LOCALE_VERSION_KEY) || '{}'
    )

    // 请求服务端最新版本
    const res = await fetch(`/api/i18n/version?locale=${locale}`)
    const { version: latestVersion } = await res.json()

    // 对比版本
    if (cachedVersions[locale] !== latestVersion) {
      console.log(`语言包${locale}有更新,从${cachedVersions[locale]}到${latestVersion}`)

      // 清除旧缓存
      localStorage.removeItem(`locale_${locale}`)

      // 重新加载
      await loadLocaleMessages(locale)

      // 更新版本记录
      cachedVersions[locale] = latestVersion
      localStorage.setItem(LOCALE_VERSION_KEY, JSON.stringify(cachedVersions))
    }
  } catch (error) {
    console.error('检查语言包版本失败:', error)
  }
}

经验二:词条命名规范

为了方便管理和避免冲突,制定了词条命名规范:

plain
模块.页面.位置.含义

示例:
product.detail.button.addToCart  // 商品详情页-按钮-加入购物车
order.list.status.pending        // 订单列表-状态-待支付
user.profile.form.nickname       // 用户资料-表单-昵称
common.message.success           // 通用-消息-成功提示

这样命名的好处:

  1. 一眼就能知道词条用在哪里
  2. 方便按模块分包
  3. 避免重名冲突
  4. 便于批量管理

经验三:性能监控

实际项目中,我们会监控国际化相关的性能指标:

javascript
// 性能监控
const performanceMetrics = {
  localeLoadTime: {},    // 语言包加载耗时
  translateTime: {},     // 翻译函数执行耗时
  switchTime: {}         // 语言切换总耗时
}

// 监控语言包加载
async function loadLocaleWithMetrics(locale) {
  const startTime = performance.now()

  try {
    await loadLocaleMessages(locale)
    const loadTime = performance.now() - startTime
    performanceMetrics.localeLoadTime[locale] = loadTime

    // 上报到监控平台
    reportMetrics('locale_load', { locale, time: loadTime })
  } catch (error) {
    // 错误上报
    reportError('locale_load_failed', { locale, error })
  }
}

// 定期上报汇总数据
setInterval(() => {
  const avgLoadTime = Object.values(performanceMetrics.localeLoadTime)
    .reduce((a, b) => a + b, 0) / Object.keys(performanceMetrics.localeLoadTime).length

  if (avgLoadTime > 1000) {
    console.warn('语言包加载过慢,平均耗时:', avgLoadTime)
  }
}, 60000)

面试常见追问

Q: 如果翻译词条特别多,怎么优化加载性能? A: 主要三个方向:一是词条分包按需加载,二是服务端压缩和CDN加速,三是预加载常用语言。我们项目词条拆成核心包和业务包,核心包200个打入首包,业务包按路由懒加载。语言文件用gzip压缩后放CDN,200KB能压到30KB左右。还有个技巧是用户在当前语言浏览时,空闲时间预加载英语包,因为80%用户会在中英文间切换。

Q: 怎么保证翻译质量? A: 这个主要靠流程管理。我们搭了个翻译管理后台,新词条先机器翻译打底,然后运营人员审核。审核时可以看到词条用在哪个页面、什么场景,还能实时预览效果。重要页面的翻译会让母语用户复核。另外建了翻译规范文档,统一术语和风格,比如"购物车"在所有地方都用"Shopping Cart"不用"Cart"。

Q: RTL布局最大的坑是什么? A: 最坑的是动画和定位。比如我们有个侧边栏从左滑入的动画,用translateX(-100%)写死了方向,RTL下应该从右边滑入但还是从左边。后来改成CSS变量,根据direction动态设置值。还有absolute定位,left: 0在RTL下不会自动变right: 0,要用逻辑属性inset-inline-start。第三方组件库更麻烦,不支持RTL只能fork源码改。

Q: 有遇到过翻译文案长度导致的布局问题吗? A: 遇到过,德语和俄语的单词特别长,把按钮都撑变形了。后来做了三个处理:一是设计时给文本预留30%的扩展空间,二是长文本自动换行或截断,三是某些场景用缩写。还有个技巧是用CSS的writing-mode,垂直排列文字能节省横向空间。最重要的是跟UI设计师对齐,让他们知道多语言会带来的布局影响。