简历描述模板
版本一:注重架构设计
负责构建企业级国际化系统,支持中英日韩等8种语言动态切换。设计了基于词条分组的
翻译管理机制,配合后端接口实现语言包按需加载,首屏加载体积减少65%。针对不同地
区实现了日期、货币、数字的差异化格式处理,解决了阿拉伯语RTL布局的兼容问题。搭
建翻译管理后台,支持运营人员在线编辑词条并实时预览效果,翻译效率提升40%。
版本二:强调性能优化
主导多语言系统架构设计,通过词条懒加载和缓存策略将语言包加载时间从2.3s优化至
0.4s。实现了基于URL参数的语言自动检测机制,结合浏览器语言偏好和IP地理位置智能
推荐,新用户语言匹配准确率达92%。处理了复杂的日期时区转换和货币汇率计算逻辑,
支持实时汇率更新。开发可视化翻译工具,非技术人员可直接管理1200+词条内容。
版本三:突出工程化能力
设计并实现前端国际化完整方案,支持8种语言无缝切换,覆盖Web、H5、小程序三端。
建立词条命名规范和分包策略,核心词条200个打入首包,业务词条按路由懒加载。针对
阿拉伯语环境实现CSS-in-JS的RTL自动镜像方案,处理了Flex布局、定位、边距等20+
场景。搭建翻译管理平台,集成谷歌翻译API实现半自动翻译,人工审核通过率78%。
SOP 标准回答
Q1: 你们的国际化方案是怎么设计的?
标准回答:
我们的国际化方案主要分三层来做:
基础层用的是Vue I18n库,但我们在它基础上做了一些改造。原本的做法是把所有翻译文
件打包进去,但我们项目翻译词条有1200多个,全部打进去会让首屏包体积增加200多KB。
所以我做了词条分组,把登录注册这些高频词条大概200个作为核心包,跟主包一起加载,
其他的按业务模块拆分,比如商品详情页的翻译词条只在进入商品详情时才加载。
中间层处理的是格式化问题。不同国家对日期、货币、数字的展示习惯不一样,比如美国
用逗号分隔千位,印度是按百分位分隔。我们封装了一套格式化工具,根据当前语言环境
自动选择对应的格式化规则,日期用的是Day.js的locale功能,货币是自己写的format
函数结合实时汇率接口。
上层是管理平台,这个是我后来搭的,因为产品经理老是找我改翻译,一天能找好几次。
我就做了个后台,他们自己能在线编辑词条,编辑完可以实时预览效果,确认无误再发布。
这个后台还接入了谷歌翻译API,新增词条时可以先机器翻译打底,再人工校对,效率提
升很明显。
追问应对:
- "为什么选择Vue I18n?" → 生态成熟、API设计合理、支持插值和复数形式,符合团队技术栈
- "语言包按需加载怎么实现?" → 用Webpack的动态import配合路由懒加载,讲具体代码
- "翻译管理平台有什么特色功能?" → 版本管理、批量导入导出、缺失词条检测、使用率统计
Q2: RTL布局适配遇到过什么问题?
标准回答:
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: 语言切换的性能是怎么优化的?
标准回答:
性能优化主要做了四件事:
第一是词条分包,这个刚才提过了。我统计了一下各个页面词条的使用频率,把使用率超
过50%的词条提取成核心包,大概200多个,体积50KB左右,跟主包一起加载。其他的按业
务模块分成十几个子包,首次进入对应页面时才加载,利用Webpack的魔法注释给每个包
命名,方便调试。
第二是缓存策略。语言包加载后会存到LocalStorage,下次直接读缓存。但要处理更新问
题,我在每个语言包里加了version字段,每次发布翻译更新时版本号+1,前端对比版本号
决定是用缓存还是重新请求。还有个细节是缓存的键名带了语言标识,比如zh-CN_v1.2.0,
避免切换语言时读错缓存。
第三是预加载。用户在当前语言环境浏览时,我会在空闲时间预加载其他常用语言的核心
包,用的是requestIdleCallback。这样用户真正切换语言时,核心词条已经在缓存里了,
切换速度很快。根据统计数据,80%的用户只用中英两种语言,所以主要预加载这两个。
第四是切换动画优化。切换语言时会有短暂的白屏,因为要等新词条加载完页面才能渲染。
我加了个全局的Loading遮罩,并且用Suspense包裹需要等待的组件,这样切换过程有个
过渡效果,体验好很多。实测切换时间从之前的2秒多降到了400毫秒左右。
难点与亮点分析
难点一:动态词条加载的时机控制
问题背景: 词条按需加载虽然减小了首屏体积,但会带来新问题:用户快速切换页面时,词条还没加载完页面就显示了,会出现闪烁或者显示key值的情况。
解决思路:
- 在路由守卫中预加载词条
- 使用Suspense组件处理异步加载状态
- 为关键词条设置fallback显示
- 实现词条加载的优先级队列
技术实现:
// 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
}
}
路由守卫集成:
// 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
解决方案:
// 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
}
使用示例:
<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>
亮点一:翻译管理后台
设计思路: 为了让运营人员能够独立管理翻译词条,我开发了一个可视化的翻译管理后台,主要功能包括:
- 在线编辑词条,支持批量导入导出
- 实时预览效果,避免发布后才发现问题
- 词条使用统计,识别冗余和缺失词条
- 版本管理和回滚
- 集成机器翻译API辅助
核心实现:
<!-- 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 配置与初始化
// 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. 语言切换组件
<!-- 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布局适配
// 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样式示例:
<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>
真实项目经验
经验一:语言包版本管理
在实际项目中,翻译词条会频繁更新,如何保证用户使用的是最新版本是个问题。
解决方案:
- 每个语言包文件加version字段
- 前端缓存时记录版本号
- 每次启动时对比版本,不一致则更新
- 服务端提供版本查询接口
// 版本管理示例
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)
}
}
经验二:词条命名规范
为了方便管理和避免冲突,制定了词条命名规范:
模块.页面.位置.含义
示例:
product.detail.button.addToCart // 商品详情页-按钮-加入购物车
order.list.status.pending // 订单列表-状态-待支付
user.profile.form.nickname // 用户资料-表单-昵称
common.message.success // 通用-消息-成功提示
这样命名的好处:
- 一眼就能知道词条用在哪里
- 方便按模块分包
- 避免重名冲突
- 便于批量管理
经验三:性能监控
实际项目中,我们会监控国际化相关的性能指标:
// 性能监控
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设计师对齐,让他们知道多语言会带来的布局影响。