Vue项目多语言适配实战从电商网站中英文切换到国际化插件封装全流程与常见问题解决指南
做电商网站的时候,多语言这个事儿绕不开。你想想,你的用户可能是上海、北京、广州的,也可能有海外华人,甚至直接做跨境电商,语言不切换起来,这网站就跟聋子了一样,啥也感知不到。我做过好几个电商项目,从一开始用原生 JS 手动拼接,到后来封装成 Vue 插件,踩过不少坑。今天就把这套东西掰开揉碎了讲给你听。
为什么要做多语言,先搞清楚这个再动手
很多人一上来就问”用 i18n 还是直接换 JSON”,其实这个问题反了——先搞清楚你的业务场景,再选工具。
我经历过一个真实的项目,一个做进口母婴产品的电商网站,初期只有中文,后来接到海外订单,发现不少华人家长想自己选语言。老板一拍脑袋:加英文!于是前端同学直接在模板里写了两个版本的 DOM,点击切换时隐藏/显示,看起来简单,结果维护的时候痛苦得一批——一个按钮文案改了,中英文要分别改两处,漏一处就出现”中英混搭”的灾难现场,用户点进去看到一半中文一半英文,直接关网站。
这种状况说明一个问题:硬编码的多语言方案是不可维护的。你需要的是统一管理、一处修改全局生效的方案,这正是 Vue I18n 这类插件存在的意义。
从零开始的完整项目搭建
先用 Vite 建一个最基础的 Vue 3 项目,别用 Vue CLI 了,现在官方主推 Vite:
npm create vite@latest vue-i18n-ecommerce -- --template vue
cd vue-i18n-ecommerce
npm install
npm install vue-i18n@9
安装完之后,目录结构先理一理。我习惯这样组织语言文件:
src/
├── i18n/
│ ├── index.js # 插件入口
│ ├── locales/
│ │ ├── zh-CN.js # 中文语言包
│ │ └── en-US.js # 英文语言包
│ └── messages.js # 语言包汇总(可选,大型项目用)
├── views/
│ ├── Home.vue
│ ├── Product.vue
│ └── Cart.vue
├── components/
│ ├── LanguageSwitcher.vue
│ └── ProductCard.vue
└── App.vue
语言包怎么写才合理
电商网站的语言包不像翻译软件输出那样简单,你需要把文案按模块拆分。我见过有人把整个语言包写成一个巨大的 JSON,几千行,改个”加入购物车”的按钮文案要翻半天。不合理的。
// src/i18n/locales/zh-CN.js
export default {
common: {
confirm: '确认',
cancel: '取消',
submit: '提交',
loading: '加载中...',
search: '搜索',
back: '返回',
next: '下一页',
prev: '上一页',
language: '语言',
},
header: {
logo: '优品商城',
nav: {
home: '首页',
category: '分类',
cart: '购物车',
user: '我的',
},
searchPlaceholder: '搜索商品名称',
},
product: {
title: '商品详情',
price: '价格',
originalPrice: '原价',
discount: '折扣',
stock: '库存',
inStock: '有货',
outOfStock: '缺货',
addToCart: '加入购物车',
buyNow: '立即购买',
quantity: '数量',
specs: '规格',
description: '商品描述',
reviews: '用户评价',
shop: '店铺',
},
cart: {
title: '购物车',
empty: '购物车是空的',
totalPrice: '合计',
discountPrice: '优惠价格',
items: '件商品',
checkout: '去结算',
delete: '删除',
selected: '已选中',
allSelected: '全选',
},
user: {
login: '登录',
register: '注册',
profile: '个人中心',
orders: '我的订单',
address: '收货地址',
favorite: '我的收藏',
logout: '退出登录',
},
// 带变量的翻译
order: {
orderPlaced: '订单已成功提交',
orderAmount: '订单金额:{amount} 元',
orderItems: '您共购买了 {count} 件商品',
deliveryTime: '预计 {date} 送达',
// 复数形式
reviewCount: '共有 {count, plural, one {# 条评价} other {# 条评价}}',
},
error: {
networkError: '网络异常,请稍后重试',
serverError: '服务器开小差了,请稍后再试',
notFound: '页面找不到了',
permissionDenied: '您没有访问权限',
validateError: '请填写完整信息',
},
}
// src/i18n/locales/en-US.js
export default {
common: {
confirm: 'Confirm',
cancel: 'Cancel',
submit: 'Submit',
loading: 'Loading...',
search: 'Search',
back: 'Back',
next: 'Next',
prev: 'Prev',
language: 'Language',
},
header: {
logo: 'Premium Mall',
nav: {
home: 'Home',
category: 'Categories',
cart: 'Cart',
user: 'Account',
},
searchPlaceholder: 'Search products',
},
product: {
title: 'Product Details',
price: 'Price',
originalPrice: 'Original Price',
discount: 'Discount',
stock: 'Stock',
inStock: 'In Stock',
outOfStock: 'Out of Stock',
addToCart: 'Add to Cart',
buyNow: 'Buy Now',
quantity: 'Quantity',
specs: 'Specifications',
description: 'Description',
reviews: 'Reviews',
shop: 'Shop',
},
cart: {
title: 'Shopping Cart',
empty: 'Your cart is empty',
totalPrice: 'Total',
discountPrice: 'Discounted Price',
items: 'items',
checkout: 'Checkout',
delete: 'Delete',
selected: 'Selected',
allSelected: 'Select All',
},
user: {
login: 'Login',
register: 'Register',
profile: 'Profile',
orders: 'My Orders',
address: 'Addresses',
favorite: 'Wishlist',
logout: 'Logout',
},
order: {
orderPlaced: 'Order placed successfully',
orderAmount: 'Order total: {amount} CNY',
orderItems: 'You purchased {count} item(s)',
deliveryTime: 'Expected delivery: {date}',
reviewCount: 'Total {count, plural, one {# review} other {# reviews}}',
},
error: {
networkError: 'Network error, please try again later',
serverError: 'Server error, please try again later',
notFound: 'Page not found',
permissionDenied: 'Permission denied',
validateError: 'Please fill in all required fields',
},
}
注意到 order 里用了 plural 吗?这是 ICU 消息格式,Vue I18n v9 默认支持,中文的”条评价”和英文的复数处理都能搞定。很多中文开发者不知道这个功能,做商品评价数量显示的时候还在自己写 count > 1 ? '条评价' : '条评价' 的逻辑,完全没必要。
封装成 Vue 插件,项目里通用
不要每次新建组件都重新引入 createI18n,要封装成一个插件,这样任何组件、任何地方都能用。
// src/i18n/index.js
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import enUS from './locales/en-US'
// 从 localStorage 读取上次选择的语言,没有就用浏览器语言
const getInitialLocale = () => {
const saved = localStorage.getItem('preferred-locale')
if (saved && [ 'zh-CN', 'en-US' ].includes(saved)) {
return saved
}
const browserLang = navigator.language || navigator.userLanguage
if (browserLang.startsWith('zh')) return 'zh-CN'
return 'en-US'
}
const i18n = createI18n({
legacy: false, // 一定要用 Composition API 模式,legacy 模式是 v8 的老东西了
locale: getInitialLocale(),
fallbackLocale: 'en-US', // 兜底语言,中文缺翻译时自动回退到英文
messages: {
'zh-CN': zhCN,
'en-US': enUS,
},
// 全局数字格式化
numberFormats: {
'zh-CN': {
currency: {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
},
},
'en-US': {
currency: {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
},
},
},
// 全局日期格式化
datetimeFormats: {
'zh-CN': {
short: {
year: 'numeric',
month: '2-digit',
day: '2-digit',
},
},
'en-US': {
short: {
year: 'numeric',
month: 'short',
day: 'numeric',
},
},
},
})
export default i18n
插件封装好了,记得在 main.js 里挂载:
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import i18n from './i18n'
createApp(App)
.use(i18n)
.mount('#app')
语言切换组件,最容易被忽略的细节
电商网站的右上角一定有个语言切换器,这个组件看起来简单,其实坑不少。
<!-- src/components/LanguageSwitcher.vue -->
<template>
<div class="language-switcher">
<button
v-for="lang in availableLocales"
:key="lang.code"
:class="[ 'lang-btn', { active: locale === lang.code } ]"
@click="switchLocale(lang.code)"
:title="lang.label"
>
<span class="lang-flag">{{ lang.flag }}</span>
<span class="lang-label">{{ lang.label }}</span>
</button>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { locale, setLocale } = useI18n({ useScope: 'global' })
const availableLocales = [
{ code: 'zh-CN', label: '中文', flag: '🇨🇳' },
{ code: 'en-US', label: 'English', flag: '🇺🇸' },
]
const switchLocale = (code) => {
if (locale.value === code) return
setLocale(code)
// 持久化到 localStorage,下次用户再来还是记住的选择
localStorage.setItem('preferred-locale', code)
// 如果是做跨境电商,还可以同步到服务端 cookie 或用户配置
document.documentElement.lang = code
}
</script>
<style scoped>
.language-switcher {
display: flex;
gap: 4px;
}
.lang-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.lang-btn:hover {
border-color: #ff6b6b;
color: #ff6b6b;
}
.lang-btn.active {
border-color: #ff6b6b;
background: #fff5f5;
color: #ff6b6b;
font-weight: 500;
}
.lang-flag {
font-size: 14px;
}
</style>
这里有一个很重要的点:document.documentElement.lang 这个属性,很多开发者会忽略它。这个属性影响搜索引擎对页面语言的判断,也影响浏览器的自动翻译功能。切换语言后必须同步更新它,不然 Google 爬虫还是会认为你的页面是中文的。
在页面里怎么用,不只是 $t 那么简单
电商页面里的翻译场景比你想的复杂得多。
场景一:动态参数的翻译
<!-- src/views/Product.vue -->
<template>
<div class="product-detail">
<h1>{{ $t('product.title') }}</h1>
<p class="price">
<span>{{ $t('product.price') }}:</span>
<span class="current-price">{{ formatPrice(product.price) }}</span>
<span v-if="product.originalPrice" class="original-price">
¥{{ product.originalPrice }}
</span>
</p>
<p class="stock-status" :class="{ 'out-of-stock': product.stock === 0 }">
{{ product.stock > 0 ? $t('product.inStock') : $t('product.outOfStock') }}
<span v-if="product.stock > 0">({{ product.stock }})</span>
</p>
<!-- 带参数的翻译 -->
<p v-if="product.reviewCount > 0" class="reviews">
{{ $t('order.reviewCount', { count: product.reviewCount }) }}
</p>
<!-- 更复杂的参数传递 -->
<p v-if="product.estimatedDelivery" class="delivery">
{{ $t('order.deliveryTime', { date: formatDate(product.estimatedDelivery) }) }}
</p>
<div class="actions">
<button class="add-to-cart" @click="handleAddToCart">
{{ $t('product.addToCart') }}
</button>
<button class="buy-now" @click="handleBuyNow">
{{ $t('product.buyNow') }}
</button>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
const { t } = useI18n()
const props = defineProps({
product: {
type: Object,
required: true,
},
})
// 用 t 函数而不是 $t 的好处:可以在 JS 逻辑中用,不只限于模板
const formatPrice = (price) => {
return new Intl.NumberFormat(locale.value, {
style: 'currency',
currency: locale.value === 'zh-CN' ? 'CNY' : 'USD',
}).format(price)
}
const handleAddToCart = () => {
// 切换语言后,Toast 提示也要跟着切换
showToast(t('cart.addedToCart', { name: props.product.name }))
}
</script>
注意我用了 t 函数(Composition API 方式),而不只是模板里的 $t。这是因为在 JS 逻辑里(比如请求接口失败后的错误提示、Toast 消息)也需要翻译,$t 只能在模板里用。
场景二:列表类数据的翻译
电商网站经常有状态字段需要翻译,比如订单状态、商品分类、颜色规格等。这些不能硬编码在模板里,要用翻译函数动态处理。
// src/utils/status.js - 状态翻译工具
import { useI18n } from 'vue-i18n'
export const useStatusText = () => {
const { t } = useI18n()
const orderStatus = {
pending: () => t('order.status.pending'),
paid: () => t('order.status.paid'),
shipped: () => t('order.status.shipped'),
completed: () => t('order.status.completed'),
cancelled: () => t('order.status.cancelled'),
refunded: () => t('order.status.refunded'),
}
const productCategory = {
electronics: () => t('category.electronics'),
clothing: () => t('category.clothing'),
food: () => t('category.food'),
home: () => t('category.home'),
beauty: () => t('category.beauty'),
}
return { orderStatus, productCategory }
}
对应的语言包:
// zh-CN.js 补充
order: {
// ... 之前的内容
status: {
pending: '待付款',
paid: '已付款',
shipped: '已发货',
completed: '已完成',
cancelled: '已取消',
refunded: '已退款',
},
},
category: {
electronics: '电子产品',
clothing: '服装鞋帽',
food: '食品饮料',
home: '家居用品',
beauty: '美妆护肤',
},
这样在模板里就可以这样用:
<template>
<span :class="`status-${order.status}`">
{{ statusText.orderStatus[order.status]() }}
</span>
</template>
<script setup>
import { useStatusText } from '@/utils/status'
const { orderStatus } = useStatusText()
// 注意:这里用函数调用是因为状态值是动态的
</script>
等等,上面这个写法有个小问题——orderStatus 是个对象,值是函数,在模板里调用函数虽然可以,但不太优雅。更好的做法是在计算属性里处理好:
<script setup>
import { computed } from 'vue'
import { useStatusText } from '@/utils/status'
const { orderStatus } = useStatusText()
const props = defineProps({
order: { type: Object, required: true },
})
const statusText = computed(() => {
return orderStatus[props.order.status]()
})
</script>
<template>
<span :class="`status-${order.status}`">{{ statusText }}</span>
</template>
插件封装进阶:支持动态加载语言包
如果你的电商网站将来要支持日语、韩语、阿拉伯语,一个一个往 messages 里塞肯定不行。要做成动态加载的:
// src/i18n/index.js - 动态加载版本
import { createI18n } from 'vue-i18n'
const getInitialLocale = () => {
const saved = localStorage.getItem('preferred-locale')
if (saved) return saved
const browserLang = navigator.language || navigator.userLanguage
if (browserLang.startsWith('zh')) return 'zh-CN'
return 'en-US'
}
// 预加载默认语言包,其他语言按需加载
const loadLocale = async (locale) => {
try {
// 从 API 动态获取,或者从本地文件加载
const module = await import(`./locales/${locale}.js`)
return module.default
} catch (error) {
console.warn(`Failed to load locale: ${locale}`, error)
return null
}
}
const initI18n = async () => {
const initialLocale = getInitialLocale()
// 加载默认语言包
const defaultMessages = await loadLocale(initialLocale)
const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: 'en-US',
messages: {
[initialLocale]: defaultMessages,
},
})
// 返回一个动态加载的方法
const addLocale = async (locale, messages) => {
if (!i18n.global.messages.value[locale]) {
i18n.global.setLocaleMessage(locale, messages)
}
}
return { i18n, addLocale }
}
export default initI18n
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import initI18n from './i18n'
async function bootstrap() {
const { i18n, addLocale } = await initI18n()
// 预加载其他可能用到的语言
addLocale('en-US', await import('./i18n/locales/en-US.js').then(m => m.default))
createApp(App)
.use(i18n)
.mount('#app')
}
bootstrap()
这样做的好处是:页面初始加载只加载一种语言,用户切换到日语时再动态加载日语包,首屏加载速度不会受影响。对于电商网站来说,首屏速度直接影响转化率,这个优化很值得。
常见问题及解决方案
问题一:切换语言后页面闪烁
这是最经典的 bug。用户切换语言后,部分 DOM 还没更新完,页面会短暂显示旧语言的内容。原因是 setLocale 是异步的,但模板渲染是同步触发的。
解决方案:给切换操作加一个 loading 状态,或者用 nextTick 确保更新完成:
import { nextTick } from 'vue'
const switchLocale = async (code) => {
if (locale.value === code) return
isSwitching.value = true
await setLocale(code)
localStorage.setItem('preferred-locale', code)
document.documentElement.lang = code
await nextTick()
isSwitching.value = false
}
模板里加一个全屏的遮罩:
<template>
<div v-if="isSwitching" class="locale-switching-overlay">
<div class="spinner"></div>
</div>
<!-- 其余内容 -->
</template>
问题二:路由国际化怎么做
电商网站的路由一般有两种处理方式:
方案 A:每个语言独立的路由前缀,比如 /zh-CN/product/123 和 /en-US/product/123。
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/:locale?', // 可选的语言前缀
component: () => import('@/views/Home.vue'),
children: [
{ path: '', component: () => import('@/views/Home.vue') },
{ path: 'product/:id', component: () => import('@/views/Product.vue') },
{ path: 'cart', component: () => import('@/views/Cart.vue') },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫里处理语言一致性
router.beforeEach((to, from) => {
const { i18n } = router.app.config.globalProperties
const locale = to.params.locale || i18n.global.locale.value
if (to.params.locale && to.params.locale !== i18n.global.locale.value) {
// 语言不匹配,重定向到正确语言的路由
const newPath = to.path.replace(/^\/[^/]+/, `/${locale}`)
return { path: newPath, replace: true }
}
})
export default router
方案 B:用 query 参数控制语言,比如 /product/123?lang=en。这个方案对 SEO 不友好,但不需要改路由结构,适合内部工具型网站。电商网站建议用方案 A。
问题三:第三方 UI 组件库的国际化
你用的 Element Plus、Ant Design Vue 这类组件库,它们自己也有一套国际化配置。这两个 i18n 要共存,不能冲突。
// src/i18n/index.js 里合并第三方组件库的语言
import { createI18n } from 'vue-i18n'
import zhCN from 'element-plus/dist/locale/zh-cn.mjs'
import enUS from 'element-plus/dist/locale/en-us.mjs'
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'en-US',
messages: {
'zh-CN': {
...zhCN, // 先引入组件库的语言
...zhLocale, // 再覆盖自己的业务语言(业务语言优先级更高)
},
'en-US': {
...enUS,
...enLocale,
},
},
})
注意顺序——组件库的在前,业务语言的在后,这样同名 key 会被业务语言覆盖,避免冲突。
问题四:SEO 和搜索引擎优化
电商网站最看重 SEO。搜索引擎需要知道每个 URL 对应哪种语言。做法是:
<html lang>属性:前面已经提到了,切换语言时必须更新hreflang标签:在index.html的<head>里声明:
<link rel="alternate" hreflang="zh-CN" href="https://example.com/zh-CN/" />
<link rel="alternate" hreflang="en-US" href="https://example.com/en-US/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />
- 每个语言独立的 Sitemap:分别提交
sitemap-zh-CN.xml和sitemap-en-US.xml,里面包含对应语言的 URL 列表 - Canonical 标签:防止重复内容,每个语言页面指向自己的 URL
问题五:RTL(从右到左)语言的支持
如果你的电商网站要做中东市场,阿拉伯语是从右往左写的,CSS 布局要整体翻转。Vue I18n 本身不处理这个,但你可以在切换语言时自动切换 dir 属性:
const rtlLocales = ['ar', 'he', 'fa'] // RTL 语言代码
const switchLocale = (code) => {
setLocale(code)
const isRTL = rtlLocales.some(l => code.startsWith(l))
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
document.documentElement.lang = code
localStorage.setItem('preferred-locale', code)
}
CSS 里用逻辑属性(logical properties)代替物理属性,这样 RTL 时不用额外写一套样式:
/* 用 margin-inline-start 代替 margin-left */
.card {
margin-inline-start: 16px; /* LTR 时是左边距,RTL 时自动变成右边距 */
text-align: start; /* 文字对齐方向跟随文字方向 */
}
电商场景下的实战技巧
技巧一:价格数字的本地化格式化
中文里价格写成 ¥1,234.56,英文里可能是 $1,234.56 或者 1,234.56 USD。用 Intl.NumberFormat 来处理,不要自己写格式化逻辑:
const formatPrice = (price, locale) => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: locale === 'zh-CN' ? 'CNY' : 'USD',
minimumFractionDigits: 2,
}).format(price)
}
// 在组件里
const priceText = computed(() => formatPrice(product.value.price, locale.value))
技巧二:日期时间的本地化
电商网站里发货时间、收货时间、促销截止时间都需要根据语言本地化显示:
const formatDate = (date, locale) => {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(date))
}
// 中文:2024年3月15日
// 英文:March 15, 2024
技巧三:多语言图片资源
有些电商网站会根据语言切换 Banner 图片、图标等资源:
<template>
<img
:src="locale === 'zh-CN' ? bannerZh : bannerEn"
:alt="t('banner.alt')"
class="hero-banner"
/>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import bannerZh from '@/assets/banners/hero-zh.jpg'
import bannerEn from '@/assets/banners/hero-en.jpg'
const { locale, t } = useI18n({ useScope: 'global' })
</script>
给小朋友也能听懂的总结
把多语言适配想象成你家里来了不同国家的朋友。中文朋友来了,你说中文;英文朋友来了,你说英文。但你的房子(网站)不能因为来了不同语言的朋友就拆了重建——你需要准备多套家具标签(语言包),贴上中文标签和英文标签,谁来了就摘哪个。语言切换器就是一个按钮,按一下把标签换掉就行。
核心就三件事:语言包要模块化(别全塞一个文件)、切换要持久化(记住用户的偏好)、动态内容要跟着语言走(价格、日期、状态文字)。
把这三个做到位,你的电商网站多语言适配就基本没问题了。剩下的就是根据实际业务慢慢补充翻译内容,一步步把语言包做厚实。
