资讯动态

Vue 3 项目多语言切换实战:vue-i18n 国际化完整指南

发布时间:2026/10/14 23:35:19 来源:尧图企业网站定制
1. 项目里为什么一定要做多语言以及 vue-i18n 的选择逻辑如果你在 Vue 3 项目里接到一个“做语言切换”的需求第一反应可能是这不就是搞个全局变量下拉框选一下把页面上的文字替换一遍吗自己写也不难吧我先说结论如果项目只有一两个页面、文案总共不超过几十条自己写确实够用。但只要你稍微往前想一步——将来要加新页面、要支持更多语言、要处理日期时间格式差异、要兼容路由切换和刷新保持状态——自己写的那套替换逻辑就会变成一座不断漏水的破房子。这也是 vue-i18n 这类国际方案在这类需求里始终是首选的根本原因它把“文案替换”这一个简单动作升级成了一套完整的“国际化”机制包括翻译文案管理、语言状态响应式、回退策略、复数规则、日期和数字格式化等。vue-i18n 在 Vue 3 时代有两个版本取向一个是传统的 Options 风格一个是 Composition API 风格。实际项目中如果项目本身就是用script setup写的组合式代码那我强烈建议直接走useI18n()这条路线它和 Vue 3 的响应式心智模型是完全对齐的。老项目从 Vue 2 迁移过来、大量使用 Options API 的可以选 legacy 模式不过新项目没必要这么干。还有一个现实问题vue-i18n 在 Vue 3 下需要配合 Vite 做依赖优化否则首次加载时偶尔会把 ESLint 或 TS 类型检查拖慢。后面我会给一个具体的配置参考处理完这些问题之后开发体验会顺畅很多。2. 从一个空项目到跑通“中文/英文切换”的完整链路这一节我直接按实战顺序走从创建 Vue 3 项目开始到页面上出现一个中英文切换按钮并生效为止。每一步我都尽量写清“为什么要这么做”而不是只丢一段让你复制的代码。2.1 安装依赖与目录组织先建一个 Vite Vue 3 TypeScript 项目。如果你已经有一个现成的项目跳过创建那一步直接装依赖npm install vue-i18n9注意版本号。vue-i18n 的 8.x 是给 Vue 2 用的Vue 3 必须用 9.x。如果直接npm install vue-i18n装到了最新版大概率没问题但如果项目里有老依赖锁定了版本就很可能装成 8.x这是新手第一坑。装完之后建议在src下建一个locales目录专门放语言包文件。目录结构我一般是这样的src/ ├── locales/ │ ├── index.ts │ ├── zh-CN.ts │ └── en-US.ts ├── i18n/ │ └── index.ts └── main.tslocales管文案数据i18n管实例创建和导出。这样拆分的好处是语言包只关心数据实例配置只关心机制后续加语言、调整配置都能各改各的不会互相污染。2.2 定义语言包的结构与规范语言包就是普通的 TS 模块导出一个对象// src/locales/zh-CN.ts export default { common: { confirm: 确认, cancel: 取消, language: 语言 }, menu: { home: 首页, about: 关于我们, contact: 联系我们 }, login: { title: 欢迎登录, username: 用户名, password: 密码, submit: 登录, success: 登录成功, failed: 登录失败请重试 } }英文包保持同样的 key 结构// src/locales/en-US.ts export default { common: { confirm: Confirm, cancel: Cancel, language: Language }, menu: { home: Home, about: About, contact: Contact }, login: { title: Welcome Back, username: Username, password: Password, submit: Sign In, success: Login Success, failed: Login failed, please try again } }这里有一个非常关键但新手几乎不会注意的原则语言包里的 key 必须语义化而不是中文直译。比如登录页标题中文包里的 key 叫login.title英文包也叫login.title两个文件结构完全一一对应。千万不能把 key 命名为login.welcomeLogin这种带着中文痕迹的写法更不要直接把中文当 key。用语义化 key 才能在后续维护里一眼看懂这个文案用在哪、什么意思也不限制内容的翻译风格。2.3 创建 i18n 实例并注册到应用接下来在i18n/index.ts里创建实例// src/i18n/index.ts import { createI18n } from vue-i18n import zhCN from ../locales/zh-CN import enUS from ../locales/en-US const i18n createI18n({ legacy: false, // 使用 Composition API 模式 locale: zh-CN, // 默认语言 fallbackLocale: zh-CN, // 回退语言 messages: { zh-CN: zhCN, en-US: enUS } }) export default i18n然后在main.ts里注册// src/main.ts import { createApp } from vue import App from ./App.vue import i18n from ./i18n const app createApp(App) app.use(i18n) app.mount(#app)到这里项目里已经可以用$t或者useI18n来取文案了。但先别急着写切换按钮我们需要把语言状态做成全局可读写的并且能持久化否则一刷新页面就回到默认中文这个“切换”是没有意义的。提示legacy: false这个配置非常关键。如果你不写它vue-i18n 默认以 legacy 模式运行Options API 风格的组件里可以用this.$t但在script setup里useI18n()拿到的是全局实例而不是组件作用域行为和预期可能不一致。新项目直接关掉 legacy统一走 Composition API。3. 语言切换的核心交互下拉切换、自动检测、持久化三件套这一节是大多数博客容易一笔带过、但实际操作中坑最多的部分。我从一个真实的页面场景出发把“切换语言”这个动作拆成三个阶段触发切换、更新语言状态、保存选择。3.1 封装一个可复用的语言切换组件先写一个最简单的语言切换器放在页面顶部导航栏里用一个原生select实现不引入任何 UI 库方便看清底层逻辑!-- src/components/LanguageSwitcher.vue -- template select :valuelocale changehandleChange option valuezh-CN中文/option option valueen-USEnglish/option /select /template script setup langts import { useI18n } from vue-i18n const { locale } useI18n() const handleChange (event: Event) { const target event.target as HTMLSelectElement locale.value target.value } /script这段代码里最关键的是locale这个 ref。在legacy: false模式下useI18n()返回的locale是一个可写的 ref改它的值全项目所有用到$t或t的地方都会立刻重新渲染。这个响应式联动是 vue-i18n 的核心能力也是它比自己写全局变量替换高出一个维度的原因。当然真实项目里很少有人用原生 select更多是封装一个带地球图标的下拉菜单。但底层逻辑一样不管是点击、滑动还是命令式调用最终都是修改locale.value。3.2 自动检测浏览器语言并设置默认值刚进网站时第一语言怎么定最理想的方案并不是写死中文而是优先读用户浏览器的语言偏好。// src/i18n/index.ts import { createI18n } from vue-i18n import zhCN from ../locales/zh-CN import enUS from ../locales/en-US function getInitialLocale() { const stored localStorage.getItem(locale) if (stored) return stored const browserLang navigator.language if (browserLang.startsWith(zh)) return zh-CN if (browserLang.startsWith(en)) return en-US return zh-CN } const i18n createI18n({ legacy: false, locale: getInitialLocale(), fallbackLocale: zh-CN, messages: { zh-CN: zhCN, en-US: enUS } }) export default i18n这里值得注意的是navigator.language返回的内容可能是zh-CN、zh-TW、en-US、en-GB这种带地区后缀的也可能是zh这种只带语言不带地区的。所以匹配的时候要先取startsWith判断语言大类而不是全等匹配。如果你直接写if (browserLang zh)很多浏览器返回的是zh-CN匹配就被跳过了。3.3 监听语言变化并写入 localStorage上面的getInitialLocale里已经读了本地存储但切换语言时还缺一个“写入”的动作。我习惯在切换组件里直接监听locale的变化写一个持久化函数import { watch } from vue import { useI18n } from vue-i18n const { locale } useI18n() watch(locale, (newLocale) { localStorage.setItem(locale, newLocale) })每次切换语言本地存储都会同步更新。下一次刷新页面getInitialLocale会先读 localStorage如果有值就直接用不会再回落到浏览器语言。这套逻辑非常直观但有一个边界情况如果用户手动清空了浏览器站点数据localStorage 也跟着没了此时会回到浏览器语言检测这其实是合理的行为。注意有些项目习惯在 store比如 Pinia里再存一份语言状态方便非组件模块读取。这种做法可行但要注意不要把 store 里的语言状态和 vue-i18n 的 locale 搞成两份数据源否则很容易出现“store 里改了但页面没反应”的问题。我的做法是始终以locale为唯一数据源store 里只存读取方法不存副本。4. 模板里的用法插值、复数、日期格式化与 HTML 文案语言切换跑通后接下来的问题是怎么在模板里优雅地使用多语言文案。这一节涉及的语法是 vue-i18n 的日常主力从简单到复杂每一个我都给出对应场景。4.1 基础文本与命名插值最简单的用法是传一个 key 给t函数template h1{{ t(login.title) }}/h1 p{{ t(message.hello, { name: 小明, time: 上午 }) }}/p /template script setup langts import { useI18n } from vue-i18n const { t } useI18n() /script对应的语言包// zh-CN.ts message: { hello: 你好{name}感谢您在{time}访问 } // en-US.ts message: { hello: Hello, {name}! Thank you for visiting in the {time} }插值语法使用的是花括号占位符{name}。这里有一个很实用的点占位符的名字可以自定义不一定要和变量名一致只要是同一套语言包里统一即可。我见过团队早期用{0}{1}这种数字占位符后来发现代码可读性很差强烈建议一开始就用有语义的单词占位符。4.2 复数与选择语法英文的复数规则是“一个苹果”和“两个苹果”在语法上是不同的中文没有这个困扰但如果你做的产品要支持英语等语言就必须处理复数。vue-i18n 内置了复数支持// 语言包 apple: 一个苹果 | {count} 个苹果 // 中文包可以不区分但英文包要这样 apple: No apples | One apple | {count} apples用法t(apple, 0) // No apples t(apple, 1) // One apple t(apple, 5) // 5 apples这个特性在装购物车数量、通知未读数这类场景非常有用。如果语言包里只有一条文本没有用|分隔vue-i18n 会直接把内容原样返回所以不涉及复数的文案可以放心写单条字符串。4.3 HTML 文案与 dangerously 使用边界有时候文案里需要带链接或者加粗文字比如“点击查看《用户协议》”其中的“用户协议”要带超链接。vue-i18n 提供了tm配合v-html的方式但这里我要特别提醒永远不要把用户输入的内容拼进翻译文案再用 v-html 渲染这是 XSS 的高发区。如果只是站内固定的协议链接、加粗强调等受信任内容可以在语言包里写 HTML// zh-CN.ts agreement: 我已阅读并同意a href/terms{terms}/a模板里这样渲染p v-htmlt(agreement, { terms: 《用户协议》 })/p但请记住一个原则能用插值解决的问题永远优先插值不要为了省事把一段完整 HTML 塞进语言包。维护语言包的人不一定是前端让翻译直接接触 HTML 标签风险非常高。4.4 日期与数字格式化这个功能容易被忽略但真正做到国际化的时候躲不开同一份日期中文看“2024年5月1日”英文看“May 1, 2024”同一个金额中文看“¥1,000.00”英文可能看“$1,000.00”。vue-i18n 提供了d和n两个函数。// 需要 i18n 配置中定义 datetimeFormats 和 numberFormats const i18n createI18n({ datetimeFormats: { zh-CN: { short: { year: numeric, month: short, day: numeric }, long: { year: numeric, month: long, day: numeric, weekday: long } }, en-US: { short: { year: numeric, month: short, day: numeric }, long: { year: numeric, month: long, day: numeric, weekday: long } } }, numberFormats: { zh-CN: { currency: { style: currency, currency: CNY } }, en-US: { currency: { style: currency, currency: USD } } } })模板里template p{{ d(new Date(), short) }}/p p{{ n(1000, currency) }}/p /template切换语言后日期格式和货币符号都会跟着变。这个功能在很多项目里不是必须的但只要你做了跨境电商、出海工具类产品这就是刚需。早一点在配置里把datetimeFormats定义好后面不会手忙脚乱。5. 从“能切换”到“好用”动态语言包与按需加载实践前面的方案把所有语言包都在createI18n初始化时一次性注册了。项目小的时候没问题但当一个应用支持四五种语言、每个语言包几百条甚至上千条文案时首包体积会明显膨胀。有一种优化思路是只加载默认语言包和当前语言包其他语言包在用户切换时再通过动态import加载。5.1 动态加载其他语言的实现方案vite 支持动态导入我们可以用import()来按需加载语言包// 简化示意 async function loadLocaleMessages(locale: string) { const messages await import(../locales/${locale}.ts) return messages.default }配合切换函数async function handleLocaleChange(localeCode: string) { if (!i18n.global.availableLocales.includes(localeCode)) { const messages await loadLocaleMessages(localeCode) i18n.global.setLocaleMessage(localeCode, messages) } locale.value localeCode localStorage.setItem(locale, localeCode) }这里用到了availableLocales来判断这个语言包是不是已经加载过了。已经加载的直接切过去没加载的先获取再切换避免重复请求资源。注意动态导入路径不能完全用变量拼接Vite 无法在构建时解析任意变量路径所以目录层级必须是静态的只能动态变换文件名部分。上面例子中../locales/${locale}.ts的写法是可行的因为目录是静态的。5.2 语言包文件的组织粒度按需加载的粒度可以继续细化。如果你的某个语言包特别大比如文档系统可以考虑把一个语言包拆成多个模块每个模块对应一个业务域再组合成一个文件。// zh-CN/index.ts import common from ./common import dashboard from ./dashboard import settings from ./settings export default { ...common, ...dashboard, ...settings }这种方式的好处是翻译人员可以按模块并行维护不会因为所有人改同一个大文件而频繁冲突。如果你的项目是多人协作且语言包量大强烈建议用这种目录化组织方式。6. 组件外使用 i18n工具函数、路由守卫、请求拦截器的场景开发中经常遇到一个尴尬在非组件的.ts文件里想拿一句文案但useI18n只能在组件里用。比如请求拦截器里要根据错误码提示用户、路由守卫里要设置页面标题。在 vue-i18n 9 中可以通过i18n.global来访问实例// src/utils/http.ts import i18n from ../i18n export function handleError(code: number) { if (code 401) { const message i18n.global.t(error.unauthorized) // 弹出提示 } }这里要注意在非组件环境里i18n.global.t拿到的语言状态是全局的跟locale保持一致。只要语言切换了这个函数读到的就是新语言的文案。如果你在组件里已经改过locale这里的t也会同步更新因为底层是同一个响应式数据源。路由守卫里设置页面标题是比较常见的实践// src/router/index.ts import i18n from ../i18n router.beforeEach((to) { const titleKey to.meta.titleKey if (titleKey) { document.title i18n.global.t(titleKey) } })但要注意一个时序问题如果语言包是按需加载的且用户切换到某个还没加载的语言时触发路由跳转i18n.global.t可能拿不到对应 key 而回退到 fallback。我的建议是路由切换前确保当前语言包已加载或者在守卫里做一次语言包预加载。7. 常见问题与排查实录我在真实项目里踩过的坑这一节整理几个高频问题每一个都是实际开发中很容易遇到、搜官方文档又不太容易快速定位的。7.1 切换语言后页面部分内容没有更新现象点了一下英文大部分文案变成英文了但某个组件的文案还是中文。排查思路先确认那个组件是不是用了useI18n()但位于v-if条件渲染的分支里条件变化后组件重新创建语言状态没来得及同步。检查该组件是否在自己的作用域里重新定义了locale比如把const { locale } useI18n()的locale赋给了某个 ref 的初始值。检查是不是把t函数的返回值保存到了普通变量里而不是模板表达式里。如果组件里写的是const title computed(() t(xxx))这是响应式的但如果写的是const title t(xxx)组件创建时取了一次值之后永远不会更新。这句话值得每个开发者在排查时先默念一遍t 函数是响应式的但 t 函数的返回值只有在模板或 computed 中使用时才具有响应性。7.2 刷新后语言回到默认值这个问题的根源几乎都是没有做 localStorage 持久化或者getInitialLocale没有被调用。还有一个隐蔽的坑是你的createI18n配置写在了setup内部组件每次创建都执行一遍直接覆盖了用户选择。解决方法是把createI18n放到模块顶层只执行一次用上面的getInitialLocale读取初始值。7.3 语言包中的 key 找不到导致控制台报错报错信息类似Cannot translate the value of keypath xxx。一般原因是语言包 key 两边没有对齐中文包有某个 key英文包漏了。vue-i18n 在开发模式下遇到缺失 key 会在控制台输出警告并且返回传入的 key 本身。这个警告建议开启不要关掉它是key对齐问题最直接的提示。如果某个 key 确实只能支持一种语言可以在语言包配置里加上missingWarn和fallbackWarn的局部控制但不建议全局关闭。7.4 TypeScript 项目里 t 函数 key 没有类型提示vue-i18n 9 是支持为每个语言包生成类型标注的但没有自动开启。要实现 key 的完整类型推断需要先做两步第一步确保语言包是 TS 模块而不是 JSON这样类型可以完整推导。第二步在i18n/index.ts里声明类型import type { DefineLocaleMessage } from vue-i18n import zhCN from ../locales/zh-CN declare module vue-i18n { export interface DefineLocaleMessage extends typeof zhCN {} }做完这一步t函数的 key 就有了完整的类型提示拼错 key 会在编译期直接报错。这个能力对大型项目的维护价值非常大强烈建议开启。我经历过几次因为 key 改名导致线上文案直接缺失的事故有了这个类型约束这类问题基本可以全部规避。7.5 语言切换导致组件意外重渲染、表单输入丢失这个问题比较隐蔽。当你切换语言时因为响应式依赖触发了整个组件树的更新如果某个表单组件恰好处于输入状态理论上它不应该丢失但如果你在某个父组件里用 v-if 根据语言重新创建了子树比如根据 language 判断渲染不同布局就会出现输入框被销毁重建、输入内容丢失的情况。解决方法是不要用语言状态作为渲染分支的条件语言切换只应触发文案变化不应触发结构变化。如果确实要按语言做不同的布局建议用独立的响应式状态管理或者把语言相关布局差异收敛到单独的组件内部。8. 经验总结这些细节让我从“能用”进到“好用”最后聊几句我自己的体会。第一点是语言包的 key 设计。这可能是整个国际化过程中最需要前期规划的部分。我的经验是key 按页面或模块划分前缀比如login.xxx、dashboard.xxx、common.xxx。千万不要按“top.title、bottom.tips”这种位置命名因为一旦后面调整位置key 就要跟着改而且翻译人员看到 key 也完全不知道这个文案在讲什么。第二点是中文和英文文案的写作风格。很多语言包翻译出来特别生硬其实是因为中文原文本身就写得很硬。比如“确认”这个词英文如果统一翻译成 “Confirm” 不是不行但很多按钮场景下 “Apply” 或 “OK” 更自然。沉浸式做一次英文体验把语言包里的生硬翻译改掉整个产品质感会明显提升。第三点是关于 vue-i18n 的版本锁定。如果你的项目是 Vue 3 Vite TypeScript建议把 vue-i18n 的版本固定到主版本号 9 以上并定期更新到最新的 minor 版本因为早期的 9.x 版本在类型推断和 Composition API 支持上确实有一些 bug。安装完跑一下构建如果出现类型报错大多数情况下升级版本就能解决。回到开头的问题语言切换是不是自己写个全局替换就行如果是一日游的练习项目那确实可以。但只要这个项目会活过三个月我建议直接上 vue-i18n它的价值不在于省去你写那几十行替换代码而在于提供了一套经过大量生产环境验证的国际化机制。你在项目中踩过的每一个坑几乎都有人替你踩过一遍并把解决方案沉淀成了这个库的功能。从把语言包文件组织好、响应式切换跑通、持久化做好到按需加载语言包、开启 TypeScript 类型约束这套链路走完你的 Vue 3 项目在语言切换这件事上就已经具备了上线能力。后面再遇到业务上的多语言扩展无非是往目录里加新文件的问题不会再动到架构层。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑